Blog | Niko Heikkilä

Test-Driven Development on a Bus Ride to Hell

Want to learn TDD while saving innocent civilians from dying? Hop on board the bus!

By Niko Heikkilä / September 17, 2022 / ☕️☕️☕️ 17 minutes read

I'm going to admit something: I had trouble understanding test-driven development (TDD) and it took me a long time to get over it.

TDD felt mysterious, confusing, and painful. Yet, I was fascinated in 1) all the testimonies of people who practised it and kept their codebase in great shape, and 2) its nature of turning upside down the way I build software. The latter reminded me of the first steps I took with functional programming and writing functions in declarative style in contrast to imperative style. I had to detach my brains from the skull, turn them around, and place them back in — figuratively speaking, of course.

So I tried to practice TDD from time to time. I failed again and again.

Driving the design of my code through tests was intimidating and challenging. However, when I finally understood that I was working with too massive batches (commits) and started to radically look at decreasing the feedback loop, it got gradually easier. Today, I use TDD for almost all the code I write.

You may be stuck in that particular quagmire too. Chances are you are asking yourself the following questions:

  • how can I split this feature into smaller tasks?
  • what test should I write first?
  • how do I organise my test code?
  • what should I assert if my logic is not yet completed?

To help you start the journey, I've written a tutorial on how to approach writing tests before the production code in tiny batches. Fasten your seatbelts, we're going on a ride but I assure you it's going to be smooth.

Prerequisites

Before continuing, I expect you know your way around the basic Git commands and Python programming language, which we use as examples here.

The outline of this tutorial is to follow the Red-Green cycles by first writing a failing test and then making it pass by writing simple production code. After each failing test, we record our changes in a new micro-commit — think it as a commit but a very small one.

If you're interested in how I work with small commits, read my earlier post about micro-commits.

You don't necessarily need to have watched the fantastic Oscar-winning film Speed (1994) directed by Jan de Bont, and starring Keanu Reeves and Sandra Bullock, but it might help you to tune into the atmosphere.

Requirements

"When a young Los Angeles police department, Special Weapons and Tactics (SWAT) officer called Jack Traven angers retired Atlanta police department bomb squad member Howard Payne, by foiling his attempt at taking hostages stuck in an elevator with a bomb, Payne in retaliation arms a bus with a bomb that will explode if it drops below 50 miles per hour. With the help of spunky passenger Annie, Jack and his partner Detective Harry Temple try to save the people on the bus before the bomb goes off, while also trying to figure out how Payne is monitoring them."

We can extract the following testable acceptance criteria from the film's storyline.

  • The bus accepts passengers on board.
  • The bus can accelerate v0v1v_0 \to v_1 mph, where v1>v0v_1 > v_0.
  • The bus can decelerate from v1v2v_1 \to v_2, where v2<v1v_2 < v_1.
  • There is an unarmed bomb installed on the bus.
  • When the bomb is unarmed and the bus accelerates to v50v \geq 50 mph, the bomb arms itself.
  • When the bomb is armed and the bus decelerates to v50v \leq 50, the bomb explodes unless there is at least one passenger on the bus who can save the day.

We will start modelling these expectations in our test code and solve one problem at a time with a minimal amount of production code.

The process of extracting automatically testable tasks from a list of requirements is inspired by the book Learning Test-Driven Development by Saleem Siddiqui. I warmly recommended reading that book if you regularly work with Javascript, Python, or Golang and are interested in TDD.

Throughout the upcoming sections, I will denote with a red circle 🔴 when we write a test and expect it fail. Consequently, I denote with a green circle 🟢 when we expect to make the test pass.

Contrary to some TDD guides, I won't stop and refactor after every green step. I consider that to be a limiting practice. Instead, I will save the refactoring as the last step when I have a full confidence in my implementation.

If you want to dig directly into the source code, check out the code in GitHub.

1. The bus accepts passengers

Our first task is to define an interface for our bus. It should also be helpful to our society and pick up passengers.

🔴 We write a failing test, which exercises this behaviour. The bus picks up a passenger, and we verify that the passenger is indeed on the bus. The test fails because we haven't defined our Bus and Passenger classes.

Diff
1+from speed_tdd.bus import Bus, Passenger
2+
3+def test_bus_accepts_passengers() -> None:
4+    bus = Bus()
5+    passenger = Passenger()
6+
7+    bus.pick(passenger)
8+
9+    assert passenger in bus.passengers

🟢 A minimum amount of code to make the test pass includes both classes and a method to pick up a passenger. We don't care about passenger details now and leave it empty. We choose to place the passengers in a set object instead of a list, although for our use case, it doesn't matter, and the data structure is easy to refactor later if needed.

Diff
1+class Passenger:
2+    pass
3+
4+class Bus:
5+    passengers: set[Passenger]
6+
7+    def __init__(self) -> None:
8+        self.passengers = set()
9+
10+    def pick(self, passenger: Passenger) -> None:
11+        self.passengers.add(passenger)

2. The bus can accelerate to a given speed

  • The bus accepts passengers on board.
  • The bus can accelerate v0v1v_0 \to v_1 mph, where v1>v0v_1 > v_0.
  • The bus can decelerate from v1v2v_1 \to v_2, where v2<v1v_2 < v_1.
  • There is an unarmed bomb installed on the bus.
  • When the bomb is unarmed and the bus accelerates to v50v \geq 50 mph, the bomb arms itself.
  • When the bomb is armed and the bus decelerates to v50v \leq 50, the bomb explodes unless there is at least one passenger on the bus who can save the day.

Now that our bus can pick up passengers, it would be great if it could also take them to their destination. But, following the laws of physics, it must naturally accelerate to a given speed to comply with that requirement.

🔴 Our test accelerates the bus to 20 miles per hour and verifies that it is achieved. Unfortunately, the test fails because no method accelerate() is defined for the bus.

Diff
1+def test_bus_can_accelerate_to_given_speed() -> None:
2+    bus = Bus()
3+    bus.accelerate(20)
4+
5+    assert bus.speed == 20

🟢 To make the test pass, we must store the bus speed in class context. Then we define the method to accelerate the bus to whatever speed we have. Finally, we discard the laws of physics momentarily to get to our goal.

Diff
1class Passenger:
2    pass
3
4class Bus:
5+   speed: int
6    passengers: set[Passenger]
7
8    def __init__(self) -> None:
9+       self.speed = 0
10        self.passengers = set()
11
12    def pick(self, passenger: Passenger) -> None:
13        self.passengers.add(passenger)
14
15+    def accelerate(self, speed: int) -> None:
16+        self.speed = speed

3. The bus cannot accelerate to a lower speed

It wouldn't make much sense to accelerate the bus and see it suddenly slow down. Of course, such a bus would need immediate repairing, but as software engineers, we are here to craft infallible buses!

🔴 Our test first accelerates the bus to 20 miles per hour and then back to 10 miles per hour. Our test fails because it is possible to do so against all common sense.

Diff
1+def test_bus_cannot_accelerate_to_lower_speed() -> None:
2+    bus = Bus()
3+    bus.accelerate(20)
4+    bus.accelerate(10)
5+
6+    assert bus.speed == 20

🟢 We write a simple one-line check to disallow slowing the bus down when accelerating. Now we realize this sounds rather dangerous.

Diff
1class Bus:
2
3    def accelerate(self, speed: int) -> None:
4+        if speed > self.speed:
5            self.speed = speed

4. The bus can decelerate to a given speed

  • The bus accepts passengers on board.
  • The bus can accelerate v0v1v_0 \to v_1 mph, where v1>v0v_1 > v_0.
  • The bus can decelerate from v1v2v_1 \to v_2, where v2<v1v_2 < v_1.
  • There is an unarmed bomb installed on the bus.
  • When the bomb is unarmed and the bus accelerates to v50v \geq 50 mph, the bomb arms itself.
  • When the bomb is armed and the bus decelerates to v50v \leq 50, the bomb explodes unless there is at least one passenger on the bus who can save the day.

As it stands, our bus cannot slow down at all and is a severe threat to the lives of its passengers. That's not good. We must do something!

🔴 We write a test which first accelerates the bus to 20 miles per hour and then decelerates it to 10 miles per hour. Unfortunately, it fails because we don't have a decelerate() method defined for our Bus class.

Diff
1+def test_bus_can_decelerate_to_given_speed() -> None:
2+    bus = Bus()
3+    bus.accelerate(20)
4+    bus.decelerate(10)
5+
6+    assert bus.speed == 10

🟢 It's easy to pass the test by simply copying the acceleration logic to a new method. Moreover, we always want to ensure we don't overstay our welcome in the red phase.

Diff
1class Bus:
2
3+    def decelerate(self, speed: int) -> None:
4+       self.speed = speed

5. The bus cannot decelerate to a higher speed

What would happen if you suddenly hit the brakes and the bus would accelerate to ludicrous speeds. A lot of dead folks would happen, I tell you. So to enforce even more safety, we should ensure our brakes don't accidentally increase the speed.

🔴 We write a test that accelerates the bus to 20 miles per hour and decelerates it to 30 miles per hour. It fails because the operation is possible.

Diff
1+def test_bus_cannot_decelerate_to_higher_speed() -> None:
2+    bus = Bus()
3+    bus.accelerate(20)
4+    bus.decelerate(30)
5+
6+    assert bus.speed == 20

🟢 We do another quick one-line check to disable that, and our test now passes.

Diff
1class Bus:
2
3    def decelerate(self, speed: int) -> None:
4+       if speed < self.speed:
5            self.speed = speed

6. The bus has an unarmed bomb on board

  • The bus accepts passengers on board.
  • The bus can accelerate v0v1v_0 \to v_1 mph, where v1>v0v_1 > v_0.
  • The bus can decelerate from v1v2v_1 \to v_2, where v2<v1v_2 < v_1.
  • There is an unarmed bomb installed on the bus.
  • When the bomb is unarmed and the bus accelerates to v50v \geq 50 mph, the bomb arms itself.
  • When the bomb is armed and the bus decelerates to v50v \leq 50, the bomb explodes unless there is at least one passenger on the bus who can save the day.

Finally, the plot thickens! There is a bitter older man portrayed by excellent Dennis Hopper who would like to blow us to smithereens. They have installed an explosive into our bus. Fortunately for us, it's yet to be armed.

🔴 We write a test that assumes we have the unarmed bomb on board. It fails because that is not the case right now. It's not the most comfortable test to make passing, but we didn't choose the requirements here.

Diff
1+def test_bus_has_an_unarmed_bomb() -> None:
2+    bus = Bus()
3+
4+    assert bus.bomb.is_unarmed

🟢 We introduce a new Bomb class and inject it into our Bus through the constructor. By design, the bomb starts its lifecycle unarmed, which makes our test pass.

Diff
1+class Bomb:
2+    armed: bool
3+
4+    def __init__(self) -> None:
5+       self.armed = False
6+
7+    @property
8+    def is_unarmed(self) -> bool:
9+       return self.armed == False
10
11class Passenger:
12    pass
13
14class Bus:
15    speed: int
16    passengers: set[Passenger]
17+   bomb: Bomb
18
19    def __init__(self) -> None:
20        self.speed = 0
21        self.passengers = set()
22+       self.bomb = Bomb()

7. When the bus accelerates to 50 miles per hour, the bomb arms itself

  • The bus accepts passengers on board.
  • The bus can accelerate v0v1v_0 \to v_1 mph, where v1>v0v_1 > v_0.
  • The bus can decelerate from v1v2v_1 \to v_2, where v2<v1v_2 < v_1.
  • There is an unarmed bomb installed on the bus.
  • When the bomb is unarmed and the bus accelerates to v50v \geq 50 mph, the bomb arms itself.
  • When the bomb is armed and the bus decelerates to v50v \leq 50, the bomb explodes unless there is at least one passenger on the bus who can save the day.

The terrorist has designed the bomb to become armed when our bus accelerates to 50 miles per hour. Makes you hope we would have built the bus to have a maximum speed of 49 miles per hour earlier, don't you?

🔴 We write a test where we accelerate the bus to 50 miles per hour and verify that the bomb is armed afterwards. But, for now, the bomb remains inactive, and our test fails.

Diff
1+def test_when_the_bus_accelerates_to_50_mph_the_bomb_is_armed() -> None:
2+    bus = Bus()
3+    bus.accelerate(50)
4+
5+    assert bus.bomb.is_armed

🟢 We make the test pass by checking the received speed instructions and arming the bomb when it exceeds 50 miles per hour. We also make a note of how our code is getting messier due to a nested conditional, but that is perfectly fine at this moment.

Diff
1class Bomb:
2
3    @property
4    def is_unarmed(self) -> bool:
5        return self.armed == False
6
7+    @property
8+    def is_armed(self) -> bool:
9+       return self.armed == True
10
11class Bus:
12
13    def accelerate(self, speed: int) -> None:
14        if speed > self.speed:
15            self.speed = speed
16+           if self.speed >= 50 and self.bomb.is_unarmed:
17+               self.bomb.armed = True

8. When the bomb is armed, and the bus decelerates to 50 miles per hour, the bomb explodes

  • The bus accepts passengers on board.
  • The bus can accelerate v0v1v_0 \to v_1 mph, where v1>v0v_1 > v_0.
  • The bus can decelerate from v1v2v_1 \to v_2, where v2<v1v_2 < v_1.
  • There is an unarmed bomb installed on the bus.
  • When the bomb is unarmed and the bus accelerates to v50v \geq 50 mph, the bomb arms itself.
  • When the bomb is armed and the bus decelerates to v50v \leq 50, the bomb explodes unless there is at least one passenger on the bus who can save the day.

To keep us on the edge of the driver's seat, the bomb explodes whenever we decelerate the bus to under 50 miles per hour.

🔴 For now, we resort to expecting an exception called Explosion whenever the bomb goes off, even after a minor slowdown. The test fails because our code doesn't raise such an exception.

Diff
1-from speed_tdd.bus import Bus, Passenger
2+from speed_tdd.bus import Bus, Passenger, Explosion
3+from pytest import raises
4
5+def test_when_the_bomb_is_armed_and_the_bus_decelerates_to_50_mph_the_bomb_explodes() -> None:
6+    bus = Bus()
7+    bus.accelerate(51)
8+
9+    with raises(Explosion):
10+       bus.decelerate(50)

🟢 To make the test pass, we check that the speed is correct and the bomb is armed. Next, we may rest in peace, unless…

Diff
1+class Explosion(Exception):
2+    pass
3
4class Bus:
5
6    def decelerate(self, speed: int) -> None:
7        if speed < self.speed:
8            self.speed = speed
9+           if self.speed <= 50 and self.bomb.is_armed:
10+               raise Explosion()

9. Belay that order! — the hero can still save the day

We have happily neglected our passengers and forgotten that Keanu Reeves has onboarded our bus. So indeed, we are going to be saved now.

🔴 We write a slightly longer test to nail our final requirement. Whenever our bomb is about to go off, Jack Traven / Neo / John Wick intervenes, magically charms all the passengers out of the bus, and lets the empty bus explode. However, our test fails because we are still raising the explosion.

Diff
1+def test_hero_can_save_the_day_from_bus_explosion() -> None:
2+    bus = Bus()
3+    sandra_bullock = Passenger(name="Sandra Bullock")
4+    keanu_reeves = Passenger(name="Keanu Reeves")
5+
6+    bus.pick(sandra_bullock)
7+    bus.pick(keanu_reeves)
8+
9+    bus.accelerate(51)
10+    bus.decelerate(50)
11+
12+    assert bus.bomb.is_exploded
13+    assert sandra_bullock.is_alive
14+    assert keanu_reeves.is_alive

🟢 To make the test pass, we start tracking a separate state for our bomb. It can be either unarmed, armed, or exploded. Unfortunately, boolean flags are not very good for this purpose, but we'll fix that later. We also add the missing functionality to our Passenger class. All the passengers now have names, and some named Keanu Reeves are our heroes. Our lives are saved whenever such a hero is on board the bus when the bomb goes off. Cool!

Diff
1class Bomb:
2    armed: bool
3+   exploded: bool
4
5    def __init__(self) -> None:
6        self.armed = False
7+       self.exploded = False
8
9    @property
10    def is_unarmed(self) -> bool:
11        return self.armed == False
12
13    @property
14    def is_armed(self) -> bool:
15        return self.armed == True
16
17+    @property
18+    def is_exploded(self) -> bool:
19+       return self.exploded == True
20
21class Passenger:
22-    pass
23+    name: str
24+    is_alive: bool
25+
26+    def __init__(self, name: str) -> None:
27+       self.name = name
28+       self.is_alive = True
29+
30+    @property
31+    def is_hero(self) -> bool:
32+       return self.name == "Keanu Reeves"
33
34class Bus:
35    speed: int
36    passengers: set[Passenger]
37    bomb: Bomb
38
39    def accelerate(self, speed: int) -> None:
40        if speed > self.speed:
41            self.speed = speed
42            if self.speed >= 50 and self.bomb.is_unarmed:
43                self.bomb.armed = True
44
45    def decelerate(self, speed: int) -> None:
46        if speed < self.speed:
47            self.speed = speed
48            if self.speed <= 50 and self.bomb.is_armed:
49+               if any(passenger.is_hero for passenger in self.passengers):
50+                   self.bomb.exploded = True
51+               else:
52                    raise Explosion()

Right now, there's not much sense in raising any exceptions. Moreover, while practising TDD, we should pay constant attention to our design choices and improve them later as long as we don't break our use cases.

🔴 We verify that our unlucky civilian is killed when the bomb goes off, even though we don't raise an exception. But unfortunately, our test failed again because we raised an unexpected exception.

Diff
1-from speed_tdd.bus import Bus, Passenger, Explosion
2-from pytest import raises
3+from speed_tdd.bus import Bus, Passenger
4
5def test_when_the_bomb_is_armed_and_the_bus_decelerates_to_50_mph_the_bomb_explodes() -> None:
6    bus = Bus()
7+   passenger = Passenger(name="Unlucky Civilian")
8+   bus.pick(passenger)
9    bus.accelerate(51)
10
11-   with raises(Explosion):
12+   bus.decelerate(50)
13
14+   assert bus.bomb.is_exploded
15+   assert passenger.is_dead

🟢 To make our test pass, we define what it means when our passenger dies. That is, they are not alive, obviously. We also commit an ugly hack to our horrible method, which kills all our passengers in case the bomb goes off.

Diff
1-class Explosion(Exception):
2-    pass
3
4class Passenger:
5
6+    @property
7+    def is_dead(self) -> bool:
8+       return self.is_alive == False
9
10class Bus:
11
12    def decelerate(self, speed: int) -> None:
13        if speed < self.speed:
14            self.speed = speed
15            if self.speed <= 50 and self.bomb.is_armed:
16-               if any(passenger.is_hero for passenger in self.passengers):
17                    self.bomb.exploded = True
18+               if any(passenger.is_hero for passenger in self.passengers):
19+                   pass
20                else:
21-                   raise Explosion()
22+                   for passenger in self.passengers:
23+                       passenger.is_alive = False

Red-green cycles are done — time to refactor

Looks like we are done here. Time to mark the feature as done and go home? Absolutely not!

  • The bus accepts passengers on board.
  • The bus can accelerate v0v1v_0 \to v_1 mph, where v1>v0v_1 > v_0.
  • The bus can decelerate from v1v2v_1 \to v_2, where v2<v1v_2 < v_1.
  • There is an unarmed bomb installed on the bus.
  • When the bomb is unarmed and the bus accelerates to v50v \geq 50 mph, the bomb arms itself.
  • When the bomb is armed and the bus decelerates to v50v \leq 50, the bomb explodes unless there is at least one passenger on the bus who can save the day.

In its current state, our codebase is ghastly with all the magic numbers, nested conditionals, and lack of abstractions. However, as professional software engineers, we should always adhere to the boy scout rule:

"Always leave the campground codebase cleaner than you found it."

Since refactoring is an opinionated business, I will skip the intermediate phases and show you the results directly. We are going to refactor both our production code and test code. While we don't ship the test code, it's imperative to keep it maintainable. How else could we continue fulfilling later requirements?

Refactoring the test code

The refactoring for our unit tests is below.

Python
1from pytest import fixture
2from speed_tdd.bus import Bus, Passenger
3
4
5@fixture
6def bus() -> Bus:
7    return Bus()
8
9
10@fixture
11def driver() -> Passenger:
12    return Passenger(name="Sandra Bullock")
13
14
15@fixture
16def hero() -> Passenger:
17    return Passenger(name="Keanu Reeves")
18
19
20def test_bus_accepts_passengers(bus: Bus, driver: Passenger) -> None:
21    bus.pick(driver)
22
23    assert driver in bus.passengers
24
25
26def test_bus_can_accelerate_to_given_speed(bus: Bus) -> None:
27    bus.accelerate(to=20)
28
29    assert bus.driving_at(speed=20)
30
31
32def test_bus_cannot_accelerate_to_lower_speed(bus: Bus) -> None:
33    bus.accelerate(to=20)
34    bus.accelerate(to=10)
35
36    assert bus.driving_at(speed=20)
37
38
39def test_bus_can_decelerate_to_given_speed(bus: Bus) -> None:
40    bus.accelerate(to=20)
41    bus.decelerate(to=10)
42
43    assert bus.driving_at(speed=10)
44
45
46def test_bus_cannot_decelerate_to_higher_speed(bus: Bus) -> None:
47    bus.accelerate(to=20)
48    bus.decelerate(to=30)
49
50    assert bus.driving_at(speed=20)
51
52
53def test_bus_has_an_unarmed_bomb(bus: Bus) -> None:
54    assert not bus.can_explode
55
56
57def test_when_the_bus_accelerates_to_50_mph_the_bomb_is_armed(bus: Bus) -> None:
58    bus.accelerate(to=50)
59
60    assert bus.can_explode
61
62
63def test_when_the_bomb_is_armed_and_the_bus_decelerates_to_50_mph_the_bomb_explodes(
64    bus: Bus, driver: Passenger
65) -> None:
66    bus.pick(driver)
67
68    bus.accelerate(to=51)
69    bus.decelerate(to=50)
70
71    assert bus.is_exploded
72    assert driver.is_dead
73
74
75def test_hero_can_save_the_day_from_bus_explosion(
76    bus: Bus, driver: Passenger, hero: Passenger
77) -> None:
78    bus.pick(driver, hero)
79
80    bus.accelerate(to=51)
81    bus.decelerate(to=50)
82
83    assert bus.is_exploded
84    assert driver.is_alive
85    assert hero.is_alive

What did we do?

  • Use test fixtures to inject the bus, the driver, and the hero into our tests. I recommended using injectable test fixtures in any test framework you are using to keep the setup phase thin and tests readable.
  • Arranged the test code to Arrange-Act-Assert blocks for improved readability.

Refactoring the production code

The refactoring result for our production code is below.

Python
1from dataclasses import dataclass
2from enum import IntEnum
3
4
5class BombState(IntEnum):
6    UNARMED = 1
7    ARMED = 2
8    EXPLODED = 3
9
10
11@dataclass
12class Bomb:
13    state: BombState = BombState.UNARMED
14    trigger_speed: int = 50
15
16    def arm(self) -> None:
17        self.state = BombState.ARMED
18
19    def explode(self) -> None:
20        self.state = BombState.EXPLODED
21
22    @property
23    def is_unarmed(self) -> bool:
24        return self.state == BombState.UNARMED
25
26    @property
27    def is_armed(self) -> bool:
28        return self.state == BombState.ARMED
29
30    @property
31    def is_exploded(self) -> bool:
32        return self.state == BombState.EXPLODED
33
34
35@dataclass(unsafe_hash=True)
36class Passenger:
37    name: str
38    is_alive: bool = True
39
40    def kill(self) -> None:
41        self.is_alive = False
42
43    @property
44    def is_hero(self) -> bool:
45        return self.name == "Keanu Reeves"
46
47    @property
48    def is_dead(self) -> bool:
49        return not self.is_alive
50
51
52class Bus:
53    speed: int
54    passengers: set[Passenger]
55    bomb: Bomb
56
57    def __init__(self) -> None:
58        self.speed = 0
59        self.passengers = set()
60        self.bomb = Bomb()
61
62    def pick(self, *passengers: Passenger) -> None:
63        for passenger in passengers:
64            self.passengers.add(passenger)
65
66    def accelerate(self, to: int) -> None:
67        if to > self.speed:
68            self.speed = to
69
70        if self.should_arm_bomb:
71            self.bomb.arm()
72
73    def decelerate(self, to: int) -> None:
74        if to < self.speed:
75            self.speed = to
76
77        if self.should_explode:
78            self.explode()
79
80    def explode(self) -> None:
81        self.bomb.explode()
82
83        if not self.is_hero_onboard:
84            self.kill_all_passengers()
85
86    def kill_all_passengers(self) -> None:
87        [passenger.kill() for passenger in self.passengers]
88
89    def driving_at(self, speed: int) -> bool:
90        return self.speed == speed
91
92    @property
93    def should_arm_bomb(self) -> bool:
94        return self.bomb.is_unarmed and self.speed >= self.bomb.trigger_speed
95
96    @property
97    def should_explode(self) -> bool:
98        return self.bomb.is_armed and self.speed <= self.bomb.trigger_speed
99
100    @property
101    def is_hero_onboard(self) -> bool:
102        return any(passenger.is_hero for passenger in self.passengers)
103
104    @property
105    def can_explode(self) -> bool:
106        return self.bomb.is_armed
107
108    @property
109    def is_exploded(self) -> bool:
110        return self.bomb.is_exploded

What did we do?

  • Replace the boolean flags controlling the Bomb state with a standard integer enumeration.
  • Save the trigger speed of 50 miles per hour as a constant to the Bomb class.
  • Convert the regular Bomb and Passenger classes to Python's data classes and eliminate the redundant constructors.
  • Split the core logic from nested conditionals to tiny methods and getters (dynamic properties).

Homework

Our bus is clearly an MVP, but it successfully transports people somewhere, even though endangering their lives.

What else could we build with our tests? Below are some suggestions, which I leave unimplemented for the sake of this tutorial's brevity.

  • The bus explicitly has a driver and cannot travel without one.
  • The bus has a maximum speed it can travel.
  • The bus cannot travel at a negative speed.
  • Unarmed bombs cannot explode.
  • Armed bombs can be disarmed.
  • The bomb can be removed from the bus.
  • Exploded bombs cannot become unarmed or armed again.
  • The exploded bus cannot be driven again.
  • Dead passengers cannot ride onboard the bus — unless it's a Halloween-themed sequel.
  • Keanu Reeves is not the only hero in the world.

If you want to practice, fork the repository, implement the code according to TDD and requirements and show me the results.

Conclusion

In this tutorial, I have shown you the power of TDD as a solid engineering technique when slicing features.

Typically, we start our work with a list of requirements that users or stakeholders would like to see us implement. Next, we have to devise clever ways to make each slice a deliverable item that our users can try out. Then, and only then, can we achieve actionable feedback and correct the course we are on. This is radically agile software development, which many organisations fail to follow because they deliver too large batches too late.

When I started grasping TDD and micro-commits, the notion of continuously integrating our work and delivering it to users — the absolute CI/CD — started to feel less intimidating and more like a safety harness protecting me against bad decisions.

I'm not looking back to the world of test-last development, big batches, continuous isolation, and eventually broken delivery; I hope after reading this tutorial, you share the same point of view with me.

Back to postsEdit PageView History