Test writing guide

Practical guide for writing tests in the game service codebase.

Test levels

Choose the right level for what you are testing:

Level When to use Location
Unit Deterministic domain logic, pure functions, state machines *_test.go next to source
Integration Component seams, store interactions, multi-step workflows *_test.go in test packages or gametest/
Scenario Critical user/system paths, end-to-end transport flows internal/test/game/scenarios/

Prefer the lowest level that gives confidence. Unit tests are fast and deterministic. Integration tests catch wiring issues. Scenario tests validate real transport paths but are slower.

Fixture patterns

Store fakes

Fake stores live in internal/test/mock/gamefakes/. They implement store interfaces with in-memory state and support error injection via *error fields.

fake := &gamefakes.FakeCampaignStore{}
fake.GetCampaignErr = errors.New("store unavailable")

Use fakes for unit tests that need store interactions without a real database.

Integration fixtures

Integration tests use gametest helpers (internal/services/game/api/grpc/game/gametest/) for common record construction and fake stores:

campaign := gametest.ActiveCampaignRecord("campaign-1")
participant := gametest.OwnerParticipantRecord("campaign-1", "participant-1")
fakeStore := gametest.NewFakeCampaignStore()

When gametest does not cover your setup needs, write imperative setup but keep it focused on the specific scenario being tested.

Daggerheart test helpers

Daggerheart-specific test utilities live in the system’s testkit/ package. Use these for system-specific state construction and validation.

Assertion best practices

Assert on codes, not messages

Error messages are implementation details. Assert on structured codes:

// Prefer: assert on rejection code.
if result.RejectionCode != campaign.RejectionNotFound {
    t.Fatalf("got code %q, want %q", result.RejectionCode, campaign.RejectionNotFound)
}

// Avoid: assert on exact error string.
if err.Error() != "campaign not found" {
    t.Fatalf("unexpected error: %v", err)
}

Negative assertions need rationale

Every negative assertion must include an Invariant: comment explaining why the absence matters:

// Invariant: rejected commands must not emit domain events.
if len(result.Events) != 0 {
    t.Fatal("expected no events on rejection")
}

Table-driven tests

Use table-driven tests for functions with multiple input variations:

tests := []struct {
    name   string
    input  Input
    want   Output
}{
    {"valid input", validInput(), expectedOutput()},
    {"empty name rejected", emptyNameInput(), rejectedOutput()},
}
for _, tt := range tests {
    t.Run(tt.name, func(t *testing.T) {
        got := function(tt.input)
        assertEqual(t, got, tt.want)
    })
}

What not to test

  • Thin wiring code (server composition, main functions)
  • Generated code (protobuf, sqlc, templ outputs)
  • Internal implementation details that may change without affecting behavior
  • Exact error message strings (use codes instead)

Test file organization

Pattern Purpose
foo_test.go Unit tests for foo.go, same package
foo_integration_test.go Integration tests requiring real dependencies
internal/services/game/api/grpc/game/gametest/ Core store fakes and record fixtures
internal/test/game/scenarios/ End-to-end scenario tests
internal/test/mock/gamefakes/ Service-level in-memory store fakes
testkit/ (per system) System-specific test utilities

Coverage

Coverage is a guardrail, not a target. The CI pipeline enforces non-regression baselines from main. Focus on testing meaningful behavior rather than maximizing line counts.

See Testing policy for CI gates and coverage floor policy.