Writing tests
Packages used:assertfixturemockerrs
Glacier's test packages let you write table-driven tests that verify values precisely, inject deterministic fakes for time and the filesystem, and set fluent expectations on interface mocks - all without importing a third-party test framework. The packages compose cleanly: assert checks values, fixture manages test resources, and mock verifies interface behavior.
Walkthrough
Step 1 - Assert values with smart equality
Import assert for checks that continue on failure, or assert/require when a nil return would crash the next assertion. The Equal[T] function accepts option values that configure the comparison engine.
import (
"testing"
"github.com/nathanbrophy/glacier/assert"
"github.com/nathanbrophy/glacier/assert/require"
)
func TestUserNames(t *testing.T) {
got := []string{"carol", "alice", "bob"}
want := []string{"alice", "bob", "carol"}
// IgnoreOrder treats slices as multisets.
assert.Equal(t, got, want, assert.IgnoreOrder())
// require stops the test immediately on failure.
require.NoError(t, loadUser(t, "u-1"))
}assert.Equal calls t.Helper() and reports every failure in one run. require.NoError calls t.FailNow - use it when continuing past a nil value would panic.
Step 2 - Inject a fake clock
Code that calls time.Now() is hard to test deterministically. Accept a fixture.Clock interface instead and inject fixture.NewClock(t, start) in tests. Calling clk.Advance drives timers without wall-clock sleeping.
import (
"context"
"testing"
"time"
"github.com/nathanbrophy/glacier/assert"
"github.com/nathanbrophy/glacier/fixture"
)
func TestRetryTimeout(t *testing.T) {
start := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
clk := fixture.NewClock(t, start)
deadline := start.Add(5 * time.Second)
calls := 0
result := retryUntil(clk, deadline, func() bool {
calls++
clk.Advance(100 * time.Millisecond)
return calls >= 3
})
assert.True(t, result)
assert.Equal(t, calls, 3)
}fixture.NewClock registers cleanup with t.Cleanup - your test stays linear with no deferred teardown to write.
Step 3 - Mock an interface
Pass any interface type parameter to mock.Of[T] to get a mock whose expectations you set with a fluent builder. Matchers are type-safe at compile time.
import (
"context"
"testing"
"github.com/nathanbrophy/glacier/assert"
"github.com/nathanbrophy/glacier/mock"
)
type Repo interface {
FindUser(ctx context.Context, id string) (User, error)
SaveUser(ctx context.Context, u User) error
}
func TestService_FindUser(t *testing.T) {
m := mock.Of[Repo](t)
m.OnCall("FindUser").
With(mock.Any[context.Context](), mock.Eq[string]("u-42")).
Return(User{ID: "u-42", Name: "Alice"}, nil).
Times(1)
m.OnCall("SaveUser").Never()
svc := NewService(m.Interface())
got, err := svc.FindUser(context.Background(), "u-42")
assert.Equal(t, got.Name, "Alice")
assert.NoError(t, err)
// Verify() runs automatically at t.Cleanup.
}mock.Eq[string] will not compile if the corresponding parameter is not a string - the check is at compile time, not runtime.
Step 4 - Verify error types
Use errs.Wrap in production code and assert.ErrorIs / assert.ErrorAs in tests to check the error chain without losing information.
import (
"errors"
"testing"
"github.com/nathanbrophy/glacier/assert"
"github.com/nathanbrophy/glacier/errs"
)
func TestLoadConfig_MissingFile(t *testing.T) {
err := loadConfig("/nonexistent.json")
// Check the sentinel at any depth in the chain.
assert.True(t, errors.Is(err, errs.ErrNotFound))
// Extract typed detail.
var pe *errs.PathError
if assert.True(t, errors.As(err, &pe)) {
assert.Equal(t, pe.Path, "/nonexistent.json")
}
}Putting it together
package service_test
import (
"context"
"testing"
"time"
"github.com/nathanbrophy/glacier/assert"
"github.com/nathanbrophy/glacier/assert/require"
"github.com/nathanbrophy/glacier/fixture"
"github.com/nathanbrophy/glacier/mock"
)
type Cache interface {
Get(ctx context.Context, key string) ([]byte, error)
Set(ctx context.Context, key string, val []byte, ttl time.Duration) error
}
func TestService_CacheHit(t *testing.T) {
t.Parallel()
start := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
clk := fixture.NewClock(t, start)
mc := mock.Of[Cache](t)
mc.OnCall("Get").
With(mock.Any[context.Context](), mock.Eq[string]("user:42")).
Return([]byte(`{"id":42,"name":"Ada"}`), nil).
Times(1)
svc := NewService(mc.Interface(), clk)
user, err := svc.User(context.Background(), "42")
require.NoError(t, err)
assert.Equal(t, user.Name, "Ada")
assert.Equal(t, clk.Now(), start, "clock must not advance on cache hit")
}What's happening underneath
- Kernel · Tier 0
assert: smart deep-equal engine with pointer deref, map-order insensitivity, float tolerance, and field exclusion;requiresub-package addst.FailNowsemantics. - Mid · Tier 1
fixture: manages test resources (fake clock, in-memory FS, goroutine leak guard) and registers all cleanup witht.Cleanup. - Mid · Tier 1
mock: reflect-based runtime mocks for any exported interface; optional+glacier:mockcodegen path for full IDE autocomplete. - Kernel · Tier 0
errs: wraps and composes errors in a way that keeps the full chain traversable viaerrors.Isanderrors.As.
Related
- Mocking HTTP - stub HTTP servers for tests that call external APIs.
- Loading config - test config loading with an in-memory FS fixture.
- Concurrency - testing goroutine groups and checking for goroutine leaks.