🚀 Test Driven Development – in 5 minutes 🚀
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
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:
1def test_can_release_bike2 bike = 'a bike'3 docking_station = DockingStation.new(bike)4
5 assert_equal('a bike', docking_station.release_bike)6end1ecomba [tdd_with_boris_bikes_ruby] % ruby docking_station_test.rb2Loaded suite docking_station_test3Started4E5=======================================================================================================================6Error: test_can_release_a_bike(TestDockingStation): NameError: uninitialized constant TestDockingStation::DockingStation7docking_station_test.rb:5:in `test_can_release_a_bike'8docking_station_test.rb:6:in `test_can_release_a_bike'9 3: class TestDockingStation < Test::Unit::TestCase10 4: def test_can_release_a_bike11 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: end16=======================================================================================================================17Finished in 0.000448 seconds.18-----------------------------------------------------------------------------------------------------------------------191 tests, 0 assertions, 0 failures, 1 errors, 0 pendings, 0 omissions, 0 notifications200% passed21-----------------------------------------------------------------------------------------------------------------------222232.14 tests/s, 0.00 assertions/s1class DockingStation2 def initialize(bike)3 @bike = bike4 end5
6 def release_bike7 @bike8 end9end1Loaded suite2Started3Finished in 0.000215 seconds.4-----------------------------------------------------------------------------------------------------------------------51 tests, 1 assertions, 0 failures, 0 errors, 0 pendings, 0 omissions, 0 notifications6100% passed7-----------------------------------------------------------------------------------------------------------------------89302.33 tests/s, 9302.33 assertions/s1class DockingStation2 def initialize(bike)3 @bike = bike4 end5
6 def release_bike7 # Why did we do this? Because we assumed that after releasing8 # 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 end12end“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.
1Bikes are parked in docking stations. When you want your bike,2you 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).
1require "test/unit"2
3class TestDockingStation < Test::Unit::TestCase4 def test_can_release_a_bike5 bike = 'a bike'6 docking_station = DockingStation.new(bike)7
8 assert_equal('a bike', docking_station.release_bike)9 end10endWhen 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?
1ecomba [tdd_with_boris_bikes_ruby] % ruby docking_station_test.rb2Loaded suite docking_station_test3Started4
5E6=======================================================================================================================7Error: test_can_release_a_bike(TestDockingStation): NameError: uninitialized constant TestDockingStation::DockingStation8docking_station_test.rb:5:in `test_can_release_a_bike'9docking_station_test.rb:6:in `test_can_release_a_bike'10 3: class TestDockingStation < Test::Unit::TestCase11 4: def test_can_release_a_bike12 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: end17=======================================================================================================================18Finished in 0.000448 seconds.19-----------------------------------------------------------------------------------------------------------------------201 tests, 0 assertions, 0 failures, 1 errors, 0 pendings, 0 omissions, 0 notifications210% passed22-----------------------------------------------------------------------------------------------------------------------232232.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.
Okay, let’s address this error message (writing just enough code for it not to fail the same way) and run the test again.
1require "test/unit"2
3class DockingStation4end5
6class TestDockingStation < Test::Unit::TestCase7 def test_can_release_a_bike8 bike = 'a bike'9 docking_station = DockingStation.new(bike)10
11 assert_equal('a bike', docking_station.release_bike)12 end13endThe message has changed!
This time we have an ArgumentError. This is because the DockingStation expects one argument (the bike)
when we initialize it!
1ecomba [tdd_with_boris_bikes_ruby] % ruby docking_station_test.rb2Loaded suite docking_station_test3Started4E5=======================================================================================================================6Error: test_can_release_a_bike(TestDockingStation): ArgumentError: wrong number of arguments (given 1, expected 0)7docking_station_test.rb:9:in `initialize'8docking_station_test.rb:9:in `new'9docking_station_test.rb:9:in `test_can_release_a_bike'10 6: class TestDockingStation < Test::Unit::TestCase11 7: def test_can_release_a_bike12 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: end17=======================================================================================================================18Finished in 0.000475 seconds.19-----------------------------------------------------------------------------------------------------------------------201 tests, 0 assertions, 0 failures, 1 errors, 0 pendings, 0 omissions, 0 notifications210% passed22-----------------------------------------------------------------------------------------------------------------------232105.26 tests/s, 0.00 assertions/s
Let’s add the initialization code to the DockingStation and run the tests again.
1require "test/unit"2
3class DockingStation4 def initialize(bike)5 end6end7
8class TestDockingStation < Test::Unit::TestCase9 def test_can_release_a_bike10 bike = 'a bike'11 docking_station = DockingStation.new(bike)12
13 assert_equal('a bike', docking_station.release_bike)14 end15endProgress, 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).
1ecomba [tdd_with_boris_bikes_ruby] % ruby docking_station_test.rb2Loaded suite docking_station_test3Started4E5=======================================================================================================================6Error: test_can_release_a_bike(TestDockingStation): NoMethodError: undefined method `release_bike'7for an instance of DockingStation8docking_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: end14 15: end15=======================================================================================================================16Finished in 0.000584 seconds.17-----------------------------------------------------------------------------------------------------------------------181 tests, 0 assertions, 0 failures, 1 errors, 0 pendings, 0 omissions, 0 notifications190% passed20-----------------------------------------------------------------------------------------------------------------------211712.33 tests/s, 0.00 assertions/sMaking the tests finally pass with one last message change!
1require "test/unit"2
3class DockingStation4 def initialize(bike)5 @bike = bike6 end7
8 def release_bike9 @bike10 end11end12
13class TestDockingStation < Test::Unit::TestCase14 def test_can_release_a_bike15 bike = 'a bike'16 docking_station = DockingStation.new(bike)17
18 assert_equal('a bike', docking_station.release(bike))19 end20endOur 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.
1ecomba [tdd_with_boris_bikes_ruby] % ruby docking_station_test.rb2Loaded suite docking_station_test3Started4Finished in 0.000192 seconds.5-----------------------------------------------------------------------------------------------------------------------61 tests, 1 assertions, 0 failures, 0 errors, 0 pendings, 0 omissions, 0 notifications7100% passed8-----------------------------------------------------------------------------------------------------------------------95208.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!
1require "test/unit"2
3class DockingStation4 def initialize(bike)5 end6
7 def release_bike8 end9end10
11class TestDockingStation < Test::Unit::TestCase12 def test_can_release_a_bike13 bike = 'a bike'14 docking_station = DockingStation.new(bike)15
16 assert_equal('a bike', docking_station.release_bike)17 end18endThe 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!
1ecomba [tdd_with_boris_bikes_ruby] % ruby docking_station_test.rb2Loaded suite docking_station_test3Started4F5=======================================================================================================================6Failure: test_can_release_a_bike(TestDockingStation)7docking_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: end13 18: end14<"a bike"> expected but was15<nil>16
17diff:18? "a bike"19? n l20? ???? ???21=======================================================================================================================22Finished in 0.003932 seconds.23-----------------------------------------------------------------------------------------------------------------------241 tests, 1 assertions, 1 failures, 0 errors, 0 pendings, 0 omissions, 0 notifications250% passed26-----------------------------------------------------------------------------------------------------------------------27254.32 tests/s, 254.32 assertions/sOh, yes, we are not returning the bike object. That shouldn't be too dificult to fix...
1require "test/unit"2
3class DockingStation4 def initialize(bike)5 @bike = bike6 end7
8 def release_bike9 @bike10 end11end12
13class TestDockingStation < Test::Unit::TestCase14 def test_can_release_a_bike15 bike = 'a bike'16 docking_station = DockingStation.new(bike)17
18 assert_equal('a bike', docking_station.release(bike))19 end20endOur 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.
1ecomba [tdd_with_boris_bikes_ruby] % ruby docking_station_test.rb2Loaded suite docking_station_test3Started4Finished in 0.000192 seconds.5-----------------------------------------------------------------------------------------------------------------------61 tests, 1 assertions, 0 failures, 0 errors, 0 pendings, 0 omissions, 0 notifications7100% passed8-----------------------------------------------------------------------------------------------------------------------95208.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.
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…
1require "test/unit"2require_relative "docking_station"3
4class DockingStation5 def initialize(bike)6 @bike = bike7 end8
9 def release_bike10 @bike11 end12end13
14class TestDockingStation < Test::Unit::TestCase15 def test_can_release_a_bike16 bike = 'a bike'17 docking_station = DockingStation.new(bike)18
19 assert_equal('a bike', docking_station.release_bike)20 end21end1class DockingStation2 def initialize(bike)3 @bike = bike4 end5
6 def release_bike7 @bike8 end9endNote: 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).
1ecomba [tdd_with_boris_bikes_ruby] % ls2docking_station.rb docking_station_test.rb3ecomba [tdd_with_boris_bikes_ruby] % ruby docking_station_test.rb4Loaded suite docking_station_test5Started6Finished in 0.000192 seconds.7-----------------------------------------------------------------------------------------------------------------------81 tests, 1 assertions, 0 failures, 0 errors, 0 pendings, 0 omissions, 0 notifications9100% passed10-----------------------------------------------------------------------------------------------------------------------115208.33 tests/s, 5208.33 assertions/sLet’s look back at our little requirements document about releasing bikes. The first sentence reads Bikes are parked in the docking stations. 🤔
1Bikes are parked in docking stations. When you want your bike,2you 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!
1require "test/unit"2require_relative "docking_station"3
4class TestDockingStation < Test::Unit::TestCase5 def test_can_release_a_bike6 bike = 'a bike'7 docking_station = DockingStation.new(bike)8
9 assert_equal('a bike', docking_station.release_bike)10 end11
12 def test_does_not_have_any_bikes_after_releasing13 bike = 'a bike'14 docking_station = DockingStation.new(bike)15 docking_station.release_bike16
17 assert_nil(docking_station.release_bike)18 end19end1class DockingStation2 def initialize(bike)3 @bike = bike4 end5
6 def release_bike7 @bike8 end9endThis 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!
1ecomba [tdd_with_boris_bikes_ruby] % ls2docking_station.rb docking_station_test.rb3ecomba [tdd_with_boris_bikes_ruby] % ruby docking_station_test.rb4Loaded suite docking_station_test5Started6F7=======================================================================================================================8Failure: test_does_not_have_any_bikes_after_releasing(TestDockingStation): <"a bike"> was expected to be nil.9docking_station_test.rb:18:in `test_does_not_have_any_bikes_after_releasing'10 15:11 16: docking_station.release_bike12 17:13 => 18: assert_nil(docking_station.release_bike)14 19:15 20: end16 21: end17=======================================================================================================================18Finished in 0.002855 seconds.19-----------------------------------------------------------------------------------------------------------------------202 tests, 2 assertions, 1 failures, 0 errors, 0 pendings, 0 omissions, 0 notifications2150% passed22-----------------------------------------------------------------------------------------------------------------------23700.53 tests/s, 700.53 assertions/s1require "test/unit"2require_relative "docking_station"3
4class TestDockingStation < Test::Unit::TestCase5 def test_can_release_a_bike6 bike = 'a bike'7 docking_station = DockingStation.new(bike)8
9 assert_equal('a bike', docking_station.release_bike)10 end11
12 def test_does_not_have_any_bikes_after_releasing13 bike = 'a bike'14 docking_station = DockingStation.new(bike)15 docking_station.release_bike16
17 assert_nil(docking_station.release_bike)18 end19end1class DockingStation2 def initialize(bike)3 @bike = bike4 end5
6 def release_bike7 @bike.tap { @bike = nil }8 end9endThere 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!
1ecomba [tdd_with_boris_bikes_ruby] % ls2docking_station.rb docking_station_test.rb3ecomba [tdd_with_boris_bikes_ruby] % ruby docking_station_test.rb4Loaded suite docking_station_test5Started6Finished in 0.000215 seconds.7-----------------------------------------------------------------------------------------------------------------------82 tests, 2 assertions, 0 failures, 0 errors, 0 pendings, 0 omissions, 0 notifications9100% passed10-----------------------------------------------------------------------------------------------------------------------119302.33 tests/s, 9302.33 assertions/sJikes, 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!