Back to Basics: Writing Unit Tests First

The value of unit tests and TDD are well documented. Unfortunately, it’s still daunting to start practicing it. Here’s a primer.

Our desired code

We need to write a method that:

  • takes a string as an argument
  • reverses the string
  • saves the string to a file on the hard drive
  • returns the reversed string

Our tool and techniques

We’ll use RSpec to test our method’s output. We’ll use red/green/refactor methodology. We’ll write unit tests that are fast, isolated, repeatable, self-verifying, and timely.

Starting with desired end state

What’s a good first test we can write? We know that given a particular input (a string “example string”) should product a given output (the string “gnirts elpmaxe”):

describe StringChanger do
  it 'reverses strings' do
    string_changer = StringChanger.new

    reversed_string = string_changer.reverse_and_save('example string')

    expect(reversed_string).to eq 'gnirts elpmaxe'
  end
end

Now let’s run our test. We’ll use the documentation format, which is more verbose, but for the sake of this example will give us more information about the tests:

% rspec string_changer_spec.rb --format documentation

We get the following output:

string_changer_spec.rb:3:in `<top (required)>':
uninitialized constant StringChanger (NameError)

The test is telling us to create a StringChanger class. Let’s write it:

class StringChanger
end

When we run our test, we get this output:

StringChanger
  reverses strings (FAILED - 1)

Failures:

  1) StringChanger reverses strings
     Failure/Error: reversed_string = string_changer.reverse_and_save('example string')
     NoMethodError:
       undefined method `reverse_and_save'
       for #<StringChanger:0x007fafe5c66ff0>
     # ./string_changer_spec.rb:8:in `block (2 levels) in <top (required)>'

Finished in 0.00032 seconds
1 example, 1 failure

Failed examples:

rspec ./string_changer_spec.rb:5 # StringReverAndSave reverses strings

Our test is telling us we need to write a method called reverse_and_save. Let’s write it:

class StringChanger
  def reverse_and_save(string_to_reverse)
  end
end

We run our test again:

StringChanger
  reverses strings (FAILED - 1)

Failures:

  1) StringChanger reverses strings
     Failure/Error: expect(reversed_string).to eq 'gnirts elpmaxe'

       expected: "gnirts elpmaxe"
            got: nil

       (compared using ==)
     # ./string_changer_spec.rb:10:in `block (2 levels) in <top (required)>'

Finished in 0.00301 seconds
1 example, 1 failure

Failed examples:

rspec ./string_changer_spec.rb:5 # StringChanger reverses strings

The simplest thing that could work

Our test is telling us our method’s logic does match expectations. Let’s make it pass:

class StringChanger
  def reverse_and_save(string_to_reverse)
    'gnirts elpmaxe'
  end
end

We run our test:

StringChanger
  reverses strings

Finished in 0.00079 seconds
1 example, 0 failures

Refactoring

We have a passing test, but there is a smell we can refactor out. Currently we are returning a hardcoded value. This obviously will not work. Let’s do a little refactor and then depend on our tests to tell us if things are still good:

class StringChanger
  def reverse_and_save(string_to_reverse)
    string_to_reverse.reverse
  end
end

We run our test:

StringChanger
  reverses strings

Finished in 0.00079 seconds
1 example, 0 failures

Our test still passes but our method is not fully functional until we write our reversed string to a file.

Stubbing dependencies such as the file system

We’re writing a unit test, which should be isolated. We don’t want to create a new file every time we run our test, so we stub it out.

The RSpec Mocks library gives us the ability to send the stub message to an object and replace the method we’re stubbing with a dummy method that doesn’t do anything:

it 'saves string to the file system' do
  string_changer = StringChanger.new
  File.stub(:write)

  string_changer.reverse_and_save('example string')

  expect(File).
    to have_received(:write).
    with('example_file', 'gnirts elpmaxe').
    once
end

We expect the File object to receive the message write one time with the arguments 'example_file' and our reversed string by using the stub’s ability to report on the messages it’s received. This is called white-box testing.

We run our test:

StringChanger
  reverses strings
  saves string to the file system (FAILED - 1)

Failures:

  1) StringChanger saves string to the file system
     Failure/Error: expect(File).to have_received(:write).with('example_file', 'gnirts elpmaxe').once
       (<File (class)>).write("example_file", "gnirts elpmaxe")
           expected: 1 time with arguments: ("example_file", "gnirts elpmaxe")
           received: 0 times with arguments: ("example_file", "gnirts elpmaxe")
     # ./string_changer_spec.rb:19:in `block (2 levels) in <top (required)>'

Finished in 0.00106 seconds
2 examples, 1 failure

Failed examples:

rspec ./string_changer_spec.rb:13 # StringChanger saves string to the file system

Our test is telling us to invoke the File object’s write method. Let’s write it:

class StringChanger
  def reverse_and_save(string_to_reverse)
    File.write('example_file', string_to_reverse.reverse)
  end
end

We run our test:

StringChanger
  reverses strings (FAILED - 1)
  saves string to the file system

Failures:

  1) StringChanger reverses strings
     Failure/Error: expect(reversed_string).to eq 'gnirts elpmaxe'

       expected: "gnirts elpmaxe"
            got: 14

       (compared using ==)
     # ./string_changer_spec.rb:10:in `block (2 levels) in <top (required)>'

Finished in 0.00136 seconds
2 examples, 1 failure

Failed examples:

rspec ./string_changer_spec.rb:5 # StringChanger reverses strings

We made our new test pass but broke our old test because we are no longer returning the reversed string. This shows the benefit of old tests helping guard against regressions in our code.

Let’s fix our original test:

class StringChanger
  def reverse_and_save(string_to_reverse)
    string_to_reverse.reverse.tap do |reversed_string|
      File.write('example_file', reversed_string)
    end
  end
end

We run our test:

StringChanger
  reverses strings
  saves string to the file system

Finished in 0.0011 seconds
2 examples, 0 failures

We now have two passing tests and a fully functional method. We did it writing the tests first. This is a simple example, but by following this process, we can conquer any sized programming task. Writing the tests first will also help us write testable code and help us keep our methods small.

What’s next?

If you found this useful, you might also enjoy: