go / test
Assertions come from is. Database tests insert fixtures through a test helper, each in a transaction that rolls back. No test reaches the network. CI reuses the Go caches across runs, with two flags that keep the result cache warm.
Assertions
func TestRename(t *testing.T) {
is := is.New(t)
co, err := Rename(id, "Acme")
is.NoErr(err)
is.Eq(co.Name, "Acme")
}
A failure reads:
rename_test.go:9: co.Name = Beta, want Acme
The variable shadows the package so a call site reads as English.
Eq,NotEqcompare two values.NoErr,HasErrcheck errors.Nil,NotNilcheck nil.Truechecks a predicate with no want to name, such asis.True(strings.Contains(s, x)).
New(t) fails fatally on the first failure. NewRelaxed(t) records
and continues, for a test that reports every mismatch in a loop.
Eq(x, nil) fails for a nil *T, because a typed nil boxed into
any is a non-nil interface. Nil handles it.
True(got == want) compiles, prints false, want true, and loses
both values.
Eq takes any
A method cannot have type parameters, so Eq takes any and gives up
compile-time type checking:
is.Eq(gotInt64, 3) // builds, then fails: int64 3 against int 3
When the two values format the same, the failure prints %T too. The
fix is a typed literal, int64(3).
Eq uses reflect.Value.Equal where both values are comparable, so
pointers compare by identity. It uses reflect.DeepEqual elsewhere,
so slices and maps compare by contents.
Label from source
co.Name is the source text of the assertion's first argument, read
back from the test file. There is no message to write.
The values always print. A failed file read degrades to
got X, want Y. A literal argument gets no label, because
[]int{1, 2} = []int{1, 2} is noise.
Under -trimpath, which the cache needs, runtime.Caller
returns a module-relative path that does not open. filepath.Base of
it does open, because go test runs each binary in its package
directory.
The package parses the whole file and takes the call node that covers the line. So a call spread over several lines keeps its label. The node must be a call to the helper. This check rejects a file with the same name from another package.
gotest.tools/v3/assert also reads the source, but opens the path
runtime.Caller returns, so under go test -trimpath its parse
fails. matryer/is prints the trailing comment, which is still a
hand-written message.
Fixtures
I use is beside database test fixtures:
func TestRename(t *testing.T) {
t.Parallel()
is := is.New(t)
db := test.NewDB(t)
co := db.InsertCompany()
err := Rename(t.Context(), db.DB, co.ID, "Acme")
is.NoErr(err)
is.Eq(db.FindCompany(co.ID).Name, "Acme")
}
db wraps the database connection and holds fixture helpers such as
db.InsertCompany(). is holds the assertions.
I keep the two helpers separate. db.Eq reads like a database query
instead of a test assertion.
The test runs the function and checks the result with is. Each
fixture method inserts a row into a test transaction:
type CompanyOpts struct {
Name string
Status string
}
type CompanyRow struct {
ID int64 `db:"id"`
Name string `db:"name"`
}
func (db *DB) InsertCompany(opts ...CompanyOpts) CompanyRow {
db.t.Helper()
var o CompanyOpts
if len(opts) > 0 {
o = opts[0]
}
if o.Name == "" {
o.Name = "Test Co"
}
if o.Status == "" {
o.Status = "active"
}
var row CompanyRow
err := db.QueryRow(db.ctx, `
INSERT INTO companies (name, status)
VALUES ($1, $2)
RETURNING id, name
`, o.Name, o.Status).Scan(&row.ID, &row.Name)
if err != nil {
db.t.Fatalf("insert company: %v", err)
}
return row
}
The options struct sets defaults that satisfy database constraints. A test overrides only the fields it tests.
The method returns the inserted row with its generated ID. If the
insert fails, t.Fatalf stops the test immediately.
The test database runs on a separate cluster. See postgres / dev test clusters.
A transaction, not a database
Each test process opens one pool against one test database. Each test takes a connection, begins a transaction, and rolls it back at cleanup:
func NewDB(t testing.TB) *DB {
t.Helper()
ctx := context.Background()
conn, err := pool.Acquire(ctx)
if err != nil {
t.Fatalf("acquire test conn: %v", err)
}
tx, err := conn.Begin(ctx)
if err != nil {
conn.Release()
t.Fatalf("begin test tx: %v", err)
}
t.Cleanup(func() {
tx.Rollback(ctx)
conn.Release()
})
return &DB{DB: pgdb.NewWithTx(tx), ctx: t.Context(), t: t}
}
The cleanup uses context.Background(), because Go cancels
t.Context() before cleanup functions run.
A test pays for one BEGIN and one ROLLBACK. Another test cannot
see rows that the test has not committed, so the tests run in
parallel against one database.
A small app of mine used the other common pattern: a
CREATE DATABASE ... TEMPLATE copy for each test, a pool for the
copy, and a DROP DATABASE at cleanup. Each copy cost 0.1 to 0.2
seconds, and the copies queued behind each other. The package ran in
37 seconds at 13% CPU. With a transaction for each test, it runs in
14 seconds.
The code under test must take a database value that a transaction can
stand in for. In my larger app, functions take a *pgdb.DB, which
wraps a pool or a pgx.Tx. Begin on a pgx.Tx opens a savepoint,
so code that opens its own transaction still runs inside the test's
transaction.
The small app passes *sql.DB to about 150 functions, and I did not
change them. go-txdb registers
a database/sql driver whose connections share one transaction for
each name:
txdb.Register("txdb", "pgx", sharedURL)
func memDB(t testing.TB) *sql.DB {
t.Helper()
db, err := sql.Open("txdb", t.Name()+"/"+randHex(6))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Close() }) // rolls back
return db
}
go-txdb turns a Begin into a savepoint. It also reads each result
set into memory, so code that runs a query inside a rows.Next loop
still works on one connection. The random suffix keeps two tests with
the same name in separate transactions.
A test that needs its own database
A transaction is one Postgres session. Two kinds of test need more than that:
- A test of an advisory lock. A session-level lock is re-entrant, so
inside one session a second
pg_try_advisory_locksucceeds. My test of a job that skips while another run holds its lock recursed until the stack overflowed. - A migration test that starts from an empty database.
Those tests still take a copy of the template. Postgres copies a database only while no session connects to it, so the transactions run on a second database: a copy of the template, made once per schema.
One database per test
Two calls to the helper in one test open two transactions. When both
write the same unique key, the second waits for the first, and the
first ends only at cleanup. One test hung this way until the test
binary timed out. Each test now opens one database. The test
connection also sets lock_timeout = '5s', so a wait like that fails
with an error that names the lock.
Unique values across processes
go test ./... runs one test binary for each package, and the
binaries share one database. A fixture helper fills a unique column,
such as users.email, from a counter in the package. Each binary
starts its counter at zero, so two binaries write the same values.
They wait on each other's uncommitted rows, and Postgres can report a
deadlock.
Each binary reserves its own block of a shared sequence when it opens its pool. An advisory lock serializes the reservation:
SELECT pg_advisory_xact_lock(hashtext('test-fixtures'));
CREATE SEQUENCE IF NOT EXISTS test_fixture_seq;
SELECT nextval('test_fixture_seq');
The counter starts at that value times a stride, so the ranges of two binaries never overlap.
Shared rows
Each test runs in its own transaction, and the transaction rolls back at the end. So the tests run in parallel against one database. That isolates what a test writes. It does not isolate a row that every test can see, such as a seeded user.
An insert that references a row through a foreign key takes a key-share lock on that row until its transaction ends. A seeded row that many fixtures point at can hold locks from many parallel transactions at once.
One of my tests ran UPDATE users SET team = NULL to hide the seeded
users from the query under test. The update wrote the one seeded user
while about twenty parallel transactions held key-share locks on it.
Postgres failed it with an internal error:
new multixact has more than one updating member. The seeded user was
already off the team, so the update changed nothing the test read, and
I deleted it.
A test does not write a row it did not insert. To keep seeded rows out of a result, the test filters on its own fixtures.
Committed rows
One test must commit. It tests a trigger that sends NOTIFY, and
Postgres delivers a notification only when the transaction commits.
Its rows stand for about a second, and the package test binaries run
at the same time. A test in another package that read the jobs table
with no filter of its own read them. It scanned a NULL into an
*int64, or counted 1 where it wanted 0. Two tests failed on pushes
that touched neither, and passed on a rerun.
A failure that nobody trusts is a failure that nobody reads. So each
read of jobs in a test now names its own rows: by queue, by job
name, or by an id that its own fixture made. A read matches an id in
args by JSONB containment, not by a cast:
SELECT count(*) FROM jobs WHERE args @> jsonb_build_object('company_id', $1::bigint);
A cast such as (args->>'company_id')::bigint reads every row in the
table. A row from another test with a non-numeric id then raises an
error.
A test in the jobs package parses every other test file and fails on
a read of jobs whose WHERE clause names none of the three. An
allow list takes an exception, with one reason for each directory.
Tables
I write a table test when cases differ only in their inputs and wants. The table holds only the differences. A case with its own setup or its own output stays a separate test.
Every row calls t.Parallel():
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
is := is.New(t)
db := test.NewDB(t)
// ...
})
}
A row is a new test. Folding parallel tests into rows that are not parallel trades away concurrency that the suite needs as it grows.
Races
A test of http.TimeoutHandler used a 1 ms timeout. Its handler
returned when the request context was cancelled. That is the same
moment the timer fires. TimeoutHandler selects on the handler being
done and on the context being done. So both cases were ready, and
select picked one at random. On a loaded CI runner, the test read
200, not 503.
Now the handler blocks until the test ends, so the timer is the only outcome:
done := make(chan struct{})
t.Cleanup(func() { close(done) })
h := http.TimeoutHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
<-done
}), time.Millisecond, "timeout")
A goroutine that a test starts and does not stop keeps running after the test ends. My web handler's constructor started a goroutine that warmed a cache from the scoreboard URLs in a package variable. A later test swapped that variable, and the race detector caught the read and the write at the same moment. The constructor now starts all of its background work through one function variable, and a test replaces it:
var startWarmers = func(h *handler) {
refreshScoresOnce()
go refreshScoresLoop()
go h.warmDayCaches()
}
Hermetic tests
A test that reaches a real host is slow, and it fails when that host
or the network does. My HTTP clients use http.DefaultTransport
unless a test gives them another, so TestMain replaces it:
func TestMain(m *testing.M) {
http.DefaultTransport = loopbackOnly{next: http.DefaultTransport}
os.Exit(m.Run())
}
type loopbackOnly struct{ next http.RoundTripper }
func (l loopbackOnly) RoundTrip(r *http.Request) (*http.Response, error) {
host := r.URL.Hostname()
if ip := net.ParseIP(host); host == "localhost" || (ip != nil && ip.IsLoopback()) {
return l.next.RoundTrip(r)
}
return nil, fmt.Errorf("test: network access to %s is blocked", host)
}
A test stubs each provider with an httptest.NewServer on loopback.
See go / http clients. My larger app refuses
every request through the default transport. An httptest server
still works there, because the client from srv.Client() has its own
transport.
The guard makes a missing stub fail at once. A retry loop then sleeps
its full delay after each failure. In the small app, five tests spent
71 of 129 seconds in a scoreboard fetch that retried after 300 ms and
600 ms, for each league and each day. I keep each retry delay in a
package variable, and TestMain sets it once:
scoreboardRetryDelay = time.Millisecond
A test that sets the variable and restores it races with the tests that run in parallel. Its restore also puts the long delay back for the tests that run after it.
Cache
To reuse the go test cache across CI runs, I persist the cache
directories and make each test binary depend only on its source.
Go keeps two on-disk caches:
GOCACHE: compiled packages and passing test results.GOMODCACHE: downloaded module source.
On a CI VM I put both on the local disk and reuse them across runs:
export GOCACHE=/var/cache/ci/go-cache
export GOMODCACHE=/var/cache/ci/go-mod
The result cache keys on the inputs to each test binary. Two defaults change those inputs on every CI run:
go teststamps the binary with VCS info, so every commit produces a different binary.- The binary embeds the absolute source path, which differs per box.
Two flags remove both:
go test -buildvcs=false -trimpath ./...
Identical source then produces an identical binary, and go test
reuses cached results across commits and across boxes that share a
GOCACHE.
The first run populates the cache. The next run on the same source
reports (cached) for unchanged packages and finishes in seconds.
cmd / sockeye runs its CI cache this way.
A cached result includes what the test binary printed, and go test
replays it on a hit. So a check that reads the run's output costs
nothing when nothing changed. My template coverage check works this
way. Each render prints a line to stdout. A command reads the lines
back out of the -v output. See
go / html templates.
The cache also keys on the environment variables the binary read. So a run with the trace on and a run with it off are separate entries.
The cache does not key on a database. After a migration, a package whose source did not change replays a pass against the old schema. I embed the schema file in a package that the test helpers import, so a migration changes the binary. See postgres / dev test clusters.