Strict test-first development suggests that when you define a new function, you proceed as follows:
- Define the function signature.
- Define test cases for the function.
- Implement the function until all tests pass.
In practice, this doesn’t work, and trying it wastes time. Instead, I recommend to grow tests organically together with your code. To illustrate this, I’ll use some snippets from a fictitious coding session and comment on the state of things after each iteration.
Let’s say we want to build some sort of roguelike game. We could begin like this:
package main
type Game struct{}
func (*Game) Run() error {
return nil
}
func main() {
g := Game{}
g.Run()
}
This code is pretty basic and has only one function that could potentially be tested: Game.Run
(). Test-first would require a test for this now, and a specification for the behavior we want this to have. That’s problematic for two reasons:
Run()
is the game loop. At this stage, we simply don’t know what this is going to do and how. So specifying what this function should do at a level of detail for a unit test is probably not possible, it’s certainly not efficient.Run()
is currently empty, and it will continue to do very little for quite a while. Writing a unit test for this would end either with a change-detector test, or a noop-test. Neither adds value, and the change detector creates a lot of churn in addition.
So instead of writing a unit test, we just move on.
package main
type Tile struct{}
type Map struct {
Tiles [][]Tile
}
func (m *Map) Draw() {
for _, row := range m.Tiles {
for _, t := range row {
// Draw t
}
}
}
func TestMap() *Map {
// returns some test data, omitted for brevity
return nil
}
type Game struct {
Map *Map
}
func (*Game) Run() error {
return nil
}
func main() {
g := Game{Map: TestMap()}
g.Run()
}
This iteration adds a few more functions, some actually containing a little bit of code. But there is still very little complexity, and at the same time, many things are still in flux. Consider, for example, drawing the map. In the actual game, it will draw graphics, but for testing, we probably want text output. At this stage, it’s already clear that the code we have isn’t sufficient and will change in the next iteration. It’s also not entirely clear how we will accommodate different drawing methods. Sure, we could write a test, but it would do very little, and would have to change a lot in the next iterations. So the argument from the previous section remains: it’s just not worth it just yet.
package main
import "fmt"
type Drawer interface {
Draw(t Tile)
}
type ConsoleDrawer struct{}
func (*ConsoleDrawer) Draw(t Tile) {
fmt.Printf("%+v", t)
}
type Tile struct{}
type Map struct {
Tiles [][]Tile
drawer Drawer
}
func (m *Map) Draw() {
for _, row := range m.Tiles {
for _, t := range row {
m.drawer.Draw(t)
}
}
}
func TestMap() *Map {
// returns some test data, omitted for brevity
return &Map{drawer: &ConsoleDrawer{}}
}
type Game struct {
Map *Map
}
func (*Game) Run() error {
return nil
}
func main() {
g := Game{Map: TestMap()}
g.Run()
}
Now we’ve taken a stab at abstracting the drawing behavior and implemented a very basic Drawer. With this, we can seriously start thinking about writing tests for the drawing behavior. But we immediately see that drawing currently produces only console output. That’s hard to test, so we need something more accessible before we can write a test.
Notice, though, how the discussion starts to shift from growing code to testing it.
package main
import (
"fmt"
"strings"
)
type Drawer interface {
Draw(t Tile) error
}
type ConsoleDrawer struct {
drawer StringDrawer
}
func (c *ConsoleDrawer) Draw(t Tile) error {
if err := c.drawer.Draw(t); err != nil {
return err
}
fmt.Print(c.drawer.String())
return nil
}
type StringDrawer struct {
buffer *strings.Builder
}
func (s *StringDrawer) Draw(t Tile) error {
if s.buffer == nil {
return fmt.Errorf("Drawer not initialzied")
}
s.buffer.WriteString(fmt.Sprintf("%+v", t))
return nil
}
func (s *StringDrawer) String() string {
return s.buffer.String()
}
type Tile struct{}
type Map struct {
Tiles [][]Tile
drawer Drawer
}
func (m *Map) Draw() {
for _, row := range m.Tiles {
for _, t := range row {
m.drawer.Draw(t)
}
}
}
func TestMap() *Map {
// returns some test data, omitted for brevity
return &Map{drawer: &ConsoleDrawer{}}
}
type Game struct {
Map *Map
}
func (*Game) Run() error {
return nil
}
func main() {
g := Game{Map: TestMap()}
g.Run()
}
This iteration shows another reason why early testing often is a waste of time. If we had tests already, even empty ones, we’d need to update them now that the signature of Draw()
changed. Given that so far we would have had no benefit from any test, that would be wasted time, even if IDEs would do most of the heavy lifting. If they even can do the heavy lifting depends a lot on the language and test framework. In my experience, you’d be left with a lot of manual work.
Further, the current iteration comes with it’s own problems that suggest that more work is required. With the new StringDrawer
at least we can produce string output, and write tests that compare those strings. However, you probably noticed that this code is full of flaws. The string buffer gets initialized only once and keeps accumulating writes forever. Also, now the drawer contains state, and that state cannot be meaningfully managed by the drawer itself. The drawer operates on tiles, so it doesn’t even need state. It could just return the string for the last tile, leaving it to the client to construct larger strings. So this already highlights a few more iterations to tidy up this code before it reaches a state that might be stable enough to write an actual test for drawing a map.
This discussion leaves us with a few obvious questions:
- Should you have unit tests at all?
Absolutely. Unit tests are still the foundation of rapid and agile development, and you cannot succeed without them. - When should you start writing unit tests?
My rule of thumb is to create a unit test as soon as you can clearly define what the behavior of a function should be. - How many unit tests should you have?
As long as adding more tests has significant additional value, add more. There is not fix number or percentage or such.
Someone might argue that all the steps we went through in the above example could be done in one go by thinking about the problem hard for a while before writing any code. And while that’s a theoretical possibility, I don’t work that way, and I never met anyone who works that way. Also, this is a snippet from a coding session that takes maybe a few minutes, and is at the very start of a project, so there is no additional context to the code. In real projects, almost all the code you write has context, which makes it much harder to consider all implications up front. So rather than propagating a theoretical ideal, I’m giving practical advice here.
If there is one thing I want you to take from this article, it is that you should be smart about when to write tests and which tests to write. Not all code needs unit tests. For example, the Game.Run()
function will probably be better served with E2E tests.
A few rough and ready guidelines:
- Write unit tests when you understand the intended behavior of a function or method, not before.
- All public functions or methods of a class/module/package should be covered by reasonable unit tests when 1) is true.
- Internal methods may significantly benefit from unit tests too. This will be very contentious with some people. I guess we simply have to disagree here.
- Be aware of change detector tests. If you have a function where sensible test you can come up with becomes a change detector, you probably should refactor your function.
- Remove tests that have not enough value.
Required Reading
Change-Detector Tests Considered Harmful