Imported from FerroxLabs/wayland (
src/process/resources/skills-library/bodies/skills/software-engineering/go-testing-patterns/SKILL.md). Install upstream withnpx skills add FerroxLabs/wayland --skill go-testing-patterns. Copyright stays with the author (Apache-2.0).
Go Testing Patterns
When to Use
Use this skill when:
- The user asks how to structure test files, test functions, or test packages in a Go project
- The user asks about table-driven tests, subtests, or parameterized testing in Go
- The user asks about the
testifylibrary (assert,require,mock,suite) or whether to use it over stdlib - The user asks about testing HTTP handlers or middleware using
net/http/httptest - The user asks about writing benchmarks with
testing.B, profiling benchmarks, or avoiding benchmark optimizations that skew results - The user asks about fuzz testing with
testing.F(Go 1.18+), seed corpus, or fuzz targets - The user asks about integration tests, test helpers,
TestMain, or golden files in Go - The user asks about race condition detection, parallel tests, or coverage thresholds in Go CI
Do NOT use this skill when:
- The user asks about initializing a Go module, project layout, or
go.modconfiguration -- usego-project-setup - The user asks about Go idioms, error handling conventions, or interface design -- use
go-idioms - The user asks about general unit testing theory, TDD philosophy, or test pyramid concepts without Go specifics -- use
unit-testing-patterns - The user asks about Go concurrency patterns without a testing focus -- use
go-concurrency - The user asks about CI/CD pipeline configuration that happens to run Go tests -- use
ci-cd-pipelines - The user asks about load testing or performance profiling in production -- use
performance-profiling
Process
1. Identify the Test Category and Appropriate Pattern
Classify what is being tested before writing a single line of test code. Each category has a canonical Go approach.
- Pure functions with no side effects: Standard table-driven tests using
t.Runsubtests. No mocking required. Focus on boundary values, zero values, and error paths. - Methods on structs with injected dependencies: Interface-based fakes or
testify/mock. Inject via constructor, never viainit()or package-level globals. - HTTP handlers and middleware: Use
net/http/httptest.NewRecorder()for handler-level tests andhttptest.NewServer()for full integration roundtrips including middleware chains. - Database-touching code: Use a real test database (ephemeral Docker container via
ory/dockertestortestcontainers-go) or a recorded query replay tool. Never use SQLite as a substitute for Postgres -- schema differences cause false confidence. - File system or OS interaction: Use
t.TempDir()(Go 1.15+) which automatically cleans up after the test. Never hardcode/tmp/paths. - CLI commands: Use
os/execwith a compiled binary inTestMain, or restructure the command logic to accept anio.Writerfor output so it can be tested without subprocess overhead. - Concurrent or goroutine-based code: Always run with
-race. Use channels orsync.WaitGroupto synchronize, nevertime.Sleep.
2. Set Up the Test File Structure
Go test files have strict conventions that must be respected.
- Place test files in the same package as the code under test (whitebox testing:
package mypackage) to access unexported identifiers, OR in a_testpackage (blackbox testing:package mypackage_test) to enforce API boundary discipline. Prefer_testsuffix packages for exported APIs; use internal packages for unexported logic testing. - Name the test file
{filename}_test.go. Go tooling will not compile these files outside ofgo test. - Use
internal/testhelpersor a dedicatedtestutilpackage for shared helper functions. Export helpers from this package, not from production packages. - Never put test helpers in
_test.gofiles if they need to be shared across packages --_test.gofiles are not importable by other packages. - Place integration tests in a separate
integration/directory or gate them behind a build tag://go:build integration. Run them separately in CI:go test -tags=integration ./.... - Use
TestMain(m *testing.M)only when you need true global setup/teardown (starting a database container, compiling a binary, seeding shared fixtures). Callos.Exit(m.Run())at the end -- failing to do so causes the test binary to report success regardless of test outcomes.
3. Write Table-Driven Tests with Subtests
Table-driven tests are idiomatic Go. Apply this pattern consistently for any function that processes varied inputs.
- Define a slice of anonymous structs (not a map -- maps have non-deterministic iteration order) containing:
name string, input fields, expected output fields, andwantErr boolor a specific error type check. - Use
t.Run(tc.name, func(t *testing.T) { ... })for each case. This produces output like--- FAIL: TestParseURL/empty_hostwhich is grep-friendly. - Mark subtests as parallel with
tt := tc; t.Parallel()inside the subtest closure. Assign the loop variable to a local copy (tt) before callingt.Parallel()-- failing to do so is the classic Go loop variable capture bug that was fixed only in Go 1.22. For Go < 1.22, this copy is mandatory. - Name test cases with snake_case or short descriptive phrases:
"empty_input","max_int_boundary","unicode_multibyte_sequence". Avoid"test 1","case A", or numeric-only names. - Group cases that test error conditions at the end of the table. Arrange happy-path cases first for readability.
- For functions returning
error, always include at least one case wherewantErr: trueand verify error content -- not justerr != nil.
func TestParseAmount(t *testing.T) {
t.Parallel()
tests := []struct {
name string
input string
want int64
wantErr bool
}{
{name: "valid_integer", input: "100", want: 100},
{name: "leading_zeros", input: "007", want: 7},
{name: "empty_string", input: "", wantErr: true},
{name: "negative_value", input: "-5", wantErr: true},
{name: "overflow_int64", input: "99999999999999999999", wantErr: true},
}
for _, tc := range tests {
tc := tc // pre-1.22 capture fix
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got, err := ParseAmount(tc.input)
if tc.wantErr {
if err == nil {
t.Fatalf("expected error, got nil")
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != tc.want {
t.Errorf("got %d, want %d", got, tc.want)
}
})
}
}
4. Choose Between stdlib Assertions and testify
The stdlib provides t.Error, t.Errorf, t.Fatal, and t.Fatalf. Testify provides assert and require packages. Make a deliberate choice per project and stick to it.
- Use stdlib only when the project has zero external test dependencies, when assertions are simple equality or nil checks, and when the team is comfortable with verbose error messages.
- Use
testify/assertwhen you need rich diff output for struct comparisons, when you want to continue running the rest of a test after a failure (non-fatal assertions), or when testing teams find theassert.Equal(t, expected, actual)signature more readable. - Use
testify/require(same API asassert) when a failure means the test cannot meaningfully continue -- e.g.,require.NoError(t, err)before using a value thaterrguards.requirecallst.FailNow()which stops the current test immediately;assertcallst.Fail()which marks failure but continues execution. - The argument order convention for testify is
(t, expected, actual)-- putting expected first matches the error message format "expected X, got Y". Getting this backwards is a common mistake that produces confusing failure messages. - Use
testify/mockfor interface mocking. Define a struct that embedsmock.Mock, implement the interface methods callingm.Called(args...), and set up expectations withmockObj.On("MethodName", args).Return(values). Always callmockObj.AssertExpectations(t)at the end of each test that uses a mock. - Do NOT use
testify/suitefor new code. Suites addSetupTest/TeardownTestlifecycle hooks but obscure test structure, make parallelism awkward, and create hidden coupling between tests. Use explicit helper functions andt.Cleanup()instead.
5. Test HTTP Handlers with httptest
The net/http/httptest package is the canonical tool for testing Go HTTP code without a real server.
- Use
httptest.NewRecorder()for testing a single handler function. Create the recorder, create a request withhttp.NewRequest("GET", "/path", nil), callhandler.ServeHTTP(rec, req), then inspectrec.Code,rec.Body.String(), andrec.Header(). - Use
httptest.NewServer(handler)to spin up a real TCP listener on a random port. This is required when testing middleware chains, redirect behavior, connection-level behavior, or client code that needs a real URL. Calldefer ts.Close()immediately after creating the server. - Use
httptest.NewTLSServer(handler)when testing TLS-specific behavior. The returned server uses a self-signed certificate; access it withts.Client()which is pre-configured to trust it. - When testing a router (e.g.,
chi,gorilla/mux,stdlib ServeMux), pass the fully assembled router tohttptest.NewRecorder()orhttptest.NewServer(). Do not test routes in isolation from the router -- the routing is part of the contract. - Set request headers explicitly:
req.Header.Set("Content-Type", "application/json"). Missing content-type headers cause handler failures that are not obvious from the test output. - For authenticated endpoints, add an auth header or cookie to the request in the test. Create a test helper
func newAuthRequest(t *testing.T, method, path string) *http.Requestto avoid repetition. - Parse and validate response bodies with
json.NewDecoder(rec.Body).Decode(&result)-- do not use string matching on JSON unless testing exact serialization format.
func TestCreateUserHandler(t *testing.T) {
svc := &fakeUserService{createResult: &User{ID: "u1", Email: "a@b.com"}}
h := NewUserHandler(svc)
body := `{"email":"a@b.com","password":"secret123"}`
req := httptest.NewRequest(http.MethodPost, "/users", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
require.Equal(t, http.StatusCreated, rec.Code)
var resp UserResponse
require.NoError(t, json.NewDecoder(rec.Body).Decode(&resp))
assert.Equal(t, "u1", resp.ID)
assert.Equal(t, "a@b.com", resp.Email)
}
6. Write Benchmarks Correctly
Benchmarks in Go use testing.B and are invoked with go test -bench=. -benchmem. Common mistakes produce meaningless results.
- Name benchmark functions
BenchmarkXxxwhereXxxdescribes the operation:BenchmarkJSONMarshal_SmallStruct,BenchmarkQueryUser_CacheMiss. - The benchmark loop must use
b.Nexactly:for i := 0; i < b.N; i++ { ... }. The testing framework calibratesb.Nautomatically until the benchmark runs for at least 1 second by default. - Call
b.ResetTimer()after expensive setup that should not be measured: allocating large data structures, connecting to test databases, or parsing large files. Do NOT callb.ResetTimer()inside the benchmark loop. - Use
b.ReportAllocs()or run with-benchmemto see allocation counts per operation (allocs/op). Reducingallocs/opto zero for hot paths is often more impactful than CPU time improvements. - Prevent compiler dead-code elimination by assigning results to a package-level variable of the appropriate type:
var BenchResult SomeType. Inside the benchmark loop:BenchResult = result. Without this, the compiler may optimize away the work being measured. - Use
b.RunParallelto benchmark concurrent workloads: this runs the benchmark body in parallel goroutines proportional toGOMAXPROCS. Use it to detect lock contention. - Use
benchstat(fromgolang.org/x/perf/cmd/benchstat) to compare benchmark runs statistically. Run each benchmark at least 5-10 times (-count=10) before comparing withbenchstat old.txt new.txt.
7. Write Fuzz Tests (Go 1.18+)
Fuzz testing uses testing.F and the go test -fuzz flag to generate novel inputs and find panics or invariant violations.
- Name fuzz functions
FuzzXxx. The function signature isfunc FuzzXxx(f *testing.F). - Provide seed corpus with
f.Add(value1, value2, ...). Seeds serve two purposes: they run as regular unit test cases duringgo test(without-fuzz), and they seed the fuzzer's input generation. Provide at least 3-5 seeds covering boundary conditions. - The fuzz target is a closure passed to
f.Fuzz(func(t *testing.T, arg1 type1, arg2 type2) { ... }). Keep the fuzz target simple and deterministic -- avoid network calls, sleep, or goroutines inside it. - The fuzzer saves inputs that cause failures to
testdata/fuzz/FuzzXxx/as files. Commit these files to the repository -- they become permanent regression tests run on everygo testinvocation. - Check invariants inside the fuzz target, not just absence of panics. For a parser:
parsed, err := Parse(input); if err == nil { assert.Equal(t, input, parsed.String()) }verifies a round-trip invariant. - Run fuzzing in CI with a time limit:
go test -fuzz=FuzzParseURL -fuzztime=30s ./pkg/url/. Do not run unlimited fuzzing in CI -- it will never terminate. Run long fuzzing sessions locally or in dedicated fuzzing infrastructure. - Fuzz targets support only the following argument types:
string,[]byte,bool,byte,rune,float32,float64,int,int8,int16,int32,int64,uint,uint8,uint16,uint32,uint64. To fuzz complex structs, encode them as JSON bytes and unmarshal inside the target.
8. Configure Coverage, Race Detection, and CI Integration
Test quality is enforced through tooling, not convention alone.
- Run
go test -race ./...in CI unconditionally. The race detector has roughly 5-20x runtime overhead -- acceptable in CI, not for production. It detects data races that-count=1tests will not reveal because timing varies. - Run
go test -cover ./...to get per-package coverage. Rungo test -coverprofile=coverage.out ./... && go tool cover -html=coverage.outlocally to visualize uncovered lines. - Set a coverage threshold in CI using a shell script or tool like
go-test-coverage: reject merges below 80% statement coverage for critical packages. Do not set a single threshold for the entire codebase -- generated code,mainpackages, and test helpers skew the number. - Exclude generated files from coverage with
//go:build ignoreor usingcoverpkgflags carefully. Usegrepto strip generated file coverage from the profile before threshold enforcement. - Cache test results:
go testcaches results automatically when inputs have not changed. The cache is invalidated by file changes, environment variable changes, and when-countis set. Use-count=1to force re-run in CI when you need fresh results despite the cache. - Use
go test -timeout 60s ./...in CI. Default timeout is 10 minutes -- too long for fast feedback. Set package-level timeouts and set the global timeout to 120s as an upper bound. - Golden file tests store expected output in
testdata/files. Useflag.Bool("update", false, "update golden files")andif *update { os.WriteFile(goldenPath, actual, 0644) }to regenerate them. Rungo test -run TestGolden -updatewhen expected output intentionally changes.
Output Format
When providing Go testing guidance, structure the response as follows:
## Test Strategy for [Component Name]
**Test category:** [unit | integration | benchmark | fuzz]
**Package placement:** [same package (whitebox) | _test suffix (blackbox)]
**External dependencies:** [list: testify/assert, testify/require, testify/mock, httptest, etc.]
---
### Test File: [filename]_test.go
```go
package [package_name][_test]
import (
"testing"
// additional imports
)
// --- Helpers and Fakes ---
type fake[Dependency] struct {
// recorded calls and configured returns
}
func (f *fake[Dependency]) [Method](args) (returns) {
// minimal fake implementation
}
// --- Unit Tests ---
func Test[Function]_[Scenario](t *testing.T) {
t.Parallel()
tests := []struct {
name string
// input fields
// expected output fields
wantErr bool
}{
{
name: "[descriptive_case_name]",
// populate fields
},
}
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
// arrange
// act
// assert
})
}
}
// --- Integration Tests (gated by build tag if needed) ---
// --- Benchmarks ---
var Bench[Result] [ResultType] // package-level sink
func Benchmark[Operation]_[Scenario](b *testing.B) {
// setup (not measured)
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
Bench[Result] = [operation]
}
}
// --- Fuzz Targets ---
func Fuzz[Operation](f *testing.F) {
f.Add([seed1])
f.Add([seed2])
f.Fuzz(func(t *testing.T, input [type]) {
// call function under test
// assert invariants
})
}
Run commands:
| Command | Purpose |
|---|---|
go test ./... |
All unit tests |
go test -race ./... |
With race detector |
go test -run TestX/subcase ./pkg |
Single subtest |
go test -bench=BenchmarkX -benchmem -count=5 ./pkg |
Benchmark with allocations |
go test -fuzz=FuzzX -fuzztime=60s ./pkg |
Fuzz for 60 seconds |
go test -cover -coverprofile=c.out ./... |
Coverage report |
go test -tags=integration ./... |
Integration tests |
Coverage target: [X]% statement coverage for this package Race-safe: [yes | no -- explain why not]
---
## Rules
1. **Never use `time.Sleep` in tests.** Sleeping to wait for async operations is nondeterministic and will cause flaky tests in CI. Use `sync.WaitGroup`, channels, or `require.Eventually(t, condition, 2*time.Second, 10*time.Millisecond)` from testify to poll with a timeout and interval.
2. **Always capture the loop variable before calling `t.Parallel()` in Go < 1.22.** The pattern `tc := tc` inside the `for _, tc := range tests` loop is mandatory. In Go 1.22+, loop variable semantics changed and the copy is no longer required, but it does not hurt to include it for compatibility.
3. **Never assert on `err.Error()` string values for error identity.** String comparison is brittle -- error messages change. Use `errors.Is(err, target)` for sentinel errors, `errors.As(err, &target)` for error types, or `require.ErrorContains(t, err, "substring")` only when the message is the specification.
4. **The argument order for testify is `(t, expected, actual)`, never reversed.** Getting this wrong produces failure messages like "expected 42, got 42 (expected and actual are identical)" which is disorienting. Always put the known/hardcoded value first.
5. **Never share mutable state between parallel tests.** Any variable written by one `t.Parallel()` subtest and read by another is a data race. Use `t.Cleanup()` for per-test teardown. Use `sync.Mutex` or channels if shared state is truly necessary -- and question whether it is.
6. **Always call `mockObj.AssertExpectations(t)` at the end of tests using `testify/mock`.** Without this call, mocks with uncalled `On()` expectations will silently pass. Register it immediately after creating the mock: `t.Cleanup(func() { mockObj.AssertExpectations(t) })`.
7. **Never use `init()` to register test dependencies or global state.** `init()` runs in every test binary, creates hidden coupling, and makes test isolation impossible. Use `TestMain` for one-time global setup, and explicit constructors for per-test dependency injection.
8. **Benchmark result variables must be package-level sinks.** Assigning benchmark results to a local variable allows the compiler to eliminate the computation as dead code, producing artificially fast benchmarks. Always use a package-level `var BenchResult T` as the sink.
9. **Fuzz seed corpus must cover error-producing inputs, not just valid inputs.** The fuzzer explores from seeds. If all seeds are valid inputs, the fuzzer starts from a valid state and may never discover parsing edge cases. Include empty strings, maximum-length inputs, control characters, and known-bad inputs as seeds.
10. **Integration tests must be gated by a build tag or an environment variable check, never by file naming alone.** Use `//go:build integration` at the top of integration test files, or check `os.Getenv("RUN_INTEGRATION_TESTS") == "1"` in `TestMain` and call `os.Exit(0)` to skip cleanly. Running integration tests on every `go test ./...` invocation slows local development and creates flaky CI.
---
## Edge Cases
**Go version below 1.18 (no fuzz testing available)**
Fuzz testing with `testing.F` requires Go 1.18+. For older codebases, implement property-based testing using `pgregory.net/rapid` or `leanovate/gopter`. These libraries generate random inputs using strategies similar to Haskell's QuickCheck. If upgrading Go is blocked, migrate fuzz targets to `rapid.Check` patterns and add a comment marking them as candidates for migration to native fuzz once Go is upgraded.
**Testing code that calls `os.Exit` or `log.Fatal`**
Functions that call `os.Exit` cannot be tested normally -- they terminate the test process. The standard Go pattern is the "subprocess test": create a test function `TestFoo_exits(t *testing.T)` that checks `os.Getenv("BE_CRASHER") == "1"` and calls the real function. The main test body re-runs itself as a subprocess with the environment variable set and asserts on the subprocess exit code. This is verbose but correct. A simpler alternative is to refactor the code under test to accept an `exit func(int)` parameter (defaulting to `os.Exit`) that can be replaced in tests.
**Testing code that reads from `os.Stdin` or writes to `os.Stdout`**
Replace direct `os.Stdin`/`os.Stdout` references with `io.Reader`/`io.Writer` parameters in the function signature. In tests, use `strings.NewReader("input data")` and `bytes.Buffer` or `strings.Builder` as the writer. This also makes the code more useful as a library. If the function cannot be refactored, use `os.Pipe()` to create a fake stdin/stdout pair: `r, w, _ := os.Pipe(); os.Stdin = r` then write to `w` from the test goroutine.
**Parallel tests with shared database state**
When running integration tests against a real database in parallel, each parallel test must operate in an isolated schema or transaction. The cleanest pattern is to create a fresh database schema per test using `t.Cleanup(func() { db.Exec("DROP SCHEMA test_xxx CASCADE") })`. An alternative is to wrap each test in a transaction and roll it back: `tx := db.Begin(); t.Cleanup(func() { tx.Rollback() })`. Pass `tx` (not the global `db`) to the code under test. This approach prevents parallel tests from interfering but requires that the code accept an interface rather than a concrete `*sql.DB`.
**Testing code with randomness or time-dependent behavior**
Never call `time.Now()` or `rand.Int()` directly inside testable functions. Instead, inject a `clock` interface with a `Now() time.Time` method (or use `github.com/jonboulle/clockwork`) and a `rand.Source` that can be seeded deterministically. In tests, pass a fixed `time.Time` value and a `rand.NewSource(42)`. This makes time-dependent tests fully deterministic without mocking the standard library, which is not possible without patching.
**Large test fixtures and golden files getting out of sync**
Golden files in `testdata/` drift from actual output as the system evolves, causing failures that are fixed by blindly running `-update` without reviewing diffs. Enforce a `git diff --exit-code testdata/` step in CI after running with `-update` -- the update should produce no changes if golden files are current. Additionally, keep golden files small and focused. If a golden file exceeds 50 lines, the test is asserting too much. Break it into multiple focused assertions or tests.
**Mocking interfaces you do not own (e.g., `http.ResponseWriter`, `sql.DB`)**
The stdlib `http.ResponseWriter` is an interface you can implement directly -- `httptest.ResponseRecorder` does exactly this. For `*sql.DB`, which is a concrete struct, the approach is different: define your own `DBQuerier` interface containing only the methods your code uses, have production code accept `DBQuerier`, and write a fake `DBQuerier` in tests. Never mock `*sql.DB` directly. Libraries like `DATA-DOG/go-sqlmock` provide a mock `database/sql` driver, but they are complex and often over-specify behavior. Prefer a hand-written fake interface.
**Race detector finding races only in tests, not production code**
Sometimes the race detector flags races in test helper code -- goroutines launched in `TestMain`, shared counters in fake implementations, or parallel test setup that writes to a shared map. These are real races even if they only appear in test code. Fix them: protect shared state in fakes with a `sync.Mutex`, use `t.Cleanup` instead of goroutines for teardown, and never write to a map from multiple goroutines without synchronization, even in tests.
---
## Example
**Input:** "I need to write tests for a `PricingService` that calculates order totals. It depends on a `ProductRepository` interface to look up product prices and applies a discount if the order total exceeds $100. I also want a benchmark. How do I structure this in Go?"
---
**Output:**
### Test Strategy for PricingService
**Test category:** unit tests (table-driven), benchmark
**Package placement:** `package pricing_test` (blackbox -- testing exported API)
**External dependencies:** `testify/assert`, `testify/require`, `testify/mock`
---
**File: `pricing_test.go`**
```go
package pricing_test
import (
"errors"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"github.com/example/shop/pricing"
)
// --- Fake / Mock for ProductRepository ---
// fakeProductRepo is a hand-written fake that gives us full control
// without the overhead of testify/mock for simple cases.
type fakeProductRepo struct {
prices map[string]float64
err error
}
func (f *fakeProductRepo) GetPrice(productID string) (float64, error) {
if f.err != nil {
return 0, f.err
}
price, ok := f.prices[productID]
if !ok {
return 0, pricing.ErrProductNotFound
}
return price, nil
}
// mockProductRepo uses testify/mock for tests that need call verification.
type mockProductRepo struct {
mock.Mock
}
func (m *mockProductRepo) GetPrice(productID string) (float64, error) {
args := m.Called(productID)
return args.Get(0).(float64), args.Error(1)
}
// --- Unit Tests: CalculateTotal ---
func TestPricingService_CalculateTotal(t *testing.T) {
t.Parallel()
tests := []struct {
name string
prices map[string]float64
orderItems map[string]int // productID -> quantity
wantTotal float64
wantErr bool
errTarget error
}{
{
name: "single_item_no_discount",
prices: map[string]float64{"prod-1": 30.00},
orderItems: map[string]int{"prod-1": 2},
wantTotal: 60.00, // 2 * 30.00, under $100 threshold
},
{
name: "multiple_items_no_discount",
prices: map[string]float64{"prod-1": 20.00, "prod-2": 15.00},
orderItems: map[string]int{"prod-1": 3, "prod-2": 1},
wantTotal: 75.00, // 60 + 15 = 75, under threshold
},
{
name: "discount_applied_at_exactly_100",
prices: map[string]float64{"prod-1": 50.00},
orderItems: map[string]int{"prod-1": 2},
wantTotal: 95.00, // 100 * 0.95 = 95.00 (5% discount)
},
{
name: "discount_applied_above_100",
prices: map[string]float64{"prod-1": 40.00, "prod-2": 35.00},
orderItems: map[string]int{"prod-1": 2, "prod-2": 1},
wantTotal: 109.25, // 115 * 0.95 = 109.25
},
{
name: "zero_quantity_item_excluded",
prices: map[string]float64{"prod-1": 50.00, "prod-2": 20.00},
orderItems: map[string]int{"prod-1": 0, "prod-2": 3},
wantTotal: 60.00, // only prod-2 counted
},
{
name: "unknown_product_returns_error",
prices: map[string]float64{},
orderItems: map[string]int{"ghost-prod": 1},
wantErr: true,
errTarget: pricing.ErrProductNotFound,
},
{
name: "repository_error_propagates",
prices: nil, // unused -- err is set
orderItems: map[string]int{"prod-1": 1},
wantErr: true,
},
{
name: "empty_order_returns_zero",
prices: map[string]float64{},
orderItems: map[string]int{},
wantTotal: 0.00,
},
}
for _, tc := range tests {
tc := tc // pre-1.22 loop variable capture
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
repo := &fakeProductRepo{prices: tc.prices}
// Override with a repo-level error for the propagation test.
if tc.name == "repository_error_propagates" {
repo.err = errors.New("connection refused")
}
svc := pricing.NewPricingService(repo)
got, err := svc.CalculateTotal(tc.orderItems)
if tc.wantErr {
require.Error(t, err)
if tc.errTarget != nil {
assert.True(t, errors.Is(err, tc.errTarget),
"expected error %v, got %v", tc.errTarget, err)
}
return
}
require.NoError(t, err)
assert.InDelta(t, tc.wantTotal, got, 0.001,
"total mismatch: got %.2f, want %.2f", got, tc.wantTotal)
})
}
}
// --- Interaction Test: verify repository is called exactly once per product ---
func TestPricingService_CalculateTotal_CallsRepoOncePerProduct(t *testing.T) {
t.Parallel()
repo := &mockProductRepo{}
repo.On("GetPrice", "prod-1").Return(20.00, nil)
repo.On("GetPrice", "prod-2").Return(15.00, nil)
t.Cleanup(func() { repo.AssertExpectations(t) })
svc := pricing.NewPricingService(repo)
_, err := svc.CalculateTotal(map[string]int{"prod-1": 3, "prod-2": 2})
require.NoError(t, err)
// AssertExpectations in Cleanup verifies each method was called exactly
// the number of times specified. No extra assertions needed here.
}
// --- Benchmarks ---
// BenchTotal is the package-level sink to prevent dead-code elimination.
var BenchTotal float64
func BenchmarkPricingService_CalculateTotal_SmallOrder(b *testing.B) {
repo := &fakeProductRepo{
prices: map[string]float64{
"prod-1": 19.99,
"prod-2": 34.50,
},
}
svc := pricing.NewPricingService(repo)
order := map[string]int{"prod-1": 2, "prod-2": 1}
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
total, _ := svc.CalculateTotal(order)
BenchTotal = total
}
}
func BenchmarkPricingService_CalculateTotal_LargeOrder(b *testing.B) {
const productCount = 100
prices := make(map[string]float64, productCount)
order := make(map[string]int, productCount)
for i := 0; i < productCount; i++ {
id := fmt.Sprintf("prod-%d", i)
prices[id] = float64(i+1) * 1.25
order[id] = 2
}
repo := &fakeProductRepo{prices: prices}
svc := pricing.NewPricingService(repo)
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
total, _ := svc.CalculateTotal(order)
BenchTotal = total
}
}
Run commands for this service:
| Command | Purpose |
|---|---|
go test ./pricing/... |
All unit tests |
go test -race ./pricing/... |
With race detector |
go test -run TestPricingService_CalculateTotal/discount_applied ./pricing |
Single subtest |
go test -bench=BenchmarkPricingService -benchmem -count=10 ./pricing |
Benchmarks with allocation data |
go test -cover -coverprofile=pricing.out ./pricing/... && go tool cover -func=pricing.out |
Coverage by function |
Expected coverage target: 90%+ statement coverage for pricing package (it contains the core business logic; coverage gaps here represent untested business rules).
Race-safe: Yes -- the fake and mock implementations are used only within individual subtests. No shared mutable state crosses subtest boundaries.
Sample benchmark output to expect:
BenchmarkPricingService_CalculateTotal_SmallOrder-8 2847193 421.3 ns/op 96 B/op 3 allocs/op
BenchmarkPricingService_CalculateTotal_LargeOrder-8 28471 42130 ns/op 9600 B/op 300 allocs/op
If allocs/op scales linearly with productCount (3 allocs per product), the next optimization target is to pre-allocate the result accumulator or reuse a pool. Use benchstat with -count=10 to confirm regressions before and after any optimization change.