Back to Home

TDD and Testing Behavior

Red, Green, Refactor

Test-Driven Development (TDD) is the practice of writing a failing unit test before writing the code to make the test pass, then cleaning up your code. This is the "red, green, refactor" cycle.

It helps with clarity of thought, increases flow, and results in modular designs that are maintainable. You can probably tell I'm a fan. It's one of the practices that has helped me become a better software engineer over the years. While there are a lot of resources out there to learn about TDD, most of them gloss over an important aspect that makes it much more effective: it matters what you try to test.

Test the Behavior

For TDD to be effective it's important to test the behavior of a component instead of its internal implementation details. This allows changing the component's internal logic without changing its tests. If the component's interface to other components in the system does not change and it can be used the same way as before, then the tests should not need to change either. This allows each individual component of the system to be enhanced, fixed, refactored, rebuilt using a different technology, or even completely replaced with minimal changes to the unit tests.

If the tests are tightly coupled to the internal implementation of a component, then it makes it difficult to modify the component without also modifying the tests. By focusing on only testing the behavior of a component, the unit tests should not be touched at all when changing its internal implementation. Instead, the tests become an important tool to verify that the internal change has not broken the component's behavior.

Component Interfaces

Whenever we build a system, be it large or small, we should break it down into components that each have a unique responsibility. This modularity provides a lot of benefits like easier restructuring, simplicity, maintainability, testability, reducing the cognitive load of software engineers, and more. By identifying the components, we're also defining the boundaries between them and the interfaces they use to communicate with each other.

For each component we should strive to make it easy to change the internal implementation of the component, but we should make it a bit harder to change the interfaces between them. These interfaces are the components' API contracts. The hard part is figuring out the granularity of the components and their responsibility. This comes with experience and depends on many factors. There are many schools of thought on how to break down a system and identify its components (e.g. domain-driven design, event storming, event modeling, clean architecture, etc.), but I generally recommend designing it out visually and running through a few of the most important user scenarios. If you find the current design difficult and complicated, try a different one and keep iterating until you find one that works well.

With some practice, you'll get better at identifying reasonable component boundaries.

Example Repository Implementation

One component boundary I find particularly useful encapsulates access to persistent data using the repository pattern. When building a feature that requires storing and retrieving data, it's usually beneficial to isolate the details of the database technology from the application logic. A repository's single responsibility is to manage the persistent data for an application (or a portion of it).

Let's build a simple repository in Go that shows the difference between the bad implementation-driven vs the good behavior-driven approaches to writing tests. We'll create a repository that saves and retrieves users. Each user only has a name, and just for simplicity we'll "persist" the data in-memory using a map, but you can imagine a DB engine, file system, or object store instead.

Both approaches are shown below:

Here's the code from these examples if you want to compare the results side-by-side: github.com/benjohns1/tdd-and-testing-behavior

Implementation-driven approach to testing

Don't do it like this!

Here's what a user looks like:

File: user.go
package app

type User struct {
	Name string
}

And here's a stub user repo to start with:

File: repo/users.go
package repo

type Users struct{}

Save a User

Let's start with saving a user in the repo. We want a Save() method that accepts a user and returns a possible error. We'll write a test for a repo method Save() that we haven't written yet, so it will fail to compile initially.

Test: store a user by name to an empty map

Here's our first failing test, setup with a common table-driven test structure that I find useful:

Red Step 1
File: repo/users_test.go
package repo

import (
	app "github.com/benjohns1/tdd-and-testing-behavior/implementation-driven"
	"reflect"
	"testing"
)

func TestUser_Save(t *testing.T) {
	type args struct {
		user app.User
	}
	tests := []struct {
		name    string
		repo    Users
		args    args
		want    Users
		wantErr error
	}{
		{
			name: "store a user by name to an empty map",
			args: args{
				user: app.User{Name: "Ender"},
			},
			want: Users{
				map[string]app.User{
					"Ender": {Name: "Ender"},
				},
			},
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			err := tt.repo.Save(tt.args.user)
			if !reflect.DeepEqual(err, tt.wantErr) {
				t.Errorf("Save() error = %v, wantErr %v", err, tt.wantErr)
				return
			}
			if !reflect.DeepEqual(tt.repo, tt.want) {
				t.Errorf("Save() = %+v, want %+v", tt.repo, tt.want)
			}
		})
	}
}

This test will fail to compile, so let's write the code to make it pass:

Green Step 2
File: repo/users.go
package repo

import app "github.com/benjohns1/tdd-and-testing-behavior/implementation-driven"

type Users struct {
	users map[string]app.User
}

func (u *Users) Save(user app.User) error {
	u.users = map[string]app.User{
		user.Name: user,
	}
	return nil
}

Alright, our test passes!

Refactor Step 3

The next step is to refactor our code and clean it up, but the code is so simple at this point let's move on to the next test cycle.

Test: store a new user by name to a map that already has a user
Red Step 1
File: repo/users_test.go (partial)
// ...
		{
			name: "store a new user by name to a map that already has a user",
			repo: Users{
				map[string]app.User{
					"Ender": {Name: "Ender"},
				},
			},
			args: args{
				user: app.User{Name: "Valentine"},
			},
			want: Users{
				map[string]app.User{
					"Ender":     {Name: "Ender"},
					"Valentine": {Name: "Valentine"},
				},
			},
		},
// ...

This test will fail because of our overly-simplistic implementation from the first test, so let's fix that:

Green Step 2
File: repo/users.go (partial)
func (u *Users) Save(user app.User) error {
	if u.users == nil {
		u.users = map[string]app.User{
			user.Name: user,
		}
	} else {
		u.users[user.Name] = user
	}
	return nil
}

This makes our test pass, but now we have a bit of cruft in our logic so let's simplify it:

Refactor Step 3
File: repo/users.go (partial)
func (u *Users) Save(user app.User) error {
	if u.users == nil {
		u.users = make(map[string]app.User, 1)
	}
	u.users[user.Name] = user
	return nil
}

And our test is still passing!

Test: return an error if the user's name already exists

If a user already exists in our repo with the same name, we want an error to be returned so let's write a test for that:

Red Step 1
File: repo/users_test.go (partial)
// ...
		{
			name: "return an error if the user's name already exists",
			repo: Users{
				map[string]app.User{
					"Peter": {Name: "Peter"},
				},
			},
			args: args{
				user: app.User{Name: "Peter"},
			},
			want: Users{
				map[string]app.User{
					"Peter": {Name: "Peter"},
				},
			},
			wantErr: fmt.Errorf("user name \"Peter\" already exists"),
		},
// ...
Green Step 2
File: repo/users.go (partial)
func (u *Users) Save(user app.User) error {
	if _, exists := u.users[user.Name]; exists {
		return fmt.Errorf("user name \"Peter\" already exists")
	}
	if u.users == nil {
		u.users = make(map[string]app.User, 1)
	}
	u.users[user.Name] = user
	return nil
}

Notice the naive hard-coded error message? It's the simplest code that makes our test pass, but we want to clean that up in the refactor step:

Refactor Step 3
File: repo/users.go (partial)
func (u *Users) Save(user app.User) error {
	if _, exists := u.users[user.Name]; exists {
		return fmt.Errorf("user name %q already exists", user.Name)
	}
	if u.users == nil {
		u.users = make(map[string]app.User, 1)
	}
	u.users[user.Name] = user
	return nil
}

Here's the code for this approach

Is there a better way?

At this point, we've only written tests and functionality around saving a user. We could continue on to build out the functionality to retrieve users taking the same approach. It's great that we're using the basic TDD red, green, refactor cycle, but first think about what would happen if we needed to change the implementation. Could we add a user ID field index? Or upgrade to a persistent DB instead of in-memory? In either of these scenarios we'd need to completely rewrite all of these tests because they are tightly coupled to the internal implementation of the user repo.

I want to show you a better way.

Behavior-driven approach to testing

Let's start off fresh with our basic user and repo stub:

File: user.go
package app

type User struct {
	Name string
}
File: repo/users.go
package repo

type Users struct{}

Save a User

Just like before, let's start with saving a user in the repo. But instead of thinking in terms of the steps that the repo will need to take to accomplish this, think about how we want the repo to behave when it is used. How does this change our perspective of the Save() method? Well, what behavior do we want from the repo when we save a user? After we save a user, we should then be able to get it back! If we think of it this way, then it doesn't matter how the repo stores the data. It only matters that if we save it, we can then get the same data back.

Let's use TDD to write our first behavioral test.

Spec: Should save a user to an empty repo

When writing this first test, we need to spend a bit more time thinking about the interface to get a user back out of the repo, too. In this example we're going to use a GetAll() function that returns a slice of all the users in the repo. We could also retrieve a single user by name, or some other way that aligns with the other use-cases.

Red Step 1

Notice the package name is repo_test instead of just repo. We want to test the repo's interface so we only want to access its publicly exported methods. Go allows appending _test to the package name to enforce this.

File: repo/users_test.go
package repo_test

import (
	app "github.com/benjohns1/tdd-and-testing-behavior/behavior-driven"
	"github.com/benjohns1/tdd-and-testing-behavior/behavior-driven/repo"
	"reflect"
	"testing"
)

func TestUser_Save(t *testing.T) {
	type args struct {
		user app.User
	}
	tests := []struct {
		name      string
		repo      repo.Users
		args      args
		wantErr   error
		wantUsers []app.User
	}{
		{
			name: "should save a user to an empty repo",
			args: args{
				user: app.User{Name: "Ender"},
			},
			wantUsers: []app.User{{Name: "Ender"}},
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			err := tt.repo.Save(tt.args.user)
			if !reflect.DeepEqual(err, tt.wantErr) {
				t.Errorf("Save() error = %v, wantErr %v", err, tt.wantErr)
				return
			}
			got, err := tt.repo.GetAll()
			if err != nil {
				t.Fatal(err)
			}
			if !reflect.DeepEqual(got, tt.wantUsers) {
				t.Errorf("After Save(), GetAll() = %+v, wantUsers %+v", got, tt.wantUsers)
			}
		})
	}
}
Green Step 2

To make this test pass, we need both a Save() and GetAll() method. Remember, we're just writing the bare minimum code to make the test pass in this step!

File: repo/users.go
package repo

import (
	app "github.com/benjohns1/tdd-and-testing-behavior/behavior-driven"
)

type Users struct{}

func (u *Users) Save(user app.User) error {
	return nil
}

func (u *Users) GetAll() ([]app.User, error) {
	return []app.User{{Name: "Ender"}}, nil
}
Refactor Step 3

Now we refactor our naive code into little better solution. And we have our test to verify it is still correct:

File: repo/users.go (partial)
type Users struct {
	user app.User
}

func (u *Users) Save(user app.User) error {
	u.user = user
	return nil
}

func (u *Users) GetAll() ([]app.User, error) {
	return []app.User{u.user}, nil
}
Spec: Should save a user to a repo that already has a user in it
Red Step 1
File: repo/users_test.go (partial)
// ...
		{
			name: "should save a user to a repo that already has a user in it",
			repo: func() repo.Users {
				r := repo.Users{}
				if err := r.Save(app.User{
					Name: "Ender",
				}); err != nil {
					t.Fatal(err)
				}
				return r
			}(),
			args: args{
				user: app.User{Name: "Valentine"},
			},
			wantUsers: []app.User{
				{Name: "Ender"},
				{Name: "Valentine"},
			},
		},
// ...
Green Step 2
File: repo/users.go (partial)
type Users struct {
	users []app.User
}

func (u *Users) Save(user app.User) error {
	u.users = append(u.users, user)
	return nil
}

func (u *Users) GetAll() ([]app.User, error) {
	return u.users, nil
}
Refactor Step 3

Let's make our repo encapsulation a bit better by not allowing mutation of the repo's internal slice:

File: repo/users.go (partial)
func (u *Users) GetAll() ([]app.User, error) {
	out := make([]app.User, 0, len(u.users))
	for _, user := range u.users {
		out = append(out, user)
	}
	return out, nil
}
Spec: Should fail if a user's name already exists
Red Step 1
File: repo/users_test.go (partial)
// ...
		{
			name: "should fail if a user's name already exists",
			repo: func() repo.Users {
				r := repo.Users{}
				if err := r.Save(app.User{
					Name: "Peter",
				}); err != nil {
					t.Fatal(err)
				}
				return r
			}(),
			args: args{
				user: app.User{Name: "Peter"},
			},
			wantUsers: []app.User{{Name: "Peter"}},
			wantErr:   fmt.Errorf("user name \"Peter\" already exists"),
		},
// ...
Green Step 2
File: repo/users.go (partial)
func (u *Users) Save(user app.User) error {
	for _, current := range u.users {
		if current.Name == user.Name {
			return fmt.Errorf("user name \"Peter\" already exists")
		}
	}
	u.users = append(u.users, user)
	return nil
}
Refactor Step 3

Clean up the naive error message:

File: repo/users.go (partial)
func (u *Users) Save(user app.User) error {
	for _, current := range u.users {
		if current.Name == user.Name {
			return fmt.Errorf("user name %q already exists", user.Name)
		}
	}
	u.users = append(u.users, user)
	return nil
}

Here's the code for this approach

Conclusion

Take a look at your implementation using the behavior-driven approach. For the same amount of effort, we've also implemented retrieving users from the repo, too. Now let's ask the same questions we did earlier: Could we add a user ID field index? Or upgrade to a persistent DB instead of in-memory? Both of these scenarios could be accomplished without modifying our existing tests (apart from maybe some test setup to implement a real DB backend).

Our behavior tests are much more robust, and they're validating what we really care about: the behavior of the repo. If the interface changes we want the tests to break because that means we've changed the repo's behavior. If we need to refactor the internals of the repo, we can use our tests to be confident that we haven't broken any existing use-cases.

TDD is an incredibly useful practice but only if you are testing behavior!

Related Posts

The essential design concepts I use when developing an evolvable, distributed system.

Read More

How can we continuously integrate small changes while practicing acceptance test-driven development?

Read More

When is it appropriate to use centralized orchestration versus event-driven choreography?

Read More

When defining a business problem and planning its solution, keep the two conversations separate...

Read More

Modern message brokers provide many important benefits to a distributed system...

Read More

Printable cheat sheets to help remember some of Uncle Bob's valuable contributions to the industry

Read More

Why Terraform?

December 25, 2019

Terraform leads the way in the infrastructure-as-code world...

Read More

I was looking for a quick and easy way to put together a personal static site and...

Read More

A few weeks ago, I decided to try Svelte's Sapper framework to handle the front-end of a simple app...

Read More

After years of consulting, I find myself continually coming back to three basic principles of system design...

Read More

In this fifth and final part of the Go middleware tutorial series, we'll use what we've learned to create a more structured API example...

Read More

Go Middleware - Part 4

February 24, 2019

In this fourth part of the Go middleware tutorial series, we'll discuss passing custom state along the request chain.

Read More

Go Middleware - Part 3

February 15, 2019

In this third part of the Go middleware tutorial series, we'll quickly look at a common variant on the recursive middleware implementation from part 2.

Read More

Go Middleware - Part 2

February 9, 2019

In this second part of the Go middleware tutorial series, we'll cover a recursive approach that provides a couple benefits beyond the simple loop chain example from part 1.

Read More

Go Middleware - Part 1

February 6, 2019

This is the first in a series of simple tutorials explaining the usage of HTTP middleware in Go.

Read More

How do we manage the architectural complexity that inevitably arises from using cloud services?

Read More

This Old Blog

January 20, 2019

I've decided to resurrect this old blog to publish some nuggets about software architecture and development, and perhaps...

Read More

Drupal 6 Theme Info Error

September 14, 2011

Recently one of my client sites had an issue where the custom theme info was corrupted...

Read More

Here's a slight modification to the handy Google Bookmarks Bookmarklet...

Read More

While building a Drupal site for one of my clients, I was having a heck of a time integrating...

Read More