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.
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.
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 meaningfulThe 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.
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 0Notice 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.
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 → 401The 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.
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" 800The 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.
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.
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.
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.
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.”
07.8 Coverage Trending
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.
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.
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\]([^)]*)||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.
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.
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 fiBecause 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.
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 doneThe 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.
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.
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 donePer-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.
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
13fiWith 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.
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 behaviorA 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.
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 areasRed 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.