Skip to content
Santekno.com | Level Up Your Engineering Skills
EN
📖 0%
31 Jul 2026 · 11 min read ·Article 13 / 208
Go

Unit Tests from Spec: AI-Accelerated TDD for Golang

Write Golang unit tests directly from a feature specification with AI-accelerated TDD using Claude Code. Turn acceptance criteria into a comprehensive test suite with testify and gomock.

IH
Ihsan Arif
Writer at Santekno · Backend Engineer

Unit Tests from Spec: AI-Accelerated TDD

One of the most concrete payoffs of Specification-Driven Development is this: when the spec is good, your unit tests from spec practically write themselves. Each acceptance criterion (AC) becomes a test function, each edge case (EC) becomes one or more scenarios, and each non-functional requirement (NFR) becomes a benchmark or integration test. In this article we turn a Golang feature spec into a comprehensive testify + gomock suite — using Claude Code to accelerate the boilerplate while making sure every assertion actually verifies the behavior the spec demands.


13.1 Direct AC-to-Test Mapping

The cleanest place to start is a one-to-one mapping: a well-formed acceptance criterion translates directly into a single test function. Take this AC pulled straight from the cancel-order spec.

markdown
1AC9: Order status not PENDING → 409 Conflict, error_code: ORDER_NOT_CANCELLABLE

That single line becomes the Go test below, where the function name encodes both the condition and the expected behavior.

go
 1func (s *CancelOrderSuite) TestAC9_ConfirmedOrder_ReturnsOrderNotCancellableError() {
 2    // Given: CONFIRMED order
 3    order := &domain.Order{Status: domain.StatusConfirmed}
 4    s.repo.EXPECT().GetByIDAndUserID(gomock.Any(), s.orderID, s.userID).Return(order, nil)
 5
 6    // When
 7    err := s.uc.Execute(s.ctx, s.validInput())
 8
 9    // Then: OrderNotCancellableError with CONFIRMED status
10    var notCancellable *domain.OrderNotCancellableError
11    s.True(errors.As(err, &notCancellable))
12    s.Equal(domain.StatusConfirmed, notCancellable.CurrentStatus)
13}

Notice the AC number lives in the test name — so a red test points you straight back to the exact line of spec it is meant to protect.


13.2 Generating the Test Suite with a Prompt

Rather than hand-writing every case, describe the mapping rules once and let Claude Code expand them across the whole spec. The prompt below is deliberately explicit about naming, structure, and mock expectations so the generated tests come out consistent.

text
 1Generate a comprehensive test suite for CancelOrderUseCase based on the spec.
 2
 3For each AC and EC:
 41. Create one test function named: Test{Layer}_{Condition}_{ExpectedBehavior}
 52. Use Given-When-Then comments (explicit // Given, // When, // Then)
 63. Mock setup must be explicit about: return values, call count
 74. Assertions must verify: return value type, side effects, async behavior
 8
 9Use testify/suite and gomock.
10Spec: [paste spec], Interfaces: [paste interfaces]

The output is a starting point, not a finished suite — the value of a strict prompt is that every generated function already follows the same shape, so your review can focus on correctness rather than formatting.


13.3 Key Test Patterns

Two patterns cover most real cases. The first asserts an asynchronous side effect: the handler returns immediately, but a goroutine must still publish an event, so we synchronize on a buffered channel.

go
 1publishCalled := make(chan struct{}, 1)
 2s.publisher.EXPECT().PublishOrderCancelled(gomock.Any(), gomock.Any()).
 3    DoAndReturn(func(_ context.Context, _ domain.OrderCancelledEvent) error {
 4        close(publishCalled)
 5        return nil
 6    })
 7
 8err := s.uc.Execute(s.ctx, s.validInput())
 9s.NoError(err)
10
11select {
12case <-publishCalled:
13case <-time.After(100 * time.Millisecond):
14    s.Fail("publisher not called within timeout")
15}

The second pattern verifies best-effort behavior: even when Kafka fails, the cancel itself must still succeed, because the spec marks event publishing as non-blocking.

go
1s.publisher.EXPECT().PublishOrderCancelled(gomock.Any(), gomock.Any()).
2    Return(errors.New("kafka: connection refused"))
3
4err := s.uc.Execute(s.ctx, s.validInput())
5s.NoError(err, "cancel should succeed even when Kafka fails")

Together these two patterns show the discipline SDD demands: assert not just the return value, but the side effects and failure semantics the spec explicitly calls out.


13.4 Table-Driven Tests for Multiple Scenarios

When many inputs share one expected outcome, a table-driven test keeps the suite compact. Here every non-PENDING status must produce the same not-cancellable error.

go
 1func (s *CancelOrderSuite) TestAC9_AllNonPendingStatuses() {
 2    statuses := []domain.Status{
 3        domain.StatusConfirmed, domain.StatusShipped,
 4        domain.StatusDelivered, domain.StatusCancelled,
 5    }
 6    for _, status := range statuses {
 7        s.Run(string(status), func() {
 8            // test each status as a sub-test
 9        })
10    }
11}

The payoff is coverage without duplication — adding a new status to the slice adds a new sub-test automatically, and each one runs isolated under its own s.Run name.


13.5 Boundary Value Tests

Numeric boundaries are where silent bugs hide, so the 15-minute cancellation window deserves explicit cases on both sides of the edge. The table below pins down exactly where “within window” flips to “expired”.

go
 1testCases := []struct {
 2    ageSec  int
 3    wantErr bool
 4}{
 5    {1, false},    // 1 second old
 6    {899, false},  // 14 min 59 sec — within window
 7    {900, true},   // exactly 15 min — window closed
 8    {901, true},   // 15 min 1 sec — expired
 9    {3600, true},  // 1 hour old
10}

The critical rows are 899 and 900: they document the inclusive/exclusive decision so nobody can flip <= to < during a refactor without a test turning red.


13.6 Handler Testing Pattern

Handler tests exercise the HTTP layer, so they wire up an Echo context, set the path param and injected user, then assert the status code. The example verifies the 204 No Content contract.

go
 1func (s *CancelOrderHandlerSuite) TestCancelOrder_ValidRequest_Returns204() {
 2    req := httptest.NewRequest(http.MethodDelete, "/", nil)
 3    rec := httptest.NewRecorder()
 4    c := s.e.NewContext(req, rec)
 5    c.SetParamNames("id")
 6    c.SetParamValues(orderID.String())
 7    c.Set("user_id", userID)
 8
 9    s.cancelUC.EXPECT().Execute(gomock.Any(), gomock.Any()).Return(nil)
10
11    s.handler.CancelOrder(c)
12
13    s.Equal(http.StatusNoContent, rec.Code)
14    s.Empty(rec.Body.String())
15}

The key assertions are the status code and the empty body — both are part of the spec’s response contract, not incidental details, so both are verified explicitly.


13.7 Spec-to-Test Coverage Report

Writing tests is only half the job; you also need to prove every AC and EC is covered. Ask Claude Code to trace each criterion back to the test that exercises it.

text
 1After the test suite is complete, generate a spec compliance report:
 2
 3For each AC and EC in the spec:
 41. Show the test function(s) that cover it
 52. Status: Covered / Not Covered / Partially
 6
 7| AC/EC | Test Function(s) | Status | Notes |
 8|-------|-----------------|--------|-------|
 9
10Test file: [file path]
11Spec: [spec path]

The report turns “we have tests” into “we have traceability” — an auditable link from every requirement to the test protecting it, which is exactly what reviewers and auditors want to see.


13.8 Test Documentation Convention

A short header comment mapping ACs to tests makes the suite self-documenting. The convention below sits at the top of the test file.

go
 1// TestSuite_CancelOrder tests CancelOrderUseCase.
 2// Spec: specs/order/cancel-order.md v1.3
 3//
 4// AC Coverage:
 5//   AC1-AC7 (happy path): TestSuccess_PendingOrder_Within15Min_*
 6//   AC8 (not found):      TestAC8_OrderNotFound_*
 7//   AC9 (status):         TestAC9_*
 8//   AC10 (window):        TestAC10_*
 9//
10// EC Coverage:
11//   EC1 (concurrent):     Integration test in test/integration/
12//   EC2 (kafka fail):     TestEC2_KafkaPublishFails_*
13//   EC3 (db fail):        TestEC3_StockRestoreFails_*

This block lives beside the code it describes, so when the spec version bumps the reviewer immediately sees which coverage claims need re-checking.


13.9 Observability Testing (NFR)

Non-functional requirements are testable too. If the spec says a success metric must increment, assert it against a real Prometheus registry rather than trusting the code by inspection.

go
 1func TestCancelOrder_MetricIncrement_OnSuccess(t *testing.T) {
 2    registry := prometheus.NewRegistry()
 3    metrics := ordermetrics.New(registry)
 4    uc := orderuc.NewCancelOrderUseCase(repo, publisher, metrics)
 5
 6    uc.Execute(ctx, input)
 7
 8    // Assert: cancel_order_total{status="success"} == 1
 9    mfs, _ := registry.Gather()
10    _ = mfs // assert metric value
11}

Testing observability this way keeps NFRs honest — the metric is verified by a test, not left to hope, so a broken dashboard fails CI instead of surprising you in production.


13.10 Property-Based Testing for the Window Boundary

For numeric boundaries you do not always need a full property-based framework. A table-driven test that walks a range of ages around the 15-minute mark approximates property testing well: it asserts the invariant “orders younger than 15 minutes are always cancellable” across many inputs, which is exactly the kind of boundary the spec pins down.


13.11 Test Naming Convention

Consistent names make tests searchable and readable as documentation. The format encodes layer, condition, and expected behavior.

text
1Format: Test{Layer}_{Condition}_{ExpectedBehavior}
2
3From AC:  TestCancelOrder_ConfirmedOrder_ReturnsNotCancellableError
4From EC:  TestCancelOrder_KafkaPublishFails_CancelStillSucceeds
5From NFR: BenchmarkCancelOrder_TypicalLoad

Read aloud, each name is a mini specification statement — which means go test -run becomes a way to query behavior by intent, not just by file.


13.12 CI Coverage Enforcement (NFR-T1)

A coverage NFR is only real if CI enforces it. The step below fails the build whenever total coverage drops below the 80% floor.

yaml
1- name: Verify coverage minimum (NFR-T1: 80%)
2  run: |
3    go test -coverprofile=coverage.out ./internal/usecase/...
4    COVERAGE=$(go tool cover -func=coverage.out | grep total | awk '{print $3}' | tr -d '%')
5    if (( $(echo "$COVERAGE < 80" | bc -l) )); then
6      echo "Coverage $COVERAGE% below NFR-T1 minimum of 80%"
7      exit 1
8    fi

With this gate in place, coverage regressions can never be merged silently — the NFR is enforced by the pipeline rather than by reviewer memory.


13.13 Parallel Test Execution

Independent tests can run in parallel to keep the suite fast. Marking both the parent and each sub-test with t.Parallel() lets the Go runtime interleave them.

go
 1func TestCancelOrder_AllErrorCases(t *testing.T) {
 2    t.Parallel()
 3    for _, tc := range testCases {
 4        tc := tc
 5        t.Run(tc.name, func(t *testing.T) {
 6            t.Parallel()
 7            // independent test logic
 8        })
 9    }
10}

The tc := tc capture is not optional — without it every parallel sub-test would close over the same loop variable, and the suite would test the last case many times over.


13.14 Contract Testing with a Real DB

Some guarantees can only be verified against a real database. Contract tests exercise the repository against Postgres and skip in short mode so unit runs stay fast.

go
1func TestOrderRepository_ContractCompliance(t *testing.T) {
2    if testing.Short() {
3        t.Skip("contract test requires DB")
4    }
5    // Use testcontainers or a dedicated test DB.
6    // Verify: GetByIDAndUserID returns nil for the wrong user (not an error)
7    // Verify: CancelWithStockRestore is atomic (all or nothing)
8}

These tests protect the two guarantees mocks can never prove — that “wrong user” looks identical to “not found”, and that stock restore is truly atomic under a real transaction.


13.15 Test Quality Metrics

Once the suite is written, measure it against four dimensions rather than coverage percentage alone: spec coverage (what share of ACs and ECs have a test), assertion ratio (average assertions per test, aim for 2–5), execution time (all unit tests under 30 seconds per NFR-T4), and mutation score (what share of injected code mutations the suite catches). Coverage tells you which lines ran; these metrics tell you whether the tests would actually notice a regression.


13.16 Tips & Gotchas

A few habits separate a suite that documents behavior from one that merely runs:

Tip 1: A test function name is mini-documentation — it should read like a spec statement.

Tip 2: One test = one AC — don’t verify everything in a single function; failures become hard to localize.

Tip 3: Use specific assertions — the exact error type and message, not just “error is not nil”.

Tip 4: Use gomock.InOrder for sequence-dependent operations.

Gotcha 1: Async tests that don’t wait on the goroutine are flaky — always synchronize on a channel.

Gotcha 2: AnyTimes() on mocks that should have strict call counts gives false confidence.

Gotcha 3: More mock setup than assertions means you’re testing implementation, not behavior.

Gotcha 4: Never call time.Now() directly in a test — inject a clock interface.


13.17 Test Review Prompt

After the suite exists, use Claude as a reviewer focused on whether the tests truly verify the spec. The prompt keeps that review targeted.

text
1Review this test suite for CancelOrderUseCase:
2
31. Coverage: are all ACs from the spec covered by at least one test?
42. Quality: do tests verify correct behavior, not just "no panic"?
53. Assertions: are they specific? (exact error type, exact message)
64. Missing tests: what should be present but isn't?
7
8Spec: [paste], Test file: [paste]

The point of this pass is to catch tests that pass for the wrong reason — green does not mean correct if the assertions are too loose to fail.


13.18 Benchmark Tests for NFR

Performance NFRs deserve a benchmark that can be tracked over time. The loop below measures the hot path so latency regressions surface early.

go
 1func BenchmarkCancelOrder_TypicalLoad(b *testing.B) {
 2    // Setup mocks and usecase
 3    b.ResetTimer()
 4    for i := 0; i < b.N; i++ {
 5        if err := uc.Execute(context.Background(), input); err != nil {
 6            b.Fatal(err)
 7        }
 8    }
 9    // NFR-P2: p95 < 500ms verified in a separate NFR test
10}

Remember to call b.ResetTimer() after setup so fixture construction never pollutes the measurement — the benchmark should time only the code the NFR is about.


13.19 Snapshot Testing for Response Format

Error response shapes are part of the API contract, so lock them down exactly. assert.JSONEq compares structure and values while ignoring insignificant whitespace.

go
1func TestCancelOrderHandler_404Response_MatchesSpec(t *testing.T) {
2    // ... setup
3    handler.CancelOrder(c)
4
5    expected := `{"error_code":"ORDER_NOT_FOUND","message":"order not found"}`
6    assert.JSONEq(t, expected, strings.TrimSpace(rec.Body.String()),
7        "response format must match spec")
8}

This test fails the moment someone renames a field or changes an error code — precisely the accidental contract break that silently breaks clients in production.


13.20 Summary

Good unit tests in SDD aren’t about a coverage number — they’re about how accurately the suite verifies the behavior the spec defines.

Clear mapping: each AC → at least one test, each EC → at least one test, each NFR → a benchmark or integration test.

Test quality: consistent Given-When-Then structure, names that reflect the spec rather than the implementation, specific assertions, and correctly synchronized async behavior.

Tools: testify/suite for organization, gomock for mocking, table-driven tests for variation, benchmarks for NFRs.

AI acceleration: Claude Code can generate the boilerplate, but every assertion must be reviewed to confirm it verifies the spec — not merely that the code doesn’t crash.

In the next article we cover Code Review with AI — how to use Claude to verify that implemented code truly matches the spec, with systematic, repeatable review techniques.

Related Articles

💬 Comments