TDD Unit Test Standard
All unit tests follow Test-Driven Development with testify table-driven patterns. This document defines the development workflow, test structure, and quality standards.
When unit tests are required
- Library / pure logic / behavior changes in Go packages: write or update table-driven unit tests (this doc).
- New modules or cross-boundary HTTP/event behavior: also add or update BDD specs — see bdd-specs.md.
- Docs-only, AGENTS.md, or comment-only edits: unit tests are not required.
- Repo-wide policy summary: root AGENTS.md Testing policy.
TDD Workflow
┌──────────┐
│ Write a │
│ test │
└─────┬────┘
│
┌───────────▼───────────┐
│ Run test → RED │ Test fails — expected
│ (no implementation) │
└───────────┬──────────┘
│
┌───────────▼───────────┐
│ Write minimal code │
│ to pass the test │
└───────────┬──────────┘
│
┌───────────▼───────────┐
│ Run test → GREEN │ Test passes
└───────────┬──────────┘
│
┌───────────▼───────────┐
│ Refactor │ Clean up, extract,
│ (keep GREEN) │ optimize — tests guard
└───────────┬──────────┘
│
┌───────────▼───────────┐
│ Run all tests │ Full suite must stay GREEN
└───────────────────────┘
Red Phase
- Write exactly one test that describes the behavior you want.
- Name the test after the function and scenario:
TestParseAction_ValidInput. - Run the test to confirm it fails for the expected reason (not a compile error, not nil pointer).
- Do not write implementation code during this phase.
Green Phase
- Write the minimum code to make the test pass.
- Do not add features, error handling, or optimizations the test does not demand.
- Run only the failing test to verify it passes, then the full suite.
Refactor Phase
- Remove duplication in both test and production code.
- Extract helpers, constants, interfaces where appropriate.
- Run the full test suite after each refactoring step.
- Do not change behavior — tests stay GREEN throughout.
Development Order
For each new function or change:
- Start with the test file: Create
<name>_test.gobefore writing implementation. - Happy path first: Write the primary success case as the first table entry.
- Error cases second: nil input, invalid input, boundary conditions, type mismatches.
- Edge cases third: empty values, zero values, maximum/minimum limits, concurrency.
- At least 3 cases per table: Every table must contain a minimum of 3 test cases. Single-case tests still wrap in
t.Run. - Write the implementation: Only after the test structure is defined.
Test File Organization
Tests are co-located with source code using Go's *_test.go convention:
pkg/workflow/
├── workflow.go # Implementation
├── workflow_test.go # Tests for workflow.go
├── types.go
└── types_test.go
Tests use the same package name with _test suffix to access only the public API:
package workflow_test // separate package — tests the public interface
When testing unexported internals is necessary (rare), use the same package:
package workflow // same package — access to unexported symbols
Prefer the _test suffix package unless testing internal logic that cannot be exercised through the public API.
Standard Pattern
func TestFunctionName_Scenario(t *testing.T) {
t.Parallel()
tests := []struct {
name string
input InputType
want OutputType
wantErr bool
wantErr error
}{
{
name: "happy path",
input: validInput,
want: expectedOutput,
wantErr: false,
},
{
name: "edge case empty input",
input: emptyInput,
want: zeroValue,
wantErr: false,
},
{
name: "error invalid input",
input: invalidInput,
wantErr: true,
wantErr: types.ErrInvalidArgument,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got, err := FunctionUnderTest(tt.input)
if tt.wantErr {
require.Error(t, err)
if tt.wantErr != nil {
assert.ErrorIs(t, err, tt.wantErr)
}
return
}
require.NoError(t, err)
assert.Equal(t, tt.want, got)
})
}
}
Variant: Multi-Input Functions
When the function takes multiple inputs, describe each in the struct:
tests := []struct {
name string
action string
wantType string
wantErr bool
}{
{name: "valid capability action", action: "capability:bookmark.list", wantType: "capability"},
{name: "docker action", action: "docker:nginx:latest", wantType: "docker"},
{name: "empty action", action: "", wantErr: true},
}
Variant: Setup-Heavy Tests
When each case requires different setup that cannot be expressed in a struct, use inline subtests:
func TestFindByEvent(t *testing.T) {
t.Parallel()
t.Run("method-match", func(t *testing.T) {
t.Parallel()
d := Definition{Name: "p", Trigger: Trigger{Event: "bookmark.created"}}
matched := d.FindByEvent("bookmark.created")
require.Len(t, matched, 1)
})
t.Run("method-no-match", func(t *testing.T) {
t.Parallel()
d := Definition{Name: "p", Trigger: Trigger{Event: "bookmark.created"}}
matched := d.FindByEvent("other.event")
require.Empty(t, matched)
})
}
Assertion Rules
| Function | Behavior | Import |
|---|---|---|
require.Xxx(t, ...) |
Fatal — stops the subtest immediately on failure | github.com/stretchr/testify/require |
assert.Xxx(t, ...) |
Non-fatal — records failure, continues execution | github.com/stretchr/testify/assert |
Usage Guidelines
requirefor preconditions: Use when subsequent assertions depend on the result (e.g.,require.NoErrorbefore accessing the returned value).requirefor setup: Validating test setup (e.g.,require.True(t, db.Ping() != nil)).assertfor behavior: Use when multiple independent assertions should all be checked (e.g.,assert.Equalon struct fields).assertfor non-critical checks: Use when the test can meaningfully continue after a failure.
// require for precondition — accessing got[0] depends on len check
require.Len(t, got, 1)
require.NoError(t, err)
// assert for behavioral checks — both should be checked
assert.Equal(t, "expected-name", got[0].Name)
assert.True(t, got[0].Enabled)
// Error checking pattern
if tt.wantErr {
require.Error(t, err) // fatal — no point continuing
assert.ErrorIs(t, err, tt.wantErr) // non-fatal — extra detail
return
}
require.NoError(t, err) // fatal — should never error
Parallel Execution
All tests declare t.Parallel() at both the top level and within each t.Run subtest:
func TestFoo(t *testing.T) {
t.Parallel() // top-level
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel() // subtest
// ...
})
}
}
This maximizes concurrency. Tests run in t.Parallel() are paused until the parent function returns, then scheduled together.
Parallel Constraints
- Tests using global mutable state (e.g.,
os.Setenv) should uset.Setenvor proper cleanup. - Tests sharing a database fixture should not use
t.Parallelagainst the same data. - Read-only globals (e.g., compiled regexps, template engines) are safe for parallel access.
- Integration tests using shared containers do not use
t.Parallel(). - Never remove
t.Parallel()to hide race conditions — fix shared-state serialization or other root causes instead.
Test Naming
| Pattern | Example | Usage |
|---|---|---|
Test<Function>_<Category> |
TestParseAction_ValidInput |
Primary naming convention |
Test<Function> |
TestValidateDAG |
When only one category, with subtests |
Test<Struct>_<Method> |
TestRenderContext_Render |
Methods on types |
Table entry names use descriptive snake_case:
{name: "happy path with valid data"}
{name: "error when input is nil"}
{name: "edge case zero length slice"}
Error Assertion Patterns
// Basic error check
require.NoError(t, err)
// Error expected, type irrelevant
require.Error(t, err)
// Exact error match (errors.Is)
assert.ErrorIs(t, err, types.ErrNotFound)
// Error expected but not nil
require.Error(t, err)
// Error string contains
assert.Contains(t, err.Error(), "connection refused")
Mock and Stub Policy
The project does not use testify/mock. Alternatives:
- Pure functions: Test with real inputs and outputs. Prefer this approach.
- Interface-based stubs: Define a local interface and pass a stub implementation in tests.
- Table-driven: Use the anonymous struct to provide different dependency behaviors via fields.
// Prefer: test the real function with varied inputs
tests := []struct {
name string
reader io.Reader // real or stub via strings.NewReader / bytes.Buffer
want string
}{...}
// Avoid: mock frameworks
// var mockClient = new(MockClient) // not used in this project
Benchmark Tests
Benchmark functions use func Benchmark*(b *testing.B):
func BenchmarkParseAction(b *testing.B) {
for b.Loop() {
ParseAction("capability:bookmark.list")
}
}
Use b.Loop() instead of for i := 0; i < b.N; i++ (Go 1.24+).
Race Testing
Run race detection locally and in CI:
go test -race ./... # single pass
go tool task test:race # 10 iterations
Tests that are inherently racy (by design) should be documented and excluded via build tags.
Short Mode
Integration and long-running tests should respect testing.Short():
func TestLongRunning(t *testing.T) {
if testing.Short() {
t.Skip("Skipping long-running test in short mode")
}
// ...
}
go test -short ./... skips these tests. Use go tool task test:short in development loops.
Coverage
Generate coverage reports to identify untested paths:
go tool task test:coverage
Coverage alone is not a sufficient quality metric. Combine with mutation scores and BDD acceptance coverage.
When NOT to Use Table-Driven
- Integration tests using
testify/suite.Suitewith container setup/teardown. - BDD acceptance tests using Ginkgo
Describe/Context/It(seebdd-specs.md). - Tests requiring per-case setup that cannot be expressed in a struct.
- Generated test files (Ent, Swagger codegen output).
Code Review Checklist
- Test file is co-located with source:
<name>_test.go. - Test function named
Test<Function>_<Category>. - Uses
t.Parallel()at top-level and in eacht.Run. - Table has at least 3 cases with descriptive
namefields. - Happy path is the first entry.
- Error paths are covered (nil input, invalid input, boundary conditions).
-
requirefor fatal assertions,assertfor non-fatal. - No panics in test code (use
requireto guard). - No use of
encoding/json— usegithub.com/bytedance/sonic. -
testing.Short()respected for long-running tests.