Enrique Comba Riepenhausen

🚀 Test Driven Development – in 5 minutes 🚀

October 6, 2024
8 min read
No tags available

Testing leads to failure, and failure leads to understanding.

~ Burt Rutan

A few months ago I was looking at the state of TDD courses. I wanted to know a few things: How many people do actually teach TDD? How good is the material?

I was disheartened to find out that we are still not teaching Test Driven Development, but rather the Test Driven Development Cycle (red, green, refactor).

You see, to test drive your code, you need a set of skills that will allow you to use this practice:

  • Code Design (design patterns and the like)
  • Evolutionary Design

If you don’t understand these techniques you will do a poor job test driving any code!

Writing tests is easy. Testing after the fact is easy. Test Driven Development (or as some people like to call it now Test First Development) is meant as a code design tool, where we design our system as we go. The “tests” are just a side-effect of our design efforts–when we write a test and it passes we just validated our design desissions.

In order to be able to design out of thin air, you will have to have “some” knowledge about code design.

You wanted to know how to TDD in 5 minutes, right?

For this post I am going to use the Ruby Programming Language as it is a very expressive language that won’t be in the way and we can concentrate on what we are doing, rather than focusing on the intricancies of the language. I’m also using the inbuild testing library found in Ruby instead of a 3rd party testing library (which are better if you are working on a realy world product).

Here it goes:

def test_can_release_bike
  bike = 'a bike'
  docking_station = DockingStation.new(bike)
 
  assert_equal('a bike', docking_station.release_bike)
end

Write a failing test

Running the test: Fails!
ecomba [tdd_with_boris_bikes_ruby] % ruby docking_station_test.rb
Loaded suite docking_station_test
Started
E
=======================================================================================================================
Error: test_can_release_a_bike(TestDockingStation): NameError: uninitialized constant TestDockingStation::DockingStation
docking_station_test.rb:5:in `test_can_release_a_bike'
docking_station_test.rb:6:in `test_can_release_a_bike'
     3: class TestDockingStation < Test::Unit::TestCase
     4:   def test_can_release_a_bike
     5:     bike = 'a bike'
  => 6:     docking_station = DockingStation.new(bike)
     7:
     8:     assert_equal('a bike', docking_station.release_bike)
     9:   end
=======================================================================================================================
Finished in 0.000448 seconds.
-----------------------------------------------------------------------------------------------------------------------
1 tests, 0 assertions, 0 failures, 1 errors, 0 pendings, 0 omissions, 0 notifications
0% passed
-----------------------------------------------------------------------------------------------------------------------
2232.14 tests/s, 0.00 assertions/s
class DockingStation
  def initialize(bike)
    @bike = bike
  end
 
  def release_bike
    @bike
  end
end

Write enough code to make the test pass

Running the test: Passes!
Loaded suite
Started
Finished in 0.000215 seconds.
-----------------------------------------------------------------------------------------------------------------------
1 tests, 1 assertions, 0 failures, 0 errors, 0 pendings, 0 omissions, 0 notifications
100% passed
-----------------------------------------------------------------------------------------------------------------------
9302.33 tests/s, 9302.33 assertions/s
class DockingStation
  def initialize(bike)
    @bike = bike
  end
 
  def release_bike
    # Why did we do this? Because we assumed that after releasing
    # the bike there shouldn't be a bike in the DockingStation,
    # but where is the test for that? 🤔
    @bike.tap { @bike = nil }
  end
end

Refactor

If you are doing this you are not doing TDD.

“Wait, what? I wrote a test first, I ran the test, wrote the code, and then, once it was passing, I refactored! What’s wrong about that?”

Let’s look into an example and maybe I can convince/show you how to do this right. It’s going to take more than 5 minutes though, I appologise. If you only wanted the 5 minute rundow, we are done here and you can go do something else, I won’t take more of your time. If on the other hand you want to see a better way, continue reading.

Let’s start by looking at this little requirement – it’s kept simple, for the moment, so we can build up our knowledge slowly. You will have noticed some of the words are highlighted. These are nouns and verbs, which will help us figure out the main concepts for this functionality.

Releasing bikes
Bikes are parked in docking stations. When you want your bike,
you can release the bike from the docking station.

From those two sentences we can imagine a DockingStation object that can release a bike. We don’t want to think about the bike right now as we want to concentrate on the DockingStation first (we have the courage of delaying the Bike definition till later).

We will start writing a test (on the left hand side), which will drive the code (on the right hand side).

docking_station_test.rb
require "test/unit"
 
class TestDockingStation < Test::Unit::TestCase
  def test_can_release_a_bike
    bike = 'a bike'
    docking_station = DockingStation.new(bike)
 
    assert_equal('a bike', docking_station.release_bike)
  end
end

When running this test you will notice something, it failed! Well, not really, it actually threw an error, meaning our test (or the intent of testing) wasn’t even run!

What do we do now? There is a very powerful concept I learned… decades ago (I wanted to say a few years ago, but no, I am old now): Change the message

How do we do this?

Running the test: Error!
ecomba [tdd_with_boris_bikes_ruby] % ruby docking_station_test.rb
Loaded suite docking_station_test
Started
 
E
=======================================================================================================================
Error: test_can_release_a_bike(TestDockingStation): NameError: uninitialized constant TestDockingStation::DockingStation
docking_station_test.rb:5:in `test_can_release_a_bike'
docking_station_test.rb:6:in `test_can_release_a_bike'
     3: class TestDockingStation < Test::Unit::TestCase
     4:   def test_can_release_a_bike
     5:     bike = 'a bike'
  => 6:     docking_station = DockingStation.new(bike)
     7:
     8:     assert_equal('a bike', docking_station.release_bike)
     9:   end
=======================================================================================================================
Finished in 0.000448 seconds.
-----------------------------------------------------------------------------------------------------------------------
1 tests, 0 assertions, 0 failures, 1 errors, 0 pendings, 0 omissions, 0 notifications
0% passed
-----------------------------------------------------------------------------------------------------------------------
2232.14 tests/s, 0.00 assertions/s

If you look at the error message, what is the first error message that we see?

That’s right NameError: unitialized constant TestDockingStation::DockingStation.

Remember, just change the message!

Okay, let’s address this error message (writing just enough code for it not to fail the same way) and run the test again.

docking_station_test.rb
require "test/unit"
 
class DockingStation
end
 
class TestDockingStation < Test::Unit::TestCase
  def test_can_release_a_bike
    bike = 'a bike'
    docking_station = DockingStation.new(bike)
 
    assert_equal('a bike', docking_station.release_bike)
  end
end

The message has changed!

This time we have an ArgumentError. This is because the DockingStation expects one argument (the bike) when we initialize it!

Running the test: Error!
ecomba [tdd_with_boris_bikes_ruby] % ruby docking_station_test.rb
Loaded suite docking_station_test
Started
E
=======================================================================================================================
Error: test_can_release_a_bike(TestDockingStation): ArgumentError: wrong number of arguments (given 1, expected 0)
docking_station_test.rb:9:in `initialize'
docking_station_test.rb:9:in `new'
docking_station_test.rb:9:in `test_can_release_a_bike'
      6: class TestDockingStation < Test::Unit::TestCase
      7:   def test_can_release_a_bike
      8:     bike = 'a bike'
  =>  9:     docking_station = DockingStation.new(bike)
     10:
     11:     assert_equal('a bike', docking_station.release_bike)
     12:   end
=======================================================================================================================
Finished in 0.000475 seconds.
-----------------------------------------------------------------------------------------------------------------------
1 tests, 0 assertions, 0 failures, 1 errors, 0 pendings, 0 omissions, 0 notifications
0% passed
-----------------------------------------------------------------------------------------------------------------------
2105.26 tests/s, 0.00 assertions/s

Let’s add the initialization code to the DockingStation and run the tests again.

docking_station_test.rb
require "test/unit"
 
class DockingStation
  def initialize(bike)
  end
end
 
class TestDockingStation < Test::Unit::TestCase
  def test_can_release_a_bike
    bike = 'a bike'
    docking_station = DockingStation.new(bike)
 
    assert_equal('a bike', docking_station.release_bike)
  end
end

Progress, our message changed again!

This time it’s a NoMethodError: undefined method 'release_bike', which makes sense, our DockingStation class has no method release_bike (as a matter of fact, it doesn’t have any methods, apart from initialize).

Running the test: Error!
ecomba [tdd_with_boris_bikes_ruby] % ruby docking_station_test.rb
Loaded suite docking_station_test
Started
E
=======================================================================================================================
Error: test_can_release_a_bike(TestDockingStation): NoMethodError: undefined method `release_bike'
for an instance of DockingStation
docking_station_test.rb:13:in `test_can_release_a_bike'
     10:     bike = 'a bike'
     11:     docking_station = DockingStation.new(bike)
     12:
  => 13:     assert_equal('a bike', docking_station.release_bike)
     14:   end
     15: end
=======================================================================================================================
Finished in 0.000584 seconds.
-----------------------------------------------------------------------------------------------------------------------
1 tests, 0 assertions, 0 failures, 1 errors, 0 pendings, 0 omissions, 0 notifications
0% passed
-----------------------------------------------------------------------------------------------------------------------
1712.33 tests/s, 0.00 assertions/s

Making the tests finally pass with one last message change!

docking_station_test.rb
require "test/unit"
 
class DockingStation
  def initialize(bike)
    @bike = bike
  end
 
  def release_bike
    @bike
  end
end
 
class TestDockingStation < Test::Unit::TestCase
  def test_can_release_a_bike
    bike = 'a bike'
    docking_station = DockingStation.new(bike)
 
    assert_equal('a bike', docking_station.release(bike))
  end
end

Our test passed! 🥳

This final message change made the tests pass. You might not have noticed, but we moved to fast there. Instead of just adding the release_bike method, we implemented the whole thing. This is bad! But I wanted to show you as you will be tempted to make such decissions when working on production code. Taking bigger steps is a slipery slope.

Running the test: Passes!
ecomba [tdd_with_boris_bikes_ruby] % ruby docking_station_test.rb
Loaded suite docking_station_test
Started
Finished in 0.000192 seconds.
-----------------------------------------------------------------------------------------------------------------------
1 tests, 1 assertions, 0 failures, 0 errors, 0 pendings, 0 omissions, 0 notifications
100% passed
-----------------------------------------------------------------------------------------------------------------------
5208.33 tests/s, 5208.33 assertions/s

In an example like this one, where the code is very easy to understand it seems almost silly to do all these little steps, but trust me, it pays off.

So, let’s rewind then and do this properly, shall we?

Rewinding – Baby steps are king!

docking_station_test.rb
require "test/unit"
 
class DockingStation
  def initialize(bike)
  end
 
  def release_bike
  end
end
 
class TestDockingStation < Test::Unit::TestCase
  def test_can_release_a_bike
    bike = 'a bike'
    docking_station = DockingStation.new(bike)
 
    assert_equal('a bike', docking_station.release_bike)
  end
end

The message has changed!

And have you noticed something? This is actually the first time our test ran and failed, which is what we wanted it to do all along!

I cannot stress enough how important it is to take this tiny baby steps when we code!

Running the test: Fails!
ecomba [tdd_with_boris_bikes_ruby] % ruby docking_station_test.rb
Loaded suite docking_station_test
Started
F
=======================================================================================================================
Failure: test_can_release_a_bike(TestDockingStation)
docking_station_test.rb:16:in `test_can_release_a_bike'
     13:     bike = 'a bike'
     14:     docking_station = DockingStation.new(bike)
     15:
  => 16:     assert_equal('a bike', docking_station.release_bike)
     17:   end
     18: end
<"a bike"> expected but was
<nil>
 
diff:
? "a bike"
? n    l
? ????    ???
=======================================================================================================================
Finished in 0.003932 seconds.
-----------------------------------------------------------------------------------------------------------------------
1 tests, 1 assertions, 1 failures, 0 errors, 0 pendings, 0 omissions, 0 notifications
0% passed
-----------------------------------------------------------------------------------------------------------------------
254.32 tests/s, 254.32 assertions/s

Oh, yes, we are not returning the bike object. That shouldn’t be too dificult to fix…

docking_station_test.rb
require "test/unit"
 
class DockingStation
  def initialize(bike)
    @bike = bike
  end
 
  def release_bike
    @bike
  end
end
 
class TestDockingStation < Test::Unit::TestCase
  def test_can_release_a_bike
    bike = 'a bike'
    docking_station = DockingStation.new(bike)
 
    assert_equal('a bike', docking_station.release(bike))
  end
end

Our test passed! 🥳

This time they passed for real, after we took our baby steps and we corrected that pesky nil.

One of the things you need to fight against when test driving your code is the temptation to jump over a step. You will make mistakes. Some mistakes you will find with ease. Some mistakes will eat you for breakfast.

If you manage to keep disciplined and work through your baby steps your chances of overseeing something will be vastly reduced.

Running the test: Passes!
ecomba [tdd_with_boris_bikes_ruby] % ruby docking_station_test.rb
Loaded suite docking_station_test
Started
Finished in 0.000192 seconds.
-----------------------------------------------------------------------------------------------------------------------
1 tests, 1 assertions, 0 failures, 0 errors, 0 pendings, 0 omissions, 0 notifications
100% passed
-----------------------------------------------------------------------------------------------------------------------
5208.33 tests/s, 5208.33 assertions/s

Many TDD instructors will tell you: “If the tests are green you have to refactor!” This isn’t necessarily true.

We do refactor our code only if and when there is a need to do so.

Right now we could argue that the code is perfect the way it is (it does exactly what we designed it to do), so there is no need. The issue we have with this code is that we actually have our “production code” inside of our “design code”, and we ain’t going to ship “tests” to production.

Let’s fix that…

docking_station_test.rb
require "test/unit"
require_relative "docking_station"
 
class DockingStation
  def initialize(bike)
    @bike = bike
  end
 
  def release_bike
    @bike
  end
end
 
class TestDockingStation < Test::Unit::TestCase
  def test_can_release_a_bike
    bike = 'a bike'
    docking_station = DockingStation.new(bike)
 
    assert_equal('a bike', docking_station.release_bike)
  end
end
docking_station.rb
class DockingStation
  def initialize(bike)
    @bike = bike
  end
 
  def release_bike
    @bike
  end
end

Note: normally you’d have the production code and the test code in different directories (like lib or src and test); I left it like this to keep things simple.

I have my code side by side like this (in my editor/IDE) –on the left hand side my test, on the right hand side my production code and at the bottom a terminal where I can run commands (like running the tests).

Normally I would setup a watcher (a script that watches for file changes) to run the tests when I save my files (but this is out of scope for this blog post).

Running the test: Passes!
ecomba [tdd_with_boris_bikes_ruby] % ls
docking_station.rb      docking_station_test.rb
ecomba [tdd_with_boris_bikes_ruby] % ruby docking_station_test.rb
Loaded suite docking_station_test
Started
Finished in 0.000192 seconds.
-----------------------------------------------------------------------------------------------------------------------
1 tests, 1 assertions, 0 failures, 0 errors, 0 pendings, 0 omissions, 0 notifications
100% passed
-----------------------------------------------------------------------------------------------------------------------
5208.33 tests/s, 5208.33 assertions/s

Let’s look back at our little requirements document about releasing bikes. The first sentence reads Bikes are parked in the docking stations. 🤔

Releasing bikes
Bikes are parked in docking stations. When you want your bike,
you can release the bike from the docking station.

This would imply that the DockingStation can have no bike, or rather, that we can add a bike to it if it’s empty… hang on a minute! In our test, when we release a bike from the DockingStation it’s nor really released, is it?

What should happen in this case? Once we release a bike, we should not be able to release the same bike again as it’s already gone!

docking_station_test.rb
require "test/unit"
require_relative "docking_station"
 
class TestDockingStation < Test::Unit::TestCase
  def test_can_release_a_bike
    bike = 'a bike'
    docking_station = DockingStation.new(bike)
 
    assert_equal('a bike', docking_station.release_bike)
  end
 
  def test_does_not_have_any_bikes_after_releasing
    bike = 'a bike'
    docking_station = DockingStation.new(bike)
    docking_station.release_bike
 
    assert_nil(docking_station.release_bike)
  end
end
docking_station.rb
class DockingStation
  def initialize(bike)
    @bike = bike
  end
 
  def release_bike
    @bike
  end
end

This test basically releases a bike and then, when attempting to release a second bike (by calling release_bike again) we expect the result to be nil.

But as you can see below, out test failed; A BIKE WAS RETURNED!

Running the test: Fails!
ecomba [tdd_with_boris_bikes_ruby] % ls
docking_station.rb      docking_station_test.rb
ecomba [tdd_with_boris_bikes_ruby] % ruby docking_station_test.rb
Loaded suite docking_station_test
Started
F
=======================================================================================================================
Failure: test_does_not_have_any_bikes_after_releasing(TestDockingStation): <"a bike"> was expected to be nil.
docking_station_test.rb:18:in `test_does_not_have_any_bikes_after_releasing'
     15:
     16:     docking_station.release_bike
     17:
  => 18:     assert_nil(docking_station.release_bike)
     19:
     20:   end
     21: end
=======================================================================================================================
Finished in 0.002855 seconds.
-----------------------------------------------------------------------------------------------------------------------
2 tests, 2 assertions, 1 failures, 0 errors, 0 pendings, 0 omissions, 0 notifications
50% passed
-----------------------------------------------------------------------------------------------------------------------
700.53 tests/s, 700.53 assertions/s

This should be an easy fix…

docking_station_test.rb
require "test/unit"
require_relative "docking_station"
 
class TestDockingStation < Test::Unit::TestCase
  def test_can_release_a_bike
    bike = 'a bike'
    docking_station = DockingStation.new(bike)
 
    assert_equal('a bike', docking_station.release_bike)
  end
 
  def test_does_not_have_any_bikes_after_releasing
    bike = 'a bike'
    docking_station = DockingStation.new(bike)
    docking_station.release_bike
 
    assert_nil(docking_station.release_bike)
  end
end
docking_station.rb
class DockingStation
  def initialize(bike)
    @bike = bike
  end
 
  def release_bike
    @bike.tap { @bike = nil }
  end
end

There we got it, now, when releasing a bike our DockingStation is empty.

The code is simple and we designed it as we went along. As it stands, this code could be released as it is. But we still got work to do…

Now it would be time to park those bikes in the DockingStation!

Running the test: Passes!
ecomba [tdd_with_boris_bikes_ruby] % ls
docking_station.rb      docking_station_test.rb
ecomba [tdd_with_boris_bikes_ruby] % ruby docking_station_test.rb
Loaded suite docking_station_test
Started
Finished in 0.000215 seconds.
-----------------------------------------------------------------------------------------------------------------------
2 tests, 2 assertions, 0 failures, 0 errors, 0 pendings, 0 omissions, 0 notifications
100% passed
-----------------------------------------------------------------------------------------------------------------------
9302.33 tests/s, 9302.33 assertions/s

Jikes, this blog post turned out to be a mini demo of Test Driven Development, appologies…

This is making me think that maybe you’d like a more structured Test Driven Development introducion. Do you? I’m asking because this weekend I’ve been toying with the idea of releasing a free introductory TDD email course to introduce you to all the aspects of TDD you’d normally learn in a 2 day course.