Skip to content
Santekno.com | Level Up Your Engineering Skills
EN
📖 0%
15 Oct 2026 · 23 min read ·Article 67 / 208
Go

Test Coverage Enforcement in Golang CI: Coverage Gate, Spec-Based Testing, AI Gap Analysis

Set up test coverage enforcement in CI for Go. A spec-based coverage gate, AI-powered gap analysis, and coverage trending that help the team maintain test quality.

IH
Ihsan Arif
Writer at Santekno · Backend Engineer

Test Coverage Enforcement from Specifications

Test coverage enforcement in golang CI is often reduced to a single magic number: 80%. Yet that number can be deeply misleading. Tests that only “cover lines” without touching edge cases and error paths can reach 80% while production stays full of bugs. In this article we build smarter coverage enforcement: not just a number, but coverage that is meaningful relative to the specification.

What we’ll cover is not merely “how to measure coverage,” but how to make every Acceptance Criterion in the spec a unit that must have a test, and then enforce that automatically in CI.


07.1 Meaningful Coverage vs Numeric Coverage

Before tuning any threshold, we need to understand the difference between coverage as a number and coverage as a guarantee of behavior. The illustration below compares the two directly.

text
 1NUMERIC COVERAGE (the common case):
 2  go test -coverprofile=cov.out ./...
 3  Coverage: 82%  ← a misleading number
 4
 5  What isn't visible:
 6  - Happy path: 95% covered
 7  - Error paths: 30% covered  ← most bugs live here
 8  - Edge cases: 10% covered
 9  - Spec acceptance criteria: 60% covered
10
11  A developer just tests the happy path → 80%+ number but plenty of bugs
12
13MEANINGFUL COVERAGE:
14  Every Acceptance Criterion from the spec = at least 1 test
15  Error paths = mandatory test
16  Spec'd edge cases = mandatory test
17
18  How to check: an audit verifies coverage per AC
19
20  Developers MUST test every AC → coverage that is meaningful

The core message: 82% without context tells you nothing about whether the error paths are tested. What we’re after isn’t a number, but the confidence that the spec’d behavior is actually verified.


07.2 Coverage Gate in CI: Basic Implementation

The first step in enforcement is running tests with coverage and then breaking the number down per layer. The GitHub Actions workflow below runs the tests, extracts coverage per layer, and enforces different thresholds for each.

yaml
 1# .github/workflows/coverage.yml — part of the full pipeline
 2
 3- name: Run tests with coverage
 4  run: |
 5    go test -race -coverprofile=coverage.out -covermode=atomic ./...
 6
 7- name: Generate coverage report
 8  run: |
 9    go tool cover -func=coverage.out > coverage-func.txt
10    go tool cover -html=coverage.out -o coverage.html
11
12- name: Extract coverage by layer
13  id: coverage
14  run: |
15    # Overall coverage
16    OVERALL=$(go tool cover -func=coverage.out | \
17      grep "^total" | awk '{print $3}' | tr -d '%')
18
19    # Coverage per layer (more meaningful)
20    USECASE=$(go tool cover -func=coverage.out | \
21      grep "internal/usecase" | \
22      awk '{sum+=$3; n++} END {if(n>0) printf "%.1f", sum/n; else print "0"}' | \
23      tr -d '%')
24
25    DOMAIN=$(go tool cover -func=coverage.out | \
26      grep "internal/domain" | \
27      awk '{sum+=$3; n++} END {if(n>0) printf "%.1f", sum/n; else print "0"}')
28
29    HANDLER=$(go tool cover -func=coverage.out | \
30      grep "delivery/http" | \
31      awk '{sum+=$3; n++} END {if(n>0) printf "%.1f", sum/n; else print "0"}')
32
33    echo "overall=$OVERALL" >> $GITHUB_OUTPUT
34    echo "usecase=$USECASE" >> $GITHUB_OUTPUT
35    echo "domain=$DOMAIN" >> $GITHUB_OUTPUT
36    echo "handler=$HANDLER" >> $GITHUB_OUTPUT
37
38    echo "Coverage: Overall=$OVERALL%, Usecase=$USECASE%, Domain=$DOMAIN%, Handler=$HANDLER%"
39
40- name: Enforce coverage thresholds per layer
41  run: |
42    USECASE=${{ steps.coverage.outputs.usecase }}
43    DOMAIN=${{ steps.coverage.outputs.domain }}
44    HANDLER=${{ steps.coverage.outputs.handler }}
45
46    FAIL=false
47
48    # Usecase: threshold 80% (most critical)
49    if awk "BEGIN {exit ($USECASE >= 80) ? 0 : 1}"; then
50      echo "Usecase coverage: $USECASE%"
51    else
52      echo "::error::Usecase coverage $USECASE% < 80% threshold"
53      FAIL=true
54    fi
55
56    # Domain: threshold 90% (pure business logic)
57    if awk "BEGIN {exit ($DOMAIN >= 90) ? 0 : 1}"; then
58      echo "Domain coverage: $DOMAIN%"
59    else
60      echo "::error::Domain coverage $DOMAIN% < 90% threshold"
61      FAIL=true
62    fi
63
64    # Handler: threshold 70% (harder to test)
65    if awk "BEGIN {exit ($HANDLER >= 70) ? 0 : 1}"; then
66      echo "Handler coverage: $HANDLER%"
67    else
68      echo "::warning::Handler coverage $HANDLER% below 70% target"
69      # Warning only, does not fail
70    fi
71
72    [ "$FAIL" == "true" ] && exit 1 || exit 0

Notice that the handler only triggers a warning, while usecase and domain trigger an error that blocks the pipeline. This is a deliberate decision: the riskiest layers are guarded most strictly.


07.3 Coverage Thresholds per Layer

Applying the same threshold to every layer is a common mistake. Each layer has a different testing difficulty and risk profile. The list below summarizes realistic targets along with the reasoning and examples that must be tested.

text
 1Layer-specific thresholds that are realistic for a production Go service:
 2
 3DOMAIN LAYER (internal/domain/):
 4  Target: 95%+
 5  Reason: Pure business logic, easy to test, zero dependencies
 6  Examples that must be tested:
 7    Order.CanBeCancelled()
 8    Order.ValidateCancellation()
 9    money.IDR.Add(), Subtract(), Compare()
10
11USECASE LAYER (internal/usecase/):
12  Target: 85%+
13  Reason: Core business logic, easy to test with mocks
14  What must be tested:
15    Happy path (AC1 of every feature)
16    Not-found scenario (repo returns nil, nil)
17    Business rule violations (AC2, AC3)
18    Infrastructure errors (DB error, Kafka error)
19    Concurrent scenarios (if any)
20
21REPOSITORY LAYER (internal/repository/):
22  Target: 60%+ (integration tests)
23  Reason: Needs real DB/Redis, hard to unit test
24  Approach: integration tests with Docker
25  What must be tested:
26    Happy path CRUD
27    Not-found behavior (return nil, nil?)
28    Transaction atomicity
29    Concurrent write (if critical)
30
31HANDLER LAYER (delivery/http/handler/):
32  Target: 70%+
33  Reason: Request parsing, response formatting
34  What must be tested:
35    Valid request → success response
36    Invalid request → 400 with error code
37    Business error → mapped HTTP code
38    Auth-required endpoint without a token → 401

The thing to remember: domain is guarded highest (95%) because it’s pure logic without dependencies, while repository is looser (60%) because it needs real infrastructure. Thresholds follow difficulty; they aren’t applied uniformly.


07.4 AI-Powered Coverage Gap Analysis

A coverage number doesn’t know what hasn’t been tested from the spec’s point of view. This is where AI comes in: comparing the spec, the implementation, and the tests to find the gaps. The script below assembles those three inputs into a single analysis prompt.

bash
 1#!/bin/bash
 2# scripts/coverage-gap-analysis.sh
 3# AI analysis of the gap between coverage and spec requirements
 4
 5set -euo pipefail
 6
 7FEATURE="${1}"
 8SPEC_FILE=".specify/features/${FEATURE}/spec.md"
 9USECASE_FILE="internal/usecase/${FEATURE}/$(ls internal/usecase/${FEATURE}/*.go 2>/dev/null | head -1)"
10TEST_FILE="internal/usecase/${FEATURE}/$(ls internal/usecase/${FEATURE}/*_test.go 2>/dev/null | head -1)"
11
12if [ ! -f "$SPEC_FILE" ]; then
13  echo "No spec found for feature: $FEATURE"
14  exit 0
15fi
16
17# Load files
18SPEC=$(cat "$SPEC_FILE")
19IMPL=$(cat "$USECASE_FILE" 2>/dev/null || echo "No implementation found")
20TESTS=$(cat "$TEST_FILE" 2>/dev/null || echo "No tests found")
21
22# Get coverage data for this file
23COVERAGE_DATA=$(go test -coverprofile=/tmp/cov.out ./internal/usecase/${FEATURE}/... 2>/dev/null
24  go tool cover -func=/tmp/cov.out | grep "$FEATURE" || echo "")
25
26PROMPT="Analyze test coverage gap for a Go feature.
27
28SPEC (Acceptance Criteria):
29$SPEC
30
31IMPLEMENTATION:
32$IMPL
33
34EXISTING TESTS:
35$TESTS
36
37COVERAGE DATA:
38$COVERAGE_DATA
39
40For each Acceptance Criteria (AC), determine:
411. Is there a test that covers this AC? (YES/NO/PARTIAL)
422. If NO or PARTIAL: what test is missing?
43
44Also identify:
45- Error paths that are NOT tested (return err cases from dependencies)
46- Edge cases mentioned in spec but not tested
47- Happy path scenarios
48
49Output format:
50AC_COVERAGE:
51  AC1: COVERED | PARTIAL | MISSING
52  [note if PARTIAL or MISSING]
53
54MISSING_TESTS:
55  - [test name]: [what it should test]
56
57PRIORITY: [HIGH|MEDIUM|LOW based on risk]
58COVERAGE_SCORE: [0-100]"
59
60./scripts/claude-call.sh "$PROMPT" 800

The output is far more actionable than “usecase coverage: 72%” — the AI names which AC is not yet covered and exactly which test needs to be added.


07.5 Workflow: Coverage Gap Comment on the PR

Gap analysis is most useful when it appears directly on the relevant PR. The workflow below detects the changed features, runs the gap analysis, and posts the report as a PR comment that is always kept up to date.

yaml
 1# .github/workflows/coverage-gap.yml
 2
 3- name: Run coverage analysis
 4  run: |
 5    go test -race -coverprofile=coverage.out ./...
 6    go tool cover -func=coverage.out > coverage-func.txt
 7
 8- name: AI Coverage Gap Analysis
 9  id: gap_analysis
10  if: github.event_name == 'pull_request'
11  env:
12    ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
13  run: |
14    # Find features changed in this PR
15    CHANGED_FEATURES=$(git diff origin/${{ github.base_ref }}...HEAD \
16      --name-only -- 'internal/usecase/**' \
17      | sed 's|internal/usecase/||' \
18      | cut -d'/' -f1 \
19      | sort -u)
20
21    GAP_REPORT=""
22    for feature in $CHANGED_FEATURES; do
23      if [ -d "internal/usecase/$feature" ]; then
24        ANALYSIS=$(./scripts/coverage-gap-analysis.sh "$feature" 2>/dev/null || echo "Analysis failed")
25        GAP_REPORT="$GAP_REPORT\n\n### Feature: $feature\n$ANALYSIS"
26      fi
27    done
28
29    echo "gap_report<<EOF" >> $GITHUB_OUTPUT
30    echo -e "$GAP_REPORT" >> $GITHUB_OUTPUT
31    echo "EOF" >> $GITHUB_OUTPUT
32
33- name: Post coverage report
34  if: github.event_name == 'pull_request'
35  uses: actions/github-script@v7
36  with:
37    script: |
38      const overall = '${{ steps.coverage.outputs.overall }}';
39      const usecase = '${{ steps.coverage.outputs.usecase }}';
40      const domain = '${{ steps.coverage.outputs.domain }}';
41      const gapReport = `${{ steps.gap_analysis.outputs.gap_report }}`;
42
43      const emoji = (val, threshold) =>
44        parseFloat(val) >= threshold ? '🟢' : '🟡';
45
46      const body = [
47        `## 📊 Test Coverage Report`,
48        ``,
49        `| Layer | Coverage | Target | Status |`,
50        `|-------|----------|--------|--------|`,
51        `| Overall | ${overall}% | 75% | ${emoji(overall, 75)} |`,
52        `| Usecase | ${usecase}% | 85% | ${emoji(usecase, 85)} |`,
53        `| Domain | ${domain}% | 90% | ${emoji(domain, 90)} |`,
54        ``,
55        `## 🔍 Coverage Gap Analysis`,
56        ``,
57        gapReport || '*No gap analysis available*',
58      ].join('\n');
59
60      // Find or create comment
61      const { data: comments } = await github.rest.issues.listComments({
62        owner: context.repo.owner,
63        repo: context.repo.repo,
64        issue_number: context.issue.number
65      });
66
67      const existing = comments.find(c => c.body.includes('📊 Test Coverage Report'));
68
69      if (existing) {
70        await github.rest.issues.updateComment({
71          owner: context.repo.owner,
72          repo: context.repo.repo,
73          comment_id: existing.id,
74          body
75        });
76      } else {
77        await github.rest.issues.createComment({
78          owner: context.repo.owner,
79          repo: context.repo.repo,
80          issue_number: context.issue.number,
81          body
82        });
83      }

The “find or update the existing comment” pattern matters so the PR isn’t flooded with new comments on every push — a single, always-fresh comment is far nicer for reviewers to read.


07.6 Actionable Coverage Reports

Go’s built-in HTML report is useful, but developers need a prioritized list: which functions have the worst coverage. The Go program below parses coverage.out and sorts the low-coverage functions so they can be worked on immediately.

go
 1// cmd/coverage-report/main.go
 2// Generate a coverage report developers can act on
 3
 4package main
 5
 6import (
 7    "bufio"
 8    "fmt"
 9    "os"
10    "sort"
11    "strconv"
12    "strings"
13)
14
15type FuncCoverage struct {
16    File     string
17    Function string
18    Coverage float64
19}
20
21func main() {
22    // Parse coverage.out file
23    f, _ := os.Open("coverage.out")
24    defer f.Close()
25
26    // Read and parse coverage data
27    var lowCoverage []FuncCoverage
28    scanner := bufio.NewScanner(f)
29
30    for scanner.Scan() {
31        line := scanner.Text()
32        parts := strings.Fields(line)
33        if len(parts) < 3 || parts[0] == "mode:" {
34            continue
35        }
36
37        // Parse: file:line.col,line.col numstmt count
38        // Simplified parsing
39        coverage := 0.0
40        if pct, err := strconv.ParseFloat(strings.TrimSuffix(parts[2], "%"), 64); err == nil {
41            coverage = pct
42        }
43
44        if coverage < 60 {
45            lowCoverage = append(lowCoverage, FuncCoverage{
46                File:     parts[0],
47                Coverage: coverage,
48            })
49        }
50    }
51
52    // Sort by coverage ascending (worst first)
53    sort.Slice(lowCoverage, func(i, j int) bool {
54        return lowCoverage[i].Coverage < lowCoverage[j].Coverage
55    })
56
57    fmt.Println("=== Functions with LOW Coverage (< 60%) ===")
58    fmt.Println("Priority: fix these first")
59    fmt.Println()
60
61    for i, fc := range lowCoverage {
62        if i >= 20 {
63            fmt.Printf("... and %d more\n", len(lowCoverage)-20)
64            break
65        }
66        fmt.Printf("%.1f%%  %s\n", fc.Coverage, fc.File)
67    }
68}

By sorting “worst first” and capping the output at the top 20 lines, this report gives the developer a clear worklist instead of a bewildering sea of numbers.


07.7 Spec-to-Test Mapping

Meaningful coverage means every AC in the spec has at least one test that references it. The bash script below extracts every AC ID from the spec and verifies that each one appears in the test file.

bash
 1#!/bin/bash
 2# scripts/spec-to-test-mapping.sh
 3# Validate that every AC has at least 1 test
 4
 5SPEC_FILE="${1}"
 6TEST_FILE="${2}"
 7
 8# Extract AC IDs from spec
 9ACS=$(grep -oP 'AC\d+' "$SPEC_FILE" | sort -u)
10
11echo "Checking test coverage for each AC..."
12echo ""
13
14MISSING=()
15for ac in $ACS; do
16  # Find a test that mentions this AC
17  # Convention: test function name or comment must mention the AC ID
18  if grep -q "$ac" "$TEST_FILE" 2>/dev/null; then
19    echo "$ac: covered"
20  else
21    echo "$ac: NO TEST FOUND"
22    MISSING+=("$ac")
23  fi
24done
25
26echo ""
27if [ "${#MISSING[@]}" -gt 0 ]; then
28  echo "Missing tests for: ${MISSING[*]}"
29  echo ""
30  echo "Convention: test function must reference AC in name or comment"
31  echo "Example:"
32  echo "  // AC2: Order is not PENDING → 409"
33  echo "  func (s *CancelOrderSuite) TestExecute_NonPendingOrder_ReturnNotCancellable() {"
34  exit 1
35fi
36
37echo "All ACs have test coverage"

The key to making this script work is a convention: the test name or its comment must name the AC ID. The example test suite below shows how each AC is annotated with a // AC1:, // AC2:, and so on.

go
 1// Example of a test that properly references its AC:
 2
 3type CancelOrderSuite struct {
 4    suite.Suite
 5    ctrl     *gomock.Controller
 6    mockRepo *mock.MockOrderRepository
 7    uc       *CancelOrderUseCase
 8}
 9
10// AC1: Order PENDING + < 30 minutes → cancel succeeds
11func (s *CancelOrderSuite) TestExecute_PendingOrderWithinWindow_ReturnNil() {
12    order := &domain.Order{
13        ID:        uuid.New(),
14        Status:    domain.StatusPending,
15        CreatedAt: time.Now().Add(-10 * time.Minute),
16    }
17    s.mockRepo.EXPECT().GetByIDAndUserID(gomock.Any(), gomock.Any(), gomock.Any()).
18        Return(order, nil)
19    s.mockRepo.EXPECT().CancelWithStockRestore(gomock.Any(), order.ID).
20        Return(nil)
21
22    err := s.uc.Execute(context.Background(), CancelOrderInput{
23        OrderID: order.ID,
24        UserID:  uuid.New(),
25    })
26
27    s.NoError(err)
28}
29
30// AC2: Order is not PENDING → ErrOrderNotCancellable
31func (s *CancelOrderSuite) TestExecute_NonPendingOrder_ReturnNotCancellable() {
32    order := &domain.Order{
33        ID:     uuid.New(),
34        Status: domain.StatusConfirmed, // NOT pending
35    }
36    s.mockRepo.EXPECT().GetByIDAndUserID(gomock.Any(), gomock.Any(), gomock.Any()).
37        Return(order, nil)
38
39    err := s.uc.Execute(context.Background(), CancelOrderInput{
40        OrderID: order.ID,
41        UserID:  uuid.New(),
42    })
43
44    var target *domain.OrderNotCancellableError
45    s.ErrorAs(err, &target)
46}
47
48// AC3: Cancel window passed → ErrCancelWindowExpired
49func (s *CancelOrderSuite) TestExecute_ExpiredCancelWindow_ReturnWindowExpired() {
50    order := &domain.Order{
51        ID:        uuid.New(),
52        Status:    domain.StatusPending,
53        CreatedAt: time.Now().Add(-31 * time.Minute), // > 30 minutes
54    }
55    s.mockRepo.EXPECT().GetByIDAndUserID(gomock.Any(), gomock.Any(), gomock.Any()).
56        Return(order, nil)
57
58    err := s.uc.Execute(context.Background(), CancelOrderInput{
59        OrderID: order.ID,
60        UserID:  uuid.New(),
61    })
62
63    s.ErrorIs(err, domain.ErrCancelWindowExpired)
64}
65
66// AC4: Order doesn't exist / not owned by the user → ErrOrderNotFound
67func (s *CancelOrderSuite) TestExecute_OrderNotFound_ReturnNotFound() {
68    s.mockRepo.EXPECT().GetByIDAndUserID(gomock.Any(), gomock.Any(), gomock.Any()).
69        Return(nil, nil) // nil, nil = not found
70
71    err := s.uc.Execute(context.Background(), CancelOrderInput{
72        OrderID: uuid.New(),
73        UserID:  uuid.New(),
74    })
75
76    s.ErrorIs(err, domain.ErrOrderNotFound)
77}
78
79// ERROR PATH: Repository error
80func (s *CancelOrderSuite) TestExecute_RepositoryError_ReturnWrappedError() {
81    dbErr := errors.New("connection refused")
82    s.mockRepo.EXPECT().GetByIDAndUserID(gomock.Any(), gomock.Any(), gomock.Any()).
83        Return(nil, dbErr)
84
85    err := s.uc.Execute(context.Background(), CancelOrderInput{})
86
87    s.Error(err)
88    s.ErrorIs(err, dbErr) // wrapped, but the original error is still traceable
89}

With every test explicitly referencing an AC, spec-to-test-mapping.sh can validate coverage semantically — not merely “the line executed,” but “the AC is verified.”


A single coverage snapshot isn’t enough; we need to see the trend over time. The CI step below saves the coverage number as an artifact with a 90-day retention so it can be aggregated later.

yaml
 1# Track coverage trend over time in CI
 2
 3- name: Save coverage to artifact
 4  run: |
 5    cat > /tmp/coverage-data.json << EOF
 6    {
 7      "date": "$(date -u +%Y-%m-%d)",
 8      "sha": "${{ github.sha }}",
 9      "branch": "${{ github.ref_name }}",
10      "overall": ${{ steps.coverage.outputs.overall }},
11      "usecase": ${{ steps.coverage.outputs.usecase }},
12      "domain": ${{ steps.coverage.outputs.domain }},
13      "handler": ${{ steps.coverage.outputs.handler }}
14    }
15    EOF
16
17- uses: actions/upload-artifact@v4
18  with:
19    name: coverage-${{ github.sha }}
20    path: /tmp/coverage-data.json
21    retention-days: 90
22
23# Scheduled job to aggregate and report the trend:
24# Every Friday: download all coverage artifacts from the sprint
25# Plot the trend: is coverage going up or down?

A declining trend is an early signal that new code is being written without enough tests — far more valuable than merely this week’s absolute number.


07.9 Coverage Badge in the README

Visible coverage encourages the behavior of protecting it. The script below determines the badge color from the overall number and automatically commits the badge update to the README on the main branch.

bash
 1# Auto-generate a coverage badge in the README
 2
 3- name: Update coverage badge
 4  if: github.ref == 'refs/heads/main'
 5  run: |
 6    COVERAGE=${{ steps.coverage.outputs.overall }}
 7
 8    # Determine badge color
 9    if [ "$COVERAGE" -ge 80 ]; then
10      COLOR="brightgreen"
11    elif [ "$COVERAGE" -ge 70 ]; then
12      COLOR="yellow"
13    else
14      COLOR="red"
15    fi
16
17    # Update README badge (using shields.io endpoint)
18    # Option 1: shields.io static badge via GitHub Gist
19    # Option 2: store in a GitHub Variable
20    echo "COVERAGE_BADGE=https://img.shields.io/badge/coverage-${COVERAGE}%25-${COLOR}" \
21      >> $GITHUB_ENV
22
23    # Update README
24    sed -i "s|!\[Coverage\]([^)]*)|![Coverage]($COVERAGE_BADGE)|g" README.md
25
26    git config user.name "github-actions[bot]"
27    git config user.email "github-actions[bot]@users.noreply.github.com"
28    git diff --quiet || (git add README.md && git commit -m "chore: update coverage badge [skip ci]" && git push)

Note the [skip ci] in the commit message so the badge update doesn’t trigger a new pipeline that would end in an endless loop.


07.10 How to Measure Meaningful Coverage

Beyond CI, we can enforce spec compliance directly inside the Go test suite. The helper below loads the ACs from the spec and ensures every AC is in the list the suite claims to cover.

go
 1// Helper to check coverage more semantically
 2
 3// testutil/coverage.go — used in test files
 4package testutil
 5
 6import (
 7    "testing"
 8)
 9
10// AssertSpecCompliance ensures every AC is tested
11// Use it in TestMain or suite setup
12func AssertSpecCompliance(t *testing.T, feature string, coveredACs []string) {
13    t.Helper()
14
15    // Load spec and compare with coveredACs
16    specACs := loadSpecACs(feature)
17
18    for _, ac := range specACs {
19        found := false
20        for _, covered := range coveredACs {
21            if covered == ac {
22                found = true
23                break
24            }
25        }
26        if !found {
27            t.Errorf("AC %s from spec not covered by any test", ac)
28        }
29    }
30}
31
32// In the test file:
33func TestCancelOrderSuite(t *testing.T) {
34    suite.Run(t, new(CancelOrderSuite))
35
36    // Assert all ACs are covered
37    testutil.AssertSpecCompliance(t, "cancel-order",
38        []string{"AC1", "AC2", "AC3", "AC4"})
39}

With this pattern, forgetting to add a test for a new AC immediately makes go test fail — enforcement moves from CI into the test suite itself.


07.11 Integration Test Coverage

The repository layer needs a real database, so its coverage is measured through separate integration tests. The job below spins up Postgres and Redis as services, runs the migrations, then measures repository coverage with the integration build tag.

yaml
 1# Run integration tests separately with a real database
 2
 3integration-coverage:
 4  name: Integration Test Coverage
 5  runs-on: ubuntu-latest
 6  if: github.event_name == 'pull_request'
 7
 8  services:
 9    postgres:
10      image: postgres:15
11      env:
12        POSTGRES_DB: santekno_test
13        POSTGRES_USER: testuser
14        POSTGRES_PASSWORD: testpass
15      ports:
16        - 5432:5432
17      options: >-
18        --health-cmd pg_isready
19        --health-interval 10s
20        --health-timeout 5s
21        --health-retries 5
22
23    redis:
24      image: redis:7
25      ports:
26        - 6379:6379
27
28  steps:
29    - uses: actions/checkout@v4
30    - uses: actions/setup-go@v5
31      with:
32        go-version: '1.22'
33        cache: true
34
35    - name: Run migrations
36      env:
37        DATABASE_URL: postgres://testuser:testpass@localhost:5432/santekno_test
38      run: |
39        go run ./cmd/migrate/main.go up
40
41    - name: Run integration tests
42      env:
43        DATABASE_URL: postgres://testuser:testpass@localhost:5432/santekno_test
44        REDIS_URL: localhost:6379
45      run: |
46        go test -tags=integration \
47          -coverprofile=integration-coverage.out \
48          -race \
49          ./internal/repository/... \
50          -timeout 120s
51
52    - name: Integration coverage report
53      run: |
54        REPO_COVERAGE=$(go tool cover -func=integration-coverage.out | \
55          grep "internal/repository" | \
56          awk '{sum+=$3; n++} END {printf "%.1f", sum/n}')
57
58        echo "Repository layer coverage: $REPO_COVERAGE%"
59
60        if awk "BEGIN {exit ($REPO_COVERAGE >= 60) ? 0 : 1}"; then
61          echo "Repository integration coverage: $REPO_COVERAGE%"
62        else
63          echo "::warning::Repository integration coverage $REPO_COVERAGE% < 60%"
64        fi

Because integration tests use the integration build tag, they don’t run during a normal go test — the unit test pipeline stays fast while the repository is still measured in a separate job.


07.12 AI-Generated Tests for Missing Coverage

When gap analysis finds a function with low coverage, AI can draft its test. The script below scans functions below 60% coverage, pulls their source, and asks the AI to generate a test that’s ready to review.

bash
 1#!/bin/bash
 2# scripts/generate-missing-tests.sh
 3# AI generates tests for functions with coverage < 60%
 4
 5set -euo pipefail
 6
 7go test -coverprofile=/tmp/cov.out ./... 2>/dev/null
 8go tool cover -func=/tmp/cov.out | \
 9  awk '$3 != "100.0%" && $3+0 < 60.0 {print $1, $2, $3}' | \
10  grep -v "_test.go" | \
11  while read file func coverage; do
12
13    # Skip generated files
14    case "$file" in *mock*|*pb.go*|*gen.go*) continue ;; esac
15
16    echo "Generating test for: $func in $file ($coverage)"
17
18    # Get function source
19    FUNC_SOURCE=$(grep -A 30 "func ${func}" "$file" 2>/dev/null | head -30)
20
21    PROMPT="Generate a comprehensive Go test for this function.
22Current coverage: $coverage
23
24Function:
25$FUNC_SOURCE
26
27Requirements:
28- Use testify/suite if this is a method on a struct
29- Cover happy path AND error paths
30- Use gomock for dependencies
31- Follow naming: TestMethodName_Scenario_ExpectedResult
32
33Output: complete test function(s) ready to paste into test file"
34
35    echo "--- Suggested test ---"
36    ./scripts/claude-call.sh "$PROMPT" 600
37    echo "--- End suggestion ---"
38    echo ""
39  done

The generated test is a starting point, not a finished result: the developer still must review it, adjust it, and run it before committing.


07.13 Coverage Tips & Gotchas

💡 Tip 1: Per-layer thresholds are more useful than an overall one. An overall 80% can mislead — domain 50% + handler 95% = an overall 75% that looks OK while domain is badly under-tested.

💡 Tip 2: Coverage of generated code. Generated files (mocks, protobuf) get randomly very high or very low coverage. Exclude them from the coverage calculation.

💡 Tip 3: A test-naming convention that references the AC. TestExecute_AC2_NonPendingOrder or a // AC2: comment makes spec-to-test mapping easy.

💡 Tip 4: Integration vs unit coverage. Track them separately. Unit test coverage target: 85%+. Integration test coverage target: 60%+ (harder because it needs infra).

⚠️ Gotcha 1: -covermode=atomic vs set. For the race detector (-race), use -covermode=atomic. For a regular test, -covermode=set is enough.

⚠️ Gotcha 2: Coverage doesn’t mean correctness. A line that’s 80% covered may have no assertion. Pair coverage with a test-quality review.

⚠️ Gotcha 3: 100% is not the target. 100% coverage for every file is overkill and unrealistic. Focus on the usecase and domain layers that hold business logic.


07.14 Benchmark: Before and After Coverage Enforcement

Real data is more convincing than theory. The summary below shows the change in coverage numbers and post-deploy bugs for one team after three months of applying enforcement.

text
 1Santekno team, 8 developers, after 3 months of coverage enforcement:
 2
 3BEFORE (baseline sprint 1):
 4  Overall: 74%
 5  Usecase: 65%   ← many error paths untested
 6  Domain: 80%
 7  Post-deploy bugs per sprint: 4-6
 8
 9AFTER (sprint 12):
10  Overall: 83%
11  Usecase: 88%   ← up 23 points
12  Domain: 96%
13  Post-deploy bugs per sprint: 1-2 (70% reduction)
14
15Key driver: usecase error-path coverage rose after AI gap analysis
16showed which tests were missing.

The 70% bug reduction isn’t because the overall number rose 9 points; it’s because the usecase error paths finally got tested — exactly the area the AI gap analysis found.


07.15 Coverage for a Monorepo

In a monorepo, each service can have its own threshold according to its criticality. The step below iterates over each service, raises the threshold for critical services, and flags the ones that fail.

yaml
 1# Coverage per service in a monorepo
 2- name: Coverage per service
 3  run: |
 4    for service in services/*/; do
 5      SERVICE_NAME=$(basename "$service")
 6      THRESHOLD=75
 7      # Order service: higher threshold (critical path)
 8      [ "$SERVICE_NAME" == "order-service" ] && THRESHOLD=85
 9
10      go test -coverprofile="/tmp/cov-${SERVICE_NAME}.out" "./${service}/..."
11      COVERAGE=$(go tool cover -func="/tmp/cov-${SERVICE_NAME}.out" | \
12        grep "total:" | awk '{print $3}' | tr -d '%')
13
14      echo "$SERVICE_NAME: $COVERAGE% (threshold: $THRESHOLD%)"
15      awk "BEGIN {exit ($COVERAGE >= $THRESHOLD) ? 0 : 1}" || echo "FAIL"
16    done

Per-service thresholds make enforcement proportional to risk: the order-service that touches money is guarded more tightly than a supporting service.


07.16 Coverage Enforcement in Pre-commit

The fastest feedback comes from local, not CI. The pre-push hook below runs usecase coverage and rejects the push if it’s below 80% — preventing a PR that’s already guaranteed to fail in CI.

bash
 1# .git/hooks/pre-push
 2# Quick coverage check before push
 3
 4go test -coverprofile=/tmp/cov.out ./internal/usecase/...
 5USECASE_COV=$(go tool cover -func=/tmp/cov.out | grep total | awk '{print $3}' | tr -d '%')
 6
 7if awk "BEGIN {exit ($USECASE_COV >= 80) ? 0 : 1}"; then
 8  echo "Usecase coverage: $USECASE_COV%"
 9else
10  echo "Usecase coverage $USECASE_COV% < 80%"
11  echo "   Add tests before pushing"
12  exit 1
13fi

With a gate at pre-push, the developer learns about a coverage problem in seconds on their own machine — not after waiting several minutes for the pipeline.


07.17 Mutation Testing for More Meaningful Coverage

High line coverage doesn’t guarantee that the tests actually verify behavior. Mutation testing measures how well the tests detect injected bugs. The command below runs go-mutesting on the domain layer.

bash
1# go-mutesting: verify that the tests actually detect bugs
2go install github.com/zimmski/go-mutesting/...@latest
3
4# Run mutation testing (slow, use selectively)
5go-mutesting ./internal/domain/order/...
6
7# Output: mutation score
8# > 80% = tests are strong enough to detect mutations
9# < 60% = tests exist but don't verify behavior

A low mutation score is a danger sign: the tests exist and the lines are covered, but no assertion actually verifies the behavior. Because it’s slow, run it selectively on the most critical layer.


07.18 Coverage as an Onboarding Tool

The visual coverage report is also a great learning tool for new developers. The command below produces an HTML report where the red areas mark code that isn’t tested yet.

bash
1# For new developers: identify areas to learn
2go test -coverprofile=cov.out ./...
3go tool cover -html=cov.out -o coverage.html
4
5# Open the coverage report: red areas = untested code
6# Good for:
7# 1. Identifying gaps that need fixing
8# 2. New developers learning the codebase from the most-tested areas

Red areas show the gaps that need filling, while also guiding new developers to start reading from the parts of the codebase that are most thoroughly tested.


07.19 Summary

Meaningful test coverage enforcement rests on a few core principles:

Layer-based thresholds: domain 90%, usecase 85%, handler 70% — not a single, misleading overall number.

Spec-to-test mapping: every AC in the spec must have a test that references it, so coverage measures behavior, not merely lines.

AI gap analysis: identifying which tests are missing based on the spec, not just reporting a percentage.

Integration vs unit: tracked separately with different thresholds because their difficulty differs.

When these three pillars work together, a meaningful 75% is worth more than an empty 90%.


07.20 Transition to the Next Article

Coverage enforcement ensures behavior is verified. But there’s another dimension of quality that tests don’t capture: does the code’s structure still respect the architectural boundaries? Tests can be 90% green while a handler quietly calls the repository directly.

In the next article we step up a level: enforcing Clean Architecture automatically in the pipeline, from depguard to a custom linter and AI architecture review.

Related Articles

💬 Comments