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.

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:

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:

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.

← All articles