go / test
I run tests with two flags that keep the result cache warm:
go test -buildvcs=false -trimpath ./...
Assertions come from is. Database tests insert fixtures through a test helper. CI reuses the Go caches across runs. The sections below cover each.
Assertions
is is a package that holds test 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.
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 takes a bool for a check with no want to name, such as
is.True(strings.Contains(s, x)). 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, which rejects a same-named file 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")
}
A database test initializes both is and db.
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 fixture helper inserts valid default columns into a test
transaction. The test then runs the function and checks the result with
is.
I define fixture methods on the test database helper:
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.
Cache
go test caches passing test results and compiled packages. To reuse
that 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 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 / cibot runs its CI cache this way.