Day 7 — Testing: Table-Driven, Fakes & Stage II Gate
Day 7 — Testing: Table-Driven, Fakes & Stage II Gate
Day 16 — Table-driven tests
Stage II · ~3h (theory-heavy)
Goal: Write idiomatic table-driven tests with t.Run, shared helpers, clear failure messages, and the habits that make Stage II’s gate (and every later stage) regression-safe.
Go’s culture is tests next to code (foo_test.go), fast unit tests by default, and tables for combinatorial cases. You do not need a third-party assertion library—the stdlib testing package is enough.
Why this day exists
Untested validation and domain logic rots. Table-driven tests:
- Compress many cases into one structure
- Name cases for failures (
t.Run)
- Make new edge cases a one-line addition
- Document behavior better than prose alone
Day 16 is the testing floor; fuzzing and golden files come in Stage IV.
Theory 1 — Package layout for tests
validate/
email.go
email_test.go # package validate OR package validate_test
Package clause in _test.go |
Style |
|---|---|
package validate |
White-box: access unexported symbols |
package validate_test |
Black-box: only public API; import the package |
Both are idiomatic. Prefer black-box for public contracts; white-box for internal helpers.
go test ./...
go test -v ./validate
go test -run TestEmail ./validateTheory 2 — Basic test function
func TestAdd(t *testing.T) {
got := Add(1, 2)
want := 3
if got != want {
t.Fatalf("Add(1,2)=%d; want %d", got, want)
}
}| Method | Effect |
|---|---|
t.Error / t.Errorf |
Mark fail; continue |
t.Fatal / t.Fatalf |
Mark fail; stop this test function |
t.Log / t.Logf |
Print with -v |
t.Helper |
Mark caller as helper for line numbers |
t.Parallel |
Allow parallel with others that also call it |
t.Cleanup |
Register post-test cleanup |
t.TempDir |
Create temp directory (auto-cleaned) |
Theory 3 — Table-driven pattern
func TestAbs(t *testing.T) {
tests := []struct {
name string
in int
want int
}{
{name: "positive", in: 5, want: 5},
{name: "negative", in: -3, want: 3},
{name: "zero", in: 0, want: 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := Abs(tt.in)
if got != tt.want {
t.Fatalf("Abs(%d)=%d; want %d", tt.in, got, tt.want)
}
})
}
}Why t.Run?
- Isolates failures to a named case
- Allows
t.Parallel()per case carefully
- Improves
-vreadability
Naming cases
Prefer descriptive names: "empty email", "wraps ErrNotFound", not "test3".
Theory 4 — Error cases in tables
tests := []struct {
name string
in string
want int
wantErr error // sentinel or nil
}{
{name: "ok", in: "42", want: 42},
{name: "empty", in: "", wantErr: ErrEmpty},
{name: "not int", in: "x", wantErr: strconv.ErrSyntax}, // or check via errors.Is
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := Parse(tt.in)
if tt.wantErr != nil {
if !errors.Is(err, tt.wantErr) {
t.Fatalf("err=%v; want Is(%v)", err, tt.wantErr)
}
return
}
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if got != tt.want {
t.Fatalf("got %d want %d", got, tt.want)
}
})
}Combine Day 13 (errors.Is/As) with tables—do not assert exact message strings unless the message is the API.
Theory 5 — Helpers
func assertNoErr(t *testing.T, err error) {
t.Helper()
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
}
func assertEq[T comparable](t *testing.T, got, want T) {
t.Helper()
if got != want {
t.Fatalf("got %v want %v", got, want)
}
}t.Helper() makes failure line numbers point at the call site in the test, not inside the helper.
cmp note
For complex structs, teams often use github.com/google/go-cmp. This volume prefers stdlib first; compare field-by-field or use maps for simplicity until Stage IV.
Theory 6 — Fixtures and files
func TestParseFile(t *testing.T) {
data, err := os.ReadFile("testdata/ok.txt")
assertNoErr(t, err)
// ...
}Convention: put fixtures under testdata/ (ignored by go build as a special name for tooling patterns).
dir := t.TempDir()
path := filepath.Join(dir, "out.txt")Prefer t.TempDir() over manual /tmp cleanup.
Theory 7 — What not to do
| Anti-pattern | Prefer |
|---|---|
| One mega test with 40 asserts | Table + t.Run |
| Sleeping for timing | Deterministic APIs; fake clocks later |
| Hitting real network by default | Interface + fake (Day 17) |
| Asserting log output formats loosely | Test return values |
| Sharing mutable global state across tests | Construct fresh subjects |
Worked examples bank
Example A — Validate email table
package validate_test
import (
"testing"
"example.com/day16/validate"
)
func TestEmail(t *testing.T) {
tests := []struct {
name string
in string
ok bool
}{
{"simple", "a@b.co", true},
{"empty", "", false},
{"no at", "ab.co", false},
{"spaces", "a @b.co", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validate.Email(tt.in)
if tt.ok && err != nil {
t.Fatalf("unexpected err: %v", err)
}
if !tt.ok && err == nil {
t.Fatal("expected error")
}
})
}
}Example B — Subtests with setup
func TestStore(t *testing.T) {
t.Run("add", func(t *testing.T) {
s := NewStore()
if err := s.Add("a", 1); err != nil {
t.Fatal(err)
}
})
t.Run("dup", func(t *testing.T) {
s := NewStore()
_ = s.Add("a", 1)
if err := s.Add("a", 1); err == nil {
t.Fatal("expected duplicate error")
}
})
}Each subtest gets a fresh store—no cross-talk.
Example C — Map of cases
cases := map[string]struct {
in string
want int
}{
"forty-two": {in: "42", want: 42},
"zero": {in: "0", want: 0},
}
for name, tt := range cases {
t.Run(name, func(t *testing.T) {
// ...
})
}Map iteration order is random—fine for independent cases; use slice if order matters for readability.
Example D — Helper with Helper()
func mustParse(t *testing.T, s string) int {
t.Helper()
n, err := strconv.Atoi(s)
if err != nil {
t.Fatalf("Atoi(%q): %v", s, err)
}
return n
}Example E — Run filter
go test -v -run 'TestEmail/empty' ./...
go test -count=1 ./... # disable cache when debugging flakesLabs
Suggested workspace: ~/lab/90daysofx/go/day16
Lab 1 — Test the Day 13 validators (or reimplement)
mkdir -p ~/lab/90daysofx/go/day16/validate
cd ~/lab/90daysofx/go/day16
go mod init example.com/day16Write table-driven tests for:
- Email-ish validation
- Password/min length
- Joined multi-error validation (
errors.Join) usingerrors.As/ field helpers
Lab 2 — Domain store tests
If you have inventory/todo types, test:
- Happy path add/get
- Missing key error (
errors.Is)
- Duplicate / insufficient quantity
Lab 3 — Coverage glance
go test -cover ./...
go test -coverprofile=cover.out ./...
go tool cover -func=cover.outDo not chase 100%. Cover the branches you care about. Note uncovered error paths and add one case.
Lab 4 — Black-box vs white-box
One test file as package validate_test. Confirm unexported helpers are inaccessible—test through exported API.
Common gotchas
| Gotcha | Fix |
|---|---|
Forgetting t.Helper |
Line numbers mislead |
Asserting err.Error() strings |
Prefer Is/As |
| Shared mutable fixtures | Fresh per t.Run |
Not using -count=1 when chasing cache |
Disable cache |
| Huge tables without names | Always name field |
Testing through main only |
Export library API |
Checkpoint
- Table +
t.Runpattern from memory
- Error cases use
errors.Is/As
- Helper uses
t.Helper()
go test ./...green
- Saw
-coveroutput once
- Black-box test package tried
Commit
git add .
git commit -m "day16: table-driven tests for validate/domain"Tomorrow
Day 17 — Fakes vs mocks: hand-written fakes behind interfaces, when not to mock, and testing services without mock-generation frameworks.
Day 17 — Fakes vs mocks
Stage II · ~3h (theory-heavy)
Goal: Drive tests with hand-written fakes behind small interfaces—distinguish fakes, stubs, mocks, and spies—and know when a real in-memory implementation is better than a mock framework.
This volume prefers stdlib + hand fakes. Mock-generation tools exist; you do not need them to write excellent Go tests. Interfaces (Day 11) are the seam.
Why this day exists
Unit tests that hit the network, real clocks, or production databases are slow and flaky. The Go way:
- Depend on interfaces at the boundary
- Supply a fake in tests
- Keep fakes stateful and simple, not hyper-configured mock DSLs
Day 17 builds taste so Day 18’s module ships with tests others trust.
Theory 1 — Vocabulary
| Term | Meaning | Typical use |
|---|---|---|
| Stub | Returns canned values; little behavior | “Always return this user” |
| Fake | Working simplified implementation | In-memory DB, map store |
| Mock | Pre-programmed expectations; fails if calls differ | Verify interaction order |
| Spy | Records calls for later assertions | “Was Save called once?” |
In Go conversations, people often say “mock” for any test double. Prefer fake for map-backed stores—you will write those most.
Theory 2 — Interface as seam (review with purpose)
type UserRepository interface {
Find(ctx context.Context, id string) (User, error)
Save(ctx context.Context, u User) error
}
type Service struct {
users UserRepository
// mail Mailer
}
func (s *Service) Rename(ctx context.Context, id, name string) error {
u, err := s.users.Find(ctx, id)
if err != nil {
return err
}
u.Name = name
return s.users.Save(ctx, u)
}Production wires Postgres; tests wire FakeUsers.
Theory 3 — Writing a good fake
type FakeUsers struct {
ByID map[string]User
// optional behavior knobs:
FindErr error
SaveErr error
}
func NewFakeUsers(users ...User) *FakeUsers {
f := &FakeUsers{ByID: make(map[string]User)}
for _, u := range users {
f.ByID[u.ID] = u
}
return f
}
func (f *FakeUsers) Find(_ context.Context, id string) (User, error) {
if f.FindErr != nil {
return User{}, f.FindErr
}
u, ok := f.ByID[id]
if !ok {
return User{}, ErrNotFound
}
return u, nil
}
func (f *FakeUsers) Save(_ context.Context, u User) error {
if f.SaveErr != nil {
return f.SaveErr
}
if f.ByID == nil {
f.ByID = make(map[string]User)
}
f.ByID[u.ID] = u
return nil
}
var _ UserRepository = (*FakeUsers)(nil)Properties of good fakes
- Implement the same interface as production
- Stateful enough to exercise real logic paths
- Failure injection via fields (
SaveErr)
- No network, no sleep
- Not more complex than the code under test
Theory 4 — Spy: record interactions
type SpyMailer struct {
Messages []string
Err error
}
func (s *SpyMailer) Send(_ context.Context, to, body string) error {
if s.Err != nil {
return s.Err
}
s.Messages = append(s.Messages, to+":"+body)
return nil
}Assert:
if len(mail.Messages) != 1 {
t.Fatalf("got %d mails", len(mail.Messages))
}Use spies when side effects matter and the fake would otherwise hide them.
Theory 5 — When mocks become harmful
| Smell | Problem |
|---|---|
| Expect exact call order of every dependency | Brittle; refactors break tests |
| Mocking concrete structs with codegen | Heavy; interfaces were the point |
| 200-line mock setup | Design may need smaller interfaces |
| Asserting implementation details | Test behavior/outcomes |
Prefer: arrange fakes → act on service → assert state and returned errors.
Theory 6 — What not to fake
| Keep real | Why |
|---|---|
| Pure functions | No seam needed |
Stdlib parsers (strconv, json for your types) |
Already deterministic |
| Your own in-memory algorithms | You are testing them |
| Fake | Why |
|---|---|
| DB / HTTP / disk (sometimes) | Speed/isolation |
| Clocks | Determinism (interface Now() time.Time) |
| Email / SMS | No external side effects |
| Randomness | Seed or inject source |
Theory 7 — Context in signatures (light)
Even if you ignore cancellation today, matching production signatures with context.Context as first param keeps fakes honest:
Find(ctx context.Context, id string) (User, error)Deep context usage is Stage III. Today: accept ctx, ignore or check ctx.Err() optionally.
Worked examples bank
Example A — Service test with fake
func TestRename(t *testing.T) {
repo := NewFakeUsers(User{ID: "1", Name: "Ada"})
svc := NewService(repo)
if err := svc.Rename(context.Background(), "1", "Augusta"); err != nil {
t.Fatal(err)
}
u, err := repo.Find(context.Background(), "1")
if err != nil {
t.Fatal(err)
}
if u.Name != "Augusta" {
t.Fatalf("name=%q", u.Name)
}
}Example B — Error path injection
func TestRenameSaveFails(t *testing.T) {
repo := NewFakeUsers(User{ID: "1", Name: "Ada"})
repo.SaveErr = errors.New("disk full")
svc := NewService(repo)
err := svc.Rename(context.Background(), "1", "X")
if err == nil {
t.Fatal("expected error")
}
}Example C — Not found
func TestRenameMissing(t *testing.T) {
svc := NewService(NewFakeUsers())
err := svc.Rename(context.Background(), "nope", "X")
if !errors.Is(err, ErrNotFound) {
t.Fatalf("got %v", err)
}
}Example D — Clock injection
type Clock interface {
Now() time.Time
}
type fixedClock struct{ t time.Time }
func (f fixedClock) Now() time.Time { return f.t }
type realClock struct{}
func (realClock) Now() time.Time { return time.Now() }Example E — Avoid over-mocking
// Weak test: only checks that Find was called — misses rename logic bugs.
// Strong test: checks saved user name and errors.Is paths.Labs
Suggested workspace: ~/lab/90daysofx/go/day17
Lab 1 — Service + fake repository
mkdir -p ~/lab/90daysofx/go/day17
cd ~/lab/90daysofx/go/day17
go mod init example.com/day17Build:
- Domain entity + repository interface
- Service methods that use the interface (at least two paths: success + error)
FakeXwith map storage + error injection
- Table-driven tests on the service
Lab 2 — Spy side effect
Add a Notifier interface. On successful rename (or create), notify. Spy records messages. Assert one notification on success and zero on failure.
Lab 3 — Compile-time check
var _ UserRepository = (*FakeUsers)(nil)
var _ UserRepository = (*PostgresUsers)(nil) // if you stub a type with panic methodsA skeleton Postgres type with panic("not implemented") methods is optional; the point is the interface contract.
Lab 4 — Judgment notes
In NOTES.md:
- What you faked and why
- What you kept real
- One mock-style assertion you avoided
Common gotchas
| Gotcha | Fix |
|---|---|
| Fake that doesn’t implement full interface | var _ I = (*Fake)(nil) |
| Shared fake state across subtests | New fake per t.Run |
| Testing the fake more than the service | Keep fakes thin |
| Interface too large to fake | Split interface |
| Real sleep/network in unit tests | Inject deps |
| Asserting call counts only | Assert outcomes |
Checkpoint
- Fake implements production interface
- Service tests use fake only
- Error injection covered
- Spy or notification assert optional but understood
- Vocabulary: fake vs mock
- Notes on judgment
Commit
git add .
git commit -m "day17: fakes spies service tests"Tomorrow
Day 18 — Stage II gate: publishable-shaped module with public API, tests (tables + fakes), README, and idiomatic errors—ready to open Stage III concurrency.
Day 18 — Stage II gate
Stage II · ~3h (integration)
Goal: Ship a publishable-shaped Go module: clear public API, table-driven tests, at least one interface seam with a fake, idiomatic errors (Is/As/Join as needed), README, and go test ./... green on Go 1.26.
Stage II gate is about library quality, not product breadth. A small well-tested package beats a sprawling untested app. CLI wrappers are optional glue.
Why this day exists
Stage II skills—interfaces, assertions, errors, modules, tests, fakes—only count when integrated:
- Others can import your package
- Tests document and protect behavior
- Errors are inspectable
- Layout matches module norms
Pass this gate before concurrency multiplies complexity.
Theory 1 — Definition of done
| Requirement | Evidence |
|---|---|
| Module path | Sensible go.mod (example.com/day18/... fine for lab) |
| Public API | Exported types/funcs with godoc comments |
| Tests | go test ./... pass; tables for core logic |
| Seam | Interface + fake or pure package easily tested without I/O |
| Errors | Sentinels and/or typed errors; no panics for input |
| README | Install/use/examples/exit or API notes |
| Toolchain | Builds on Go 1.26 |
Explicit non-goals
- No race-heavy concurrency requirement
- No external cloud services
- No mock codegen frameworks
- No perfect semantic version publish to a real VCS host required (shape only)
Theory 3 — Suggested layout
day18/
go.mod
README.md
<lib>/
doc.go # package comment
api.go
errors.go
api_test.go
cmd/<tool>/main.go # optional
Package comment
// Package validate implements small composable validators with inspectable errors.
package validateKeep main thin
If CLI exists, it only maps flags → library → exit codes.
Theory 4 — API design checklist
- Minimal exports — unexport helpers
- Errors are part of API — document sentinels
- Accept interfaces, return concrete where it helps
- Context first if any I/O or cancellation
- No global mutable state
- Examples in README that match tests
// ErrNotFound is returned when an item does not exist.
var ErrNotFound = errors.New("not found")Theory 5 — Test bar for the gate
Minimum:
- ≥ 1 table-driven test with ≥ 4 cases
- ≥ 1 error-path assertion using
errors.Isorerrors.As
- Fresh subjects per subtest (no leaked state)
go test ./...clean
Stretch:
- Fake behind interface with service test
-coverglance; note gaps
testdata/fixture
Theory 6 — README shape
# module name
What problem it solves (3–5 sentences).
## Install
go get example.com/your/module@latest # or clone path for lab
## Example
Short Go snippet using the public API.
## Errors
Which sentinels / types callers should check.
## Develop
go test ./...For lab modules not published, document go test and local replace/go work if used.
Worked examples bank
Example A — Public validation API sketch
package validate
import "errors"
var ErrInvalid = errors.New("invalid")
type Result struct {
// optional structured outcome
}
func Email(s string) error { /* ... */ }
func Check(email, password string) error {
return errors.Join(Email(email), Password(password))
}Example B — Test excerpt
func TestCheck(t *testing.T) {
tests := []struct {
name, email, pass string
wantFields []string
}{
{"ok", "a@b.co", "password1", nil},
{"both bad", "", "x", []string{"email", "password"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := Check(tt.email, tt.pass)
// assert fields via helper walking Join
_ = err
})
}
}Example C — Exit mapping CLI
func main() {
if err := run(os.Args[1:]); err != nil {
fmt.Fprintln(os.Stderr, err)
if errors.Is(err, validate.ErrInvalid) {
os.Exit(1)
}
os.Exit(2)
}
}Example D — Self-review questions
- Can a stranger use the package from README alone?
- Are failure modes tested?
- Any panic left for user input?
- Is
go.modfree of accidentalreplace?
Labs
Suggested workspace: ~/lab/90daysofx/go/day18
Lab 1 — Build the gate module
mkdir -p ~/lab/90daysofx/go/day18
cd ~/lab/90daysofx/go/day18
go mod init example.com/day18Implement your chosen project to the definition of done.
Lab 2 — Test suite pass
go test ./...
go test -cover ./...Fix until green. Add one case that failed during development (regression).
Lab 3 — README + example
Write README. If time allows, add ExampleCheck in _test.go for go test example output (optional func ExampleXxx()).
func ExampleEmail() {
fmt.Println(Email("a@b.co") == nil)
// Output:
// true
}Lab 4 — Gate retrospective
Write RETRO.md (10–15 lines):
- What Stage II skill was hardest
- What you would refactor before Stage III
- One bug tests caught
Common gotchas
| Gotcha | Fix |
|---|---|
Logic only in main |
Move to library package |
| Tests missing error paths | Add Is/As cases |
| README lists features you did not ship | Document reality |
Left replace in go.mod |
Remove |
| Panic on bad input | Return error |
| Interface without test double | Fake or drop interface |
Checkpoint
go test ./...green on 1.26
- Public API documented
- Table-driven tests present
- Idiomatic errors
- README usable
- Retro written
- Ready for goroutines without unfinished Stage II debt
Commit
git add .
git commit -m "day18: stage II gate module + tests"Tomorrow
Stage III begins — Day 19 Goroutines: the concurrency model (G/M/P light), launching work safely, and why “just add go” is not a strategy—without channels yet until Day 20.