🚀 Test Driven Development – in 5 minutes 🚀

Written by Enrique Comba Riepenhausen on 06/10/2024
Photo used in this post by National Cancer Institute on Unsplash
It looks like you are accessing this article on a mobile phone.
For a better experience turn your phone on landscape mode to be able to read the code examples!

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:

1
def test_can_release_bike
2
bike = 'a bike'
3
docking_station = DockingStation.new(bike)
4
5
assert_equal('a bike', docking_station.release_bike)
6
end
Write a failing test
Running the test: Fails!
1
ecomba [tdd_with_boris_bikes_ruby] % ruby docking_station_test.rb
2
Loaded suite docking_station_test
3
Started
4
E
5
=======================================================================================================================
6
Error: test_can_release_a_bike(TestDockingStation): NameError: uninitialized constant TestDockingStation::DockingStation
7
docking_station_test.rb:5:in `test_can_release_a_bike'
8
docking_station_test.rb:6:in `test_can_release_a_bike'
9
3: class TestDockingStation < Test::Unit::TestCase
10
4: def test_can_release_a_bike
11
5: bike = 'a bike'
12
=> 6: docking_station = DockingStation.new(bike)
13
7:
14
8: assert_equal('a bike', docking_station.release_bike)
15
9: end
16
=======================================================================================================================
17
Finished in 0.000448 seconds.
18
-----------------------------------------------------------------------------------------------------------------------
19
1 tests, 0 assertions, 0 failures, 1 errors, 0 pendings, 0 omissions, 0 notifications
20
0% passed
21
-----------------------------------------------------------------------------------------------------------------------
22
2232.14 tests/s, 0.00 assertions/s
1
class DockingStation
2
def initialize(bike)
3
@bike = bike
4
end
5
6
def release_bike
7
@bike
8
end
9
end
Write enough code to make the test pass
Running the test: Passes!
1
Loaded suite
2
Started
3
Finished in 0.000215 seconds.
4
-----------------------------------------------------------------------------------------------------------------------
5
1 tests, 1 assertions, 0 failures, 0 errors, 0 pendings, 0 omissions, 0 notifications
6
100% passed
7
-----------------------------------------------------------------------------------------------------------------------
8
9302.33 tests/s, 9302.33 assertions/s
1
class DockingStation
2
def initialize(bike)
3
@bike = bike
4
end
5
6
def release_bike
7
# Why did we do this? Because we assumed that after releasing
8
# the bike there shouldn't be a bike in the DockingStation,
9
# but where is the test for that? 🤔
10
@bike.tap { @bike = nil }
11
end
12
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
1
Bikes are parked in docking stations. When you want your bike,
2
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
1
require "test/unit"
2
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
10
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!
1
ecomba [tdd_with_boris_bikes_ruby] % ruby docking_station_test.rb
2
Loaded suite docking_station_test
3
Started
4
5
E
6
=======================================================================================================================
7
Error: test_can_release_a_bike(TestDockingStation): NameError: uninitialized constant TestDockingStation::DockingStation
8
docking_station_test.rb:5:in `test_can_release_a_bike'
9
docking_station_test.rb:6:in `test_can_release_a_bike'
10
3: class TestDockingStation < Test::Unit::TestCase
11
4: def test_can_release_a_bike
12
5: bike = 'a bike'
13
=> 6: docking_station = DockingStation.new(bike)
14
7:
15
8: assert_equal('a bike', docking_station.release_bike)
16
9: end
17
=======================================================================================================================
18
Finished in 0.000448 seconds.
19
-----------------------------------------------------------------------------------------------------------------------
20
1 tests, 0 assertions, 0 failures, 1 errors, 0 pendings, 0 omissions, 0 notifications
21
0% passed
22
-----------------------------------------------------------------------------------------------------------------------
23
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
1
require "test/unit"
2
3
class DockingStation
4
end
5
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
13
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!
1
ecomba [tdd_with_boris_bikes_ruby] % ruby docking_station_test.rb
2
Loaded suite docking_station_test
3
Started
4
E
5
=======================================================================================================================
6
Error: test_can_release_a_bike(TestDockingStation): ArgumentError: wrong number of arguments (given 1, expected 0)
7
docking_station_test.rb:9:in `initialize'
8
docking_station_test.rb:9:in `new'
9
docking_station_test.rb:9:in `test_can_release_a_bike'
10
6: class TestDockingStation < Test::Unit::TestCase
11
7: def test_can_release_a_bike
12
8: bike = 'a bike'
13
=> 9: docking_station = DockingStation.new(bike)
14
10:
15
11: assert_equal('a bike', docking_station.release_bike)
16
12: end
17
=======================================================================================================================
18
Finished in 0.000475 seconds.
19
-----------------------------------------------------------------------------------------------------------------------
20
1 tests, 0 assertions, 0 failures, 1 errors, 0 pendings, 0 omissions, 0 notifications
21
0% passed
22
-----------------------------------------------------------------------------------------------------------------------
23
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
1
require "test/unit"
2
3
class DockingStation
4
def initialize(bike)
5
end
6
end
7
8
class TestDockingStation < Test::Unit::TestCase
9
def 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

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!
1
ecomba [tdd_with_boris_bikes_ruby] % ruby docking_station_test.rb
2
Loaded suite docking_station_test
3
Started
4
E
5
=======================================================================================================================
6
Error: test_can_release_a_bike(TestDockingStation): NoMethodError: undefined method `release_bike'
7
for an instance of DockingStation
8
docking_station_test.rb:13:in `test_can_release_a_bike'
9
10: bike = 'a bike'
10
11: docking_station = DockingStation.new(bike)
11
12:
12
=> 13: assert_equal('a bike', docking_station.release_bike)
13
14: end
14
15: end
15
=======================================================================================================================
16
Finished in 0.000584 seconds.
17
-----------------------------------------------------------------------------------------------------------------------
18
1 tests, 0 assertions, 0 failures, 1 errors, 0 pendings, 0 omissions, 0 notifications
19
0% passed
20
-----------------------------------------------------------------------------------------------------------------------
21
1712.33 tests/s, 0.00 assertions/s

Making the tests finally pass with one last message change!

docking_station_test.rb
1
require "test/unit"
2
3
class DockingStation
4
def initialize(bike)
5
@bike = bike
6
end
7
8
def release_bike
9
@bike
10
end
11
end
12
13
class TestDockingStation < Test::Unit::TestCase
14
def test_can_release_a_bike
15
bike = 'a bike'
16
docking_station = DockingStation.new(bike)
17
18
assert_equal('a bike', docking_station.release(bike))
19
end
20
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!
1
ecomba [tdd_with_boris_bikes_ruby] % ruby docking_station_test.rb
2
Loaded suite docking_station_test
3
Started
4
Finished in 0.000192 seconds.
5
-----------------------------------------------------------------------------------------------------------------------
6
1 tests, 1 assertions, 0 failures, 0 errors, 0 pendings, 0 omissions, 0 notifications
7
100% passed
8
-----------------------------------------------------------------------------------------------------------------------
9
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
1
require "test/unit"
2
3
class DockingStation
4
def initialize(bike)
5
end
6
7
def release_bike
8
end
9
end
10
11
class TestDockingStation < Test::Unit::TestCase
12
def 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

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!
1
ecomba [tdd_with_boris_bikes_ruby] % ruby docking_station_test.rb
2
Loaded suite docking_station_test
3
Started
4
F
5
=======================================================================================================================
6
Failure: test_can_release_a_bike(TestDockingStation)
7
docking_station_test.rb:16:in `test_can_release_a_bike'
8
13: bike = 'a bike'
9
14: docking_station = DockingStation.new(bike)
10
15:
11
=> 16: assert_equal('a bike', docking_station.release_bike)
12
17: end
13
18: end
14
<"a bike"> expected but was
15
<nil>
16
17
diff:
18
? "a bike"
19
? n l
20
? ???? ???
21
=======================================================================================================================
22
Finished in 0.003932 seconds.
23
-----------------------------------------------------------------------------------------------------------------------
24
1 tests, 1 assertions, 1 failures, 0 errors, 0 pendings, 0 omissions, 0 notifications
25
0% passed
26
-----------------------------------------------------------------------------------------------------------------------
27
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
1
require "test/unit"
2
3
class DockingStation
4
def initialize(bike)
5
@bike = bike
6
end
7
8
def release_bike
9
@bike
10
end
11
end
12
13
class TestDockingStation < Test::Unit::TestCase
14
def test_can_release_a_bike
15
bike = 'a bike'
16
docking_station = DockingStation.new(bike)
17
18
assert_equal('a bike', docking_station.release(bike))
19
end
20
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!
1
ecomba [tdd_with_boris_bikes_ruby] % ruby docking_station_test.rb
2
Loaded suite docking_station_test
3
Started
4
Finished in 0.000192 seconds.
5
-----------------------------------------------------------------------------------------------------------------------
6
1 tests, 1 assertions, 0 failures, 0 errors, 0 pendings, 0 omissions, 0 notifications
7
100% passed
8
-----------------------------------------------------------------------------------------------------------------------
9
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
1
require "test/unit"
2
require_relative "docking_station"
3
4
class DockingStation
5
def initialize(bike)
6
@bike = bike
7
end
8
9
def release_bike
10
@bike
11
end
12
end
13
14
class TestDockingStation < Test::Unit::TestCase
15
def test_can_release_a_bike
16
bike = 'a bike'
17
docking_station = DockingStation.new(bike)
18
19
assert_equal('a bike', docking_station.release_bike)
20
end
21
end
docking_station.rb
1
class DockingStation
2
def initialize(bike)
3
@bike = bike
4
end
5
6
def release_bike
7
@bike
8
end
9
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!
1
ecomba [tdd_with_boris_bikes_ruby] % ls
2
docking_station.rb docking_station_test.rb
3
ecomba [tdd_with_boris_bikes_ruby] % ruby docking_station_test.rb
4
Loaded suite docking_station_test
5
Started
6
Finished in 0.000192 seconds.
7
-----------------------------------------------------------------------------------------------------------------------
8
1 tests, 1 assertions, 0 failures, 0 errors, 0 pendings, 0 omissions, 0 notifications
9
100% passed
10
-----------------------------------------------------------------------------------------------------------------------
11
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
1
Bikes are parked in docking stations. When you want your bike,
2
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
1
require "test/unit"
2
require_relative "docking_station"
3
4
class TestDockingStation < Test::Unit::TestCase
5
def test_can_release_a_bike
6
bike = 'a bike'
7
docking_station = DockingStation.new(bike)
8
9
assert_equal('a bike', docking_station.release_bike)
10
end
11
12
def test_does_not_have_any_bikes_after_releasing
13
bike = 'a bike'
14
docking_station = DockingStation.new(bike)
15
docking_station.release_bike
16
17
assert_nil(docking_station.release_bike)
18
end
19
end
docking_station.rb
1
class DockingStation
2
def initialize(bike)
3
@bike = bike
4
end
5
6
def release_bike
7
@bike
8
end
9
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!
1
ecomba [tdd_with_boris_bikes_ruby] % ls
2
docking_station.rb docking_station_test.rb
3
ecomba [tdd_with_boris_bikes_ruby] % ruby docking_station_test.rb
4
Loaded suite docking_station_test
5
Started
6
F
7
=======================================================================================================================
8
Failure: test_does_not_have_any_bikes_after_releasing(TestDockingStation): <"a bike"> was expected to be nil.
9
docking_station_test.rb:18:in `test_does_not_have_any_bikes_after_releasing'
10
15:
11
16: docking_station.release_bike
12
17:
13
=> 18: assert_nil(docking_station.release_bike)
14
19:
15
20: end
16
21: end
17
=======================================================================================================================
18
Finished in 0.002855 seconds.
19
-----------------------------------------------------------------------------------------------------------------------
20
2 tests, 2 assertions, 1 failures, 0 errors, 0 pendings, 0 omissions, 0 notifications
21
50% passed
22
-----------------------------------------------------------------------------------------------------------------------
23
700.53 tests/s, 700.53 assertions/s
This should be an easy fix...
docking_station_test.rb
1
require "test/unit"
2
require_relative "docking_station"
3
4
class TestDockingStation < Test::Unit::TestCase
5
def test_can_release_a_bike
6
bike = 'a bike'
7
docking_station = DockingStation.new(bike)
8
9
assert_equal('a bike', docking_station.release_bike)
10
end
11
12
def test_does_not_have_any_bikes_after_releasing
13
bike = 'a bike'
14
docking_station = DockingStation.new(bike)
15
docking_station.release_bike
16
17
assert_nil(docking_station.release_bike)
18
end
19
end
docking_station.rb
1
class DockingStation
2
def initialize(bike)
3
@bike = bike
4
end
5
6
def release_bike
7
@bike.tap { @bike = nil }
8
end
9
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!
1
ecomba [tdd_with_boris_bikes_ruby] % ls
2
docking_station.rb docking_station_test.rb
3
ecomba [tdd_with_boris_bikes_ruby] % ruby docking_station_test.rb
4
Loaded suite docking_station_test
5
Started
6
Finished in 0.000215 seconds.
7
-----------------------------------------------------------------------------------------------------------------------
8
2 tests, 2 assertions, 0 failures, 0 errors, 0 pendings, 0 omissions, 0 notifications
9
100% passed
10
-----------------------------------------------------------------------------------------------------------------------
11
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.

Let me know if you’d like me to bring this introduction to TDD to life!

back to the blog