tinka alpha
tinkaalpha

Test doubles

This guide covers test doubles: stand-ins for services an App uses, in a test. You will learn how to:

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.

tests/functional/signup_test.go
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:

app/mail/mail.go
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
}
tests/fakes.go
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:

go
	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

See also: HTTP tests, Dependency injection, Emitter