Test doubles
This guide covers test doubles: stand-ins for services an App uses, in a test. You will learn how to:
- Replace one of the App's services for a test with
tinkatest.Fake - Write a fake behind a small interface, and check it against the real service
- Record the App's events instead of delivering them
- Know when a double makes a test worse
Overview
A test of a handler that sends an email shouldn't send one. It replaces the mailer with a test double, a stand-in the test controls, and checks what the handler asked of it. The common kinds:
| Double | Does | Use it for |
|---|---|---|
| Fake | works, simply: an in-memory store, a mailbox that keeps what it was sent | services with state, the usual choice |
| Stub | returns one canned answer, or an error | one path, such as a payment that's declined |
| Spy | records how it was called | when the call itself is the behavior, such as an event announced to other modules |
Prefer fakes. A test that checks what a fake ended up holding, such as "Ada has one email", survives a change to how the code sends it; a test that checks each call breaks on any refactor.
Replace a service
tinkatest.Fake[T] makes every service of type T the App resolves from its container be the value you give, until the test ends. Call it before tinkatest.New starts the App: services built when the App boots keep what they were given, so a later swap wouldn't reach them, and Fake fails the test to say so.
func TestSignupSendsAWelcome(t *testing.T) {
t.Parallel()
app := tests.App(t)
mailer := &tests.Mailer{}
tinkatest.Fake[mail.Mailer](t, app, mailer)
c := tinkatest.New(t, app)
c.Post("/signup").Form(url.Values{"email": {"ada@example.com"}}).Send(t).AssertRedirect("/welcome")
if got := mailer.To("ada@example.com"); len(got) != 1 || got[0].Subject != "Welcome" {
t.Errorf("emails to Ada = %+v, want one welcome", got)
}
}Each test builds its own App, so a fake never reaches another test, even in parallel.
Write a fake
Fakes swap best behind a small interface that the code using the service declares, with only the methods it calls. The App registers the real service under that interface, and the test's fake takes its place:
package mail
import "context"
// A Message is an email to send.
type Message struct {
To, Subject, Body string
}
// A Mailer sends email.
type Mailer interface {
Send(ctx context.Context, m Message) error
}package tests
import (
"context"
"sync"
"blog/app/mail"
)
// Mailer keeps what it's sent, for tests to check.
type Mailer struct {
mu sync.Mutex
sent []mail.Message
}
func (f *Mailer) Send(ctx context.Context, m mail.Message) error {
f.mu.Lock()
defer f.mu.Unlock()
f.sent = append(f.sent, m)
return nil
}
// To returns the messages sent to address.
func (f *Mailer) To(address string) []mail.Message {
f.mu.Lock()
defer f.mu.Unlock()
var out []mail.Message
for _, m := range f.sent {
if m.To == address {
out = append(out, m)
}
}
return out
}The lock matters: the App handles requests on goroutines of its own, and go test -race reports a fake that isn't safe for them.
A fake is only useful while it behaves like the real service. Write the expectations both must meet as one test function, and run it against each: the fake in every run, and the real service where it can run, such as against a test inbox.
Fake events
tinkatest.Events records the App's events instead of delivering them, until the test ends:
app := tests.App(t)
sent := tinkatest.Events(t, app)
c := tinkatest.New(t, app)
c.Post("/signup").Form(form).Send(t).AssertRedirect("/welcome")
sent.AssertEmitted[UserRegistered](t, func(e UserRegistered) bool { return e.Email == "ada@example.com" })Assert an event when the event is the contract, such as one another part of the App reacts to. When what matters is the effect, such as the welcome email, let the listener run and check the effect instead.
Hash passwords quickly
Hashing a password for real takes about 130 milliseconds, on purpose. The skeleton's tests.App replaces the App's *password.Hasher with password.Fake(), which takes under a millisecond, so tests that log in stay fast. Hashes it makes only verify in tests.
When a double makes a test worse
- More than about three doubles in one test usually means the code under test does too much. Move the work into a type of its own, and test it directly.
- A double that copies half of another service's behavior will drift from it. Check it against the real one, as above, or use the real one.
- Don't fake the database driver, or assert on the SQL a query sends: test code that reads and writes data against a real database.
- A test that breaks when you refactor without changing behavior checks how the code works rather than what it does. Assert on outcomes.
See also: HTTP tests, Dependency injection, Emitter