In this video, Robert Martin uses Kotlin and JUnit to illustrate his Three Laws of TDD. But what about Go? Follow me and challenge the master! We will walk in his footsteps with the only help of Brad Fitzpatrick’s checkFunc pattern.
The Three Laws
- You are not allowed to write any production code unless it is to make a failing unit test pass.
- You are not allowed to write any more of a unit test than is sufficient to fail; and compilation failures are failures.
- You are not allowed to write any more production code than is sufficient to pass the one failing unit test.
Prime factors
Step 1: the API
We want a function to list the prime factors of a given number.
No clue how to solve the problem, but we might have a function signature already. Let’s start with this.
// factorsof.go
func factorsOf(n int) []int
Next, we have to define our checkFunc
: a test function checker. Think of it as a matcher function that takes as arguments all the interesting values our factorsOf
function produce.
In our case, we are only interested in the slice of integers that factorsOf
returns. That will be the only argument of our checker type.
// factorsof_test.go
type checkFunc func([]int) error
Step 2: the first failing test
Let’s lay a table-driven test.
func TestFactorsOf(t *testing.T) {
type checkFunc func([]int) error
// SECTION 1: checkers
isEmptyList := func(have []int) error {
if len(have) > 0 {
return fmt.Errorf("expected empty list, found %v.", have)
}
return nil
}
// SECTION 2: test cases
tests := [...]struct {
in int
check checkFunc
}{
// the first test: factors of 1
{1, isEmptyList},
}
// SECTION 3: test logic
for _, tc := range tests {
t.Run(fmt.Sprintf("Factors of %d", tc.in), func(t *testing.T) {
factors := factorsOf(tc.in)
if err := tc.check(factors); err != nil {
t.Error(err)
}
})
}
}
- isEmptyList is our first checker. It returns an error if the list is not empty.
- tests is the array of cases we want to test. A test case in an unnamed struct that carries the input to feed the target function with; and a checkFunc for checking the return value.
- The first case states that we expect that input 1 will produce an empty list.
- In the last section, we iterate over tests and we spin a named subtest (t.Run) for each case.
The production code that is sufficient to pass the failing test will then be:
// factorsof1.go
func factorsOf(n int) []int {
return []int{}
}
Remember: not more production code than is sufficient to pass the one failing unit test. Now this sentence has a visual meaning :)
Note that so far, that’s a ~10 / 1 LOC ratio between the test code and the production code. That’s fine. We are using a verbose pattern; it’ll get slightly better after a couple iterations.
Step 3: iterate
The prime factor of 2 is 2. First of all, we need a new checker to check a non-empty list. Here it is!
is := func(want ...int) checkFunc {
return func(have []int) error {
if !reflect.DeepEqual(have, want) {
return fmt.Errorf("expected list %v, found %v.", want, have)
}
return nil
}
}
It’s a closure: the actual matcher is a function that is dynamically generated based on the expected value that is passed to the parent function.
// factorsof2_test.go
func TestFactorsOf(t *testing.T) {
type checkFunc func([]int) error
// SECTION 1: checkers
isEmptyList := func(have []int) error {
if len(have) > 0 {
return fmt.Errorf("Expected empty list, found %v.", have)
}
return nil
}
is := func(want ...int) checkFunc {
return func(have []int) error {
if !reflect.DeepEqual(have, want) {
return fmt.Errorf("Expected list %v, found %v.", want, have)
}
return nil
}
}
// SECTION 2: test cases
tests := [...]struct {
in int
check checkFunc
}{
{1, isEmptyList},
{2, is(2)},
}
// SECTION 3: test logic
for _, tc := range tests {
t.Run(fmt.Sprintf("Factors of %d", tc.in), func(t *testing.T) {
factors := factorsOf(tc.in)
if err := tc.check(factors); err != nil {
t.Error(err)
}
})
}
}
// factorsof2.go
func factorsOf(n int) []int {
var factors []int
if n > 1 {
factors = append(factors, 2)
}
return factors
}
Is this cheating? Sure not. We are asked for the minimal required code; there will be room for refactoring in the next steps.
{3, is(3)}
Now is the time to generalise a bit!
// factorsof3.go
func factorsOf(n int) []int {
var factors []int
if n > 1 {
factors = append(factors, n)
}
return factors
}
That 2 we were appending to factors
is now an n
. This change involves a single character and it’s the most effective in the whole process.
{4, is(2, 2)}
OK let’s first check if the number is divisible by 2, and append that to the slice.
// factorsof4.go
func factorsOf(n int) []int {
var factors []int
// return an empty slice if n <= 1
if n > 1 {
// factor 2
if n%2 == 0 {
factors = append(factors, 2)
n /= 2
}
// append the remainder
if n > 1 {
factors = append(factors, n)
}
}
return factors
}
{5, is(5)},
{6, is(2, 3)},
{7, is(7)},
Awesome, tests are still passing.
{8, is(2, 2, 2)},
Uh-oh. We never instructed our algorithm to output more than two factors.
// factorsof8.go
func factorsOf(n int) []int {
var factors []int
if n > 1 {
// factor all the 2s
for n%2 == 0 {
factors = append(factors, 2)
n /= 2
}
// append the remainder
if n > 1 {
factors = append(factors, n)
}
}
return factors
}
Just by turning an if
into a for
, we now have a loop and our algorithm is not bound to return a finite number of factors anymore.
{9, is(3, 3)},
In order to be able to factor 3s, we could add a new for
cycle for factoring threes.
This is maybe the most difficult part of this exercise: we have to take that decision. Is it time to refactor yet? This question has led to uncountable ticket explosions in the history of software engineering. The answer is often subjective. In this case, the decision is pretty straightforward:
// factorsof9.go
func factorsOf(n int) []int {
var factors []int
for divisor := 2; n > 1; divisor++ {
for n%divisor == 0 {
factors = append(factors, divisor)
n /= divisor
}
}
return factors
}
Here we go.
Apparently, test driven development is a way to incrementally derive solutions to problems.
As Mr. Martin puts it: we didn’t design the algorithm upfront. Where did it come from? We just made the test cases pass, one by one.
// factorsof9_test.go
package main
import (
"fmt"
"reflect"
"testing"
)
func TestFactorsOf(t *testing.T) {
type checkFunc func([]int) error
// checkers
isEmptyList := func(have []int) error {
if len(have) > 0 {
return fmt.Errorf("Expected empty list, found %v.", have)
}
return nil
}
is := func(want ...int) checkFunc {
return func(have []int) error {
if !reflect.DeepEqual(have, want) {
return fmt.Errorf("Expected list %v, found %v.", want, have)
}
return nil
}
}
// test cases
for _,tc := range [...]struct {
in int
check checkFunc
}{
{1, isEmptyList},
{2, is(2)},
{3, is(3)},
{4, is(2, 2)},
{5, is(5)},
{6, is(2, 3)},
{7, is(7)},
{8, is(2, 2, 2)},
{9, is(3, 3)},
{2 * 2 * 3 * 3 * 5 * 7 * 11 * 11 * 13, is(2, 2, 3, 3, 5, 7, 11, 11, 13)},
} {
t.Run(fmt.Sprintf("Factors of %d", tc.in), func(t *testing.T) {
factors := factorsOf(tc.in)
if err := tc.check(factors); err != nil {
t.Error(err)
}
})
}
}
Have fun and write tests!