📖 Book - Kent Beck - Test Driven Development by Example

#book
Author:: [[Kent Beck]]
Book:: Test-Driven Development
Genre:: #genre/non-fiction/development
Rating:: #rating/★★★★★
Started:: 2022-08-19
Finished::
Reading time::
The Rhythm of Test-Driven Development
- Quickly add a test.
- Run all tests and see the new one fail.
- Make a little change.
- Run all test and see them all succeed.
- Refactor to remove duplication.
The Money Example
- when writing a test, imagine the best possible interface, even though it might not end up staying this way
- if you come upon a small imperfection while writing a test, make a note of this and come back to it later. Your goal is to get the bar to go green as quickly as possible
- after writing a test, your first job is to get rid of compile errors
- one goal of testing is to reduce an abstract problem to a defined, solvable one
Start with the most idiotic implementation that gets the bar to green. If your goal is to implement a multiplication function, start with this test:
public testMultiplication() {
$five = new Dollar(5);
$five->times(2);
$this->assertEquals(10, $five->amount);
}
Then you might be tempted to actually multiply the amount. Don’t. Start with:
class Dollar {
public int $amount;
public function __construct(int $amount) {
}
public function times(int $multiplier) {
return 10;
}
}
Only then go forward to the implementation that actually makes sense, while still keeping the tests green:
class Dollar {
public int $amount;
public function __construct(int $amount) {
$this->amount = $amount;
}
public function times(int $multiplier) {
$this->amount *= $multiplier;
}
}
Degenerate Objects
- ==Quick green excuses all sins. But only for a moment.==
When working with the test, we notice that our Dollar object is mutable, which it should not be. So we modify the test:
public function testMultiplication {
$five = new Dollar(5);
$five->times(2);
$this->assertEquals(10, $five->amount);
$five->times(3);
$this->assertEquals(15, $five->amount);
}
Which, of course, breaks. Then we modify the Dollar code by making it return a new, immutable Dollar, and it works!
There are [[The Three Implementation Strategies of Test-Driven Development|three tactics]] for getting to green:
- Fake It — write stupid implementations
- Obvious Implementation — write the correct implementation
- a third one we get to later
Encountering an unexpected red bar is a good indicator that you have to switch to the former.
Equality for All
The Dollar we wrote earlier is a Value Objects. As such, they should equal one another. We’ll write a test for that:
public function testEquality() {
$this->assertEquals((new Dollar(5)).equals(new Dollar(5)));
}
The Fake It implementation first:
public function equals(Object $object) {
return true;
}
The [[The Three Implementation Strategies of Test-Driven Development|third implementation strategy]] is called Triangulation:
![[Kent Beck - Test-Driven Development#^highlight-367358820]] So, we need a second example:
public function testEquality() {
$this->assertEquals((new Dollar(5))->equals(new Dollar(5)));
$this->assertNotEquals((new Dollar(5))->equals(new Dollar(6)));
}
Which breaks our Fake It Implementation, and we actually write the correct code by comparing the Dollars amounts.
4. Privacy
Now, we can rewrite our initial test like this:
public function testMultiplication() {
$five = new Dollar(5);
$this->assertEquals(new Dollar(10), $five->times(2));
$this->assertEquals(new Dollar(15), $five->times(3));
}
This reads much clearer and has the added benefit that we can now make the amount attribute of the Dollar class private.
5. Franc-ly speaking
Our next big goal is: $5 + 10 CHF = $10 if rate is 2:1. That’s a pretty big leap. So for now, we test and implement the Franc object.
To do, we duplicate the Dollars tests, copy the Dollar class and replace all instances of the word “Dollar” with “Franc”. Easy-peasy green bar.
But remember the fifth step of the [[The Rhythm of Test-Driven Development|TDD rhythm]]: Remove duplication.
6. Equality for all, Redux
We made a mess with the last test and implementation, so now we clean up by implementing a common superclass:
graph TD;
A(Money)-->B(Dollar);
A-->C(Franc);
We start by making a Money class:
class Money {}
Tests: ✅
Then, make Dollar extend Money. Tests: ✅ Obviously, because there’s no logic in Dollar yet.
From then, we slowly change the implementation of the equals method to accomodate Money objects instead of Dollars. While doing this, we notice that the Franc equals method still uses Dollar. Oops.
This is common in everyday coding: You refactor something that doesn’t have a test yet. It’s imperative to write the tests before refactoring. That’s the only way you can keep your trust in refacotring ([[Kent Beck - Test-Driven Development#^highlight-367380910]]).
public function testEquality() {
$this->assertTrue((new Dollar(5))->equals(new Dollar(5)));
$this->assertNotTrue((new Dollar(5))->equals(new Dollar(6)));
$this->assertTrue((new Franc(5))->equals(new Franc(5)));
$this->assertNotTrue((new Franc(5))->equals(new Franc(6)));
}
With that test in place, we can safely refactor Franc’s equality method and pull the logic into the superclass.
7. Apples and Oranges
Here’s a problem:
public function testEquality() {
// ...code
$this->assertFalse((new Franc(5))->equals(new Dollar(6)));
}
Tests: ❌
Dollar and Franc objects are, so far, interchangeable. We can change the equals method like so:
public function equals(Object $object) {
$money = (Money)$object;
return $this->amount == $money->amount && __CLASS__ == $money->getClass());
}
This code smells a bit, because we’d rather use something from the domain of finance (Hint: currency) than the domain of Java, but alas.
8. Makin’ Objects
We can reconcile the times method of both classes by making them return Money instead of their respective classes. The next big step would be to get rid of the two subclasses and consolidate them into Money. Beeeeg step.
First step could be to introduce a factory class in Money that returns a Dollar:
public function testMultiplication() {
$five = Money::dollar(5);
$this->assertEquals...
}
# Money
public static function dollar(int amount): Dollar {
return new Dollar(amount);
}
Now our compiler nags us because Money doesn’t have a times method, so we make Money abstract:
abstract class Money {
abstract public function times(int $multiplier): Money;
}
public static function dollar(int amount): Money {
return new Dollar(amount);
}
Instead of instancing Dollar objects with the new keyword, we can use the Factory method and save a few calls to the Dollar class.
9. Times We’re Livin’ In
The next step is introducing tests for currencies. They could look like this:
public function testCurrency() {
$this->assertEquals('USD', Money::dollar(5)->currency());
$this->assertEquals('CHF', Money::franc(1)->currency());
}
To get to ✅, we can change our Dollar and Franc objects like so:
class Franc {
private string $currency;
public function __construct(int $amount) {
$this->amount = $amoun;
$this->currency = 'CHF';
}
public function currency(): string {
return $this->currency;
}
}
Tests pass when we do the same to Dollar, which makes us notice code duplication. [[The Rhythm of Test-Driven Development|Step 5]] tells us to reduce duplication, so change Money to look like this:
class Money {
protected string $currency;
public function currency(): string {
return $this->currency;
}
}
…and delete the code from the subclasses. Now we want to move the constant strings “USD” and “CHF” to the static factory methods. In steps:[^1]
- add a parameter to the constructor:
__construct(int $amount, string $currency) - this breaks the calls to the constructor from the factory methods:
public static franc(int $amount): Money { return new Franc($amount, null); } - Make the factory function pass the currency:
public static franc(int $amount): Money { return new Franc($amount, 'CHF'); } - Assign the currency to the attribute:
__construct(int $amount, string $currency) { $this amount = $amount; $this->currency = $currency; }
Now the constructors for Dollar and Franc are identical, so we can push it up to the superclas:
# Money
public function __construct(int $amount, string $currency) {
$this->amount = $amount;
$this->currency = $currency;
}
# Dollar
public function __construct(int $amount, string $currency) {
parent::__construct($amount, $currency);
}
# Franc
public function __construct(int $amount, string $currency) {
parent::__construct($amount, $currency);
}
cough cough Duplication cough cough
10. Interesting Times
Our goal is still to do away with Franc and Dollar and just use Money. Now we want to make the implementations of times identical. To do this, we inline the factory method:
# Franc
public function times(int $multiplier): Money {
return new Franc($this->amount * $multiplier, "CHF")
}
# Same for Dollar
We know the currency variable, so we can write:
# Franc
public function times(int $multiplier): Money {
return new Franc($this->amount * $multiplier, $this->currency)
}
# Same for Dollar
The question is this: Does it matter if I have a Franc or Money object? Instead of answering this by thinking, we can just change something and let the tests answer. To experiment, we change the times method in Franc:
public function times(int $multiplier): Money {
return new Money($this->amount * $multiplier, $this->currency);
}
The compiler complains, because Money is abstract, so we make it concrete. We get a ❌ in the tests because our equals function compares the classes of two objects instead of the currency. We write the test (❌), and then the implementation (✅):
public function testDifferentClassEquality() {
$this->assertTrue((new Money(10, "CHF"))->equals(new Franc(10, "CHF")));
}
public function equals(Object $object): boolean {
$money = (Money)$object;
return $this->amount == $money->amount && $this->currency == $money->currency;
}
Now we can also change the times function in Dollar as we did in Franc and push both of them up to the Money superclass.
11. The Root of All Evil
Now we can eleminate the subclasses! This also makes it important to delete some tests we wrote earlier, which pertain to the subclasses. This is fine and part of the process.
12. Addition, Finally
Our goal is still $5 + 10 CHF = $10 if rate is 2:1. Still too much of a beeeg step, so let’s start with a simple addition test:
public function testSimpleAddition() {
$sum = Money::dollar(5)->plus(Money::dollar(5));
$this->assertEquals(Money::dollar(10), $sum);
}
We could fake the implementation, but it’s too trivial, so we do it properly:
public function plus(int $addend): Money {
return new Money($this-> amound + $addend, $this->currency);
}
Now we begin taking big steps forward. We decide that using arithmetic operations on Money classes should result in an Expression class. An expression as in “$5 + 10 CHF”. An example of an Expression would be a Sum. We also want to be able to reduce our Expression back into a Money, so we will write a Bank object to do so. In code:
public function testSimpleAddition() {
$five = Money::dollar(5);
$sum = $five->plus(5);
$this->assertEquals(get_class($sum), Expression::class);
$bank = new Bank();
$reduced = bank->reduce($sum, 'USD');
$this->assertEquals(Money::dollar(10), $reduced)
}
// to get this to compile, we need an interface
interface Expression {}
// And Money->plus needs to return an Expression
public function plus(int $addend): Expression {
return new Money($this->amount + $addend->amount, $this->currency);
}
// Which means that Money needs to implement Expression
class Money implements Expression {...}
// Stub a Bank and Fake It when it comes to implementation
class Bank {
public function reduce(Expression $source, string $to):Money {
return Money::dollar(10);
}
}
Why does the Bank object have the reduce function and not the expression? Because Expression will be at the heart of this entire operation, and it’s too tempting to make it a [[God Object]].
We’re now back to a green test ✅ and are ready to refactor.
13. Make It
A lot of refactoring happening here that I’m not typing. Read the chapter in the book. ;)
14. Change It
Now it’s getting interesting: we want the Bank object to be able to make currencies interchangeable. We start with a test:
public function testReduceMoneyDifferentCurrency() {
$bank = new Bank();
$bank->addRate("CHF","USD",2);
$result = $bank->reduce(Money::franc(2), "USD");
$this->assertEquals(Money::dollar(1), $result);
}
Easy, obvious, ugly implementation:
# Money
public function reduce(string $to): Money {
// 🤮
$rate = ($this->currency == 'CHF' && $to == 'USD')
? 2
: 1;
return new Money($this->amount / $rate, $to);
}
This is some garbage, because Money should have no idea about exchange rates. That’s the Bank’s responsibility. Through some refactoring, we end up with:
# Bank
public function rate(string $from, string $to) {
return ($from == 'CHF' && $to == 'USD')
? 2
: 1;
}
# Money
public function reduce(Bank $bank, string $to): Money {
int rate = $bank->rate($this->currency, $to);
return new Money($this->amount / $rate, $to);
}
From there, the next big ugliness is the constant 2 as the rate, so we implement a Rate class and a hashtable to hold those so we can look up the exchange rate between two currencies (again, too much code to type).