Twitter GitHub Facebook Instagram dirv.me

Daniel Irvine on building software

Walkthrough: Test-driven development of a Qt app in Ruby, part 2

15 June 2014

This is the second part of a series looking at using TDD to build a desktop GUI app in Ruby using Qt.

In part 1 we created our first two tests and the corresponding implementation. Now that both those tests are green, it’s time to refactor our test code.

Here’s our current spec/click_counter_spec.rb file.

require 'qt'
require 'spec_helper'
require 'click_counter'

describe ClickCounter do

  it 'displays a window' do
    app = Qt::Application.new(ARGV)
    window = ClickCounter.new 
    expect(window).to be_kind_of(Qt::Widget)
    expect(window.parent).to be_nil
  end

  it 'shows 0 before any clicks are made' do
    app = Qt::Application.new(ARGV)
    window = ClickCounter.new
    expect(window).to have_label_with_text('0')
  end
end

Let’s change this around to get rid of the repeated code.

describe ClickCounter do

  before(:all) do
    app = Qt::Application.new(ARGV)
  end

  let(:window) { ClickCounter.new }

  it 'displays a window' do
    expect(window).to be_kind_of(Qt::Widget)
    expect(window.parent).to be_nil
  end

  it 'shows 0 before any clicks are made' do
    expect(window).to have_label_with_text('0')
  end
end

Here we use an RSpec before block to ensure we’re constructing a Qt::Application only once for this describe block, and we use the let syntax to remove test duplication.

Click the button

Let’s move on to the next test. This time, we want to test that when a button is clicked, the label changes to display the text “1”. Add the following test to the describe block.

it 'changes 0 to 1 after one click' do
    find_widget('clicker').click
    expect(window).to have_label_with_text('1')
    expect(window).to_not have_label_with_text('0')
end

For this to work, you’ll also need to define the method find_widget. Put it in the describe block too:

def find_widget(name)
    window.children.find{ |w| w.object_name==name }
end

There’s a couple of important points here. First, we’re again searching through the window’s children to find an object, but this time we’re matching on object_name. This is a new requirement we’re setting on the class under test. We need to ensure we create a clickable widget with a name of “clicker”. Naming objects for the purpose of testing is a very useful technique that we’ll be using again in this series.

The second important point is that we not only expect for a label of “1” to appear but that a label of “0” does not appear. By stating this we’re explicitly stating that the the 0 changes to 1, not just that a 1 appears.

Making it green

Let’s make this test green. Change the ClickCounter class to read as follows.

class ClickCounter < Qt::Widget
  
  slots :click

  def initialize
    super(nil)
    @count_label = Qt::Label.new('0')
    layout = Qt::VBoxLayout.new
    layout.addWidget(@count_label)
    button = Qt::PushButton.new
    button.object_name = 'clicker'
    connect(button, SIGNAL(:clicked), self, SLOT(:click))
    layout.addWidget(button)
    setLayout(layout)
  end
  
  def click
    @count_label.text = '1'
  end
end

Any Qt event will emit a signal that can be connected to a slot. In this case, we connect the :clicked signal on an instance of Qt::PushButton.new to the :click slot on the ClickCounter class. The name of the slot must match a method on the class which then gets called as soon as the signal is fired.

Our test code is not testing that the signal and slot are connected correctly. Our test calls the ClickCounter#click method directly, bypassing the signal class. You could argue that emitting the signal is a more complete technique, and you’d be right. We’ll look at how to do this in the next post in this series.

The test is now green. We’ve written the minimum amount of code to make it pass. We’ll need a final test to make this program pass the user’s original requirement.

it 'shows 2 after two clicks' do
  find_widget('clicker').click
  find_widget('clicker').click
  expect(window).to have_label_with_text('2')
end

That test can be made to pass simply by changing the click method to read as follows.

def click
  @count_label.text = @count_label.text.to_i + 1 
end

Let’s take a look at our test results and coverage.

....

Finished in 0.42911 seconds (files took 0.21245 seconds to load)
4 examples, 0 failures
Coverage report generated for RSpec to /QtBindingsTdd/coverage. 15 / 15 LOC (100.0%) covered.

Nice work!

Red, green, refactor

We’ve made our tests pass, now let’s refactor. I think ClickCounter would be clearer with its initializer split out into shorter methods with clearer names, and also with the introduction of an integer variable to store the current click count. I prefer using that than to converting a string to an integer and back again each time a click is made. Below is my final refactored code.

class ClickCounter < Qt::Widget
  
  slots :click

  def initialize
    super(nil)
    layout = Qt::VBoxLayout.new
    layout.addWidget(create_label)
    layout.addWidget(create_button)
    setLayout(layout)
  end
  
  def click
    update_label
  end
  
  def create_label
    @count = 0
    @count_label = Qt::Label.new(@count.to_s)
  end

  def update_label
    @count += 1
    @count_label.text = @count.to_s
  end
  
  def create_button
    button = Qt::PushButton.new
    button.object_name = 'clicker'
    connect(button, SIGNAL(:clicked), self, SLOT(:click))
    button
  end

end

RSpec shared contexts

There’s one final but important refactor. The find_widget method in our tests will be useful for all Qt tests, not just the ones in this class. Let’s pull it out into a shared context, which is a little like a base class for describe blocks. We can put the app initialization code in there, too. Open up spec/qt_helpers.rb and add the following.

RSpec.shared_context :qt do

  before(:all) do
    @app = Qt::Application.new(ARGV)
  end

  def find_widget(parent, name)
    parent.children.find{ |w| w.object_name==name }
  end

end

Remove that code from click_counter_spec.rb and in its place add the following single line to the top of the describe block.

  include_context :qt

You’ll need to change the calls to find_widget to now include the parent parameters, like this:

find_widget(window, 'clicker')

In the next post I’ll cover stubbing out methods in order to test showing the window, and we’ll actually run the application.

About the author

Daniel Irvine is a software craftsman at 8th Light, based in London. These days he prefers to code in Clojure and Ruby, despite having been a C++ and C# developer for the majority of his career.

For a longer bio please see danielirvine.com. To contact Daniel, send a tweet to @d_ir or use the comments section below.

Twitter GitHub Facebook Instagram dirv.me