Test Coverage Enforcement Golang CI: Coverage Gate, Spec-Based Testing, AI Gap Analysis
Setup test coverage enforcement di CI untuk Go. Coverage gate berdasarkan spesifikasi, AI-powered gap analysis, dan coverage trending yang membantu tim maintain kualitas test.
Test Coverage Enforcement dari Spesifikasi
Test coverage enforcement golang CI sering direduksi menjadi satu angka keramat: 80%. Padahal angka itu bisa sangat menipu. Test yang hanya “cover line” tanpa menyentuh edge case dan error path bisa saja mencapai 80% sementara production tetap penuh bug. Di artikel ini kita bangun coverage enforcement yang lebih cerdas: bukan sekadar angka, tapi coverage yang bermakna terhadap spesifikasi.
Yang akan kita bahas bukan cuma “cara mengukur coverage”, tapi bagaimana menjadikan setiap Acceptance Criteria di spec sebagai unit yang wajib punya test, lalu menegakkannya di CI secara otomatis.
07.1 Coverage yang Bermakna vs Coverage Angka
Sebelum menyetel threshold apa pun, kita perlu paham bedanya coverage sebagai angka dan coverage sebagai jaminan perilaku. Ilustrasi berikut membandingkan keduanya secara langsung.
1COVERAGE ANGKA (yang umum):
2 go test -coverprofile=cov.out ./...
3 Coverage: 82% ← angka yang menipu
4
5 Apa yang tidak terlihat:
6 - Happy path: 95% covered
7 - Error paths: 30% covered ← kebanyakan bug di sini
8 - Edge cases: 10% covered
9 - Spec acceptance criteria: 60% covered
10
11 Developer cukup test happy path → angka 80%+ tapi bug banyak
12
13COVERAGE BERMAKNA:
14 Setiap Acceptance Criteria dari spec = minimal 1 test
15 Error paths = mandatory test
16 Edge cases yang di-spec = mandatory test
17
18 Cara check: specify audit verifikasi coverage per AC
19
20 Developer WAJIB test semua AC → coverage yang meaningfulPesan intinya: angka 82% tanpa konteks tidak memberi tahu apakah error path sudah di-test. Yang kita kejar bukan angka, tapi keyakinan bahwa perilaku yang di-spec benar-benar diverifikasi.
07.2 Coverage Gate di CI: Implementasi Dasar
Langkah pertama enforcement adalah menjalankan test dengan coverage lalu memecah angka per layer. Workflow GitHub Actions berikut menjalankan test, mengekstrak coverage per layer, dan menegakkan threshold yang berbeda-beda.
1# .github/workflows/coverage.yml — bagian dari 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 (lebih 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, tidak fail
70 fi
71
72 [ "$FAIL" == "true" ] && exit 1 || exit 0Perhatikan bahwa handler hanya memicu warning, sedangkan usecase dan domain memicu error yang mem-block pipeline. Ini keputusan sadar: layer yang paling berisiko dijaga paling ketat.
07.3 Coverage Thresholds per Layer
Threshold yang sama untuk semua layer adalah kesalahan umum. Setiap layer punya tingkat kesulitan test dan profil risiko yang berbeda. Daftar berikut merangkum target realistis beserta alasan dan contoh yang wajib di-test.
1Layer-specific thresholds yang realistic untuk production Go service:
2
3DOMAIN LAYER (internal/domain/):
4 Target: 95%+
5 Reason: Pure business logic, mudah di-test, zero dependency
6 Contoh yang wajib test:
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, mudah di-test dengan mock
14 Yang wajib di-test:
15 ✅ Happy path (AC1 dari setiap feature)
16 ✅ Not-found scenario (repo return nil, nil)
17 ✅ Business rule violations (AC2, AC3)
18 ✅ Infrastructure errors (DB error, Kafka error)
19 ⚠️ Concurrent scenarios (jika ada)
20
21REPOSITORY LAYER (internal/repository/):
22 Target: 60%+ (integration tests)
23 Reason: Butuh real DB/Redis, sulit di-unit test
24 Approach: integration tests dengan Docker
25 Yang wajib di-test:
26 ✅ Happy path CRUD
27 ✅ Not-found behavior (return nil, nil?)
28 ✅ Transaction atomicity
29 ⚠️ Concurrent write (jika critical)
30
31HANDLER LAYER (delivery/http/handler/):
32 Target: 70%+
33 Reason: Request parsing, response formatting
34 Yang wajib di-test:
35 ✅ Valid request → success response
36 ✅ Invalid request → 400 dengan error code
37 ✅ Business error → mapped HTTP code
38 ✅ Auth required endpoint tanpa token → 401Yang perlu diingat: domain dijaga paling tinggi (95%) karena murni logika tanpa dependency, sedangkan repository lebih longgar (60%) karena butuh infrastruktur nyata. Threshold mengikuti kesulitan, bukan dipukul rata.
07.4 AI-Powered Coverage Gap Analysis
Angka coverage tidak tahu apa yang belum di-test dari sudut pandang spec. Di sinilah AI berperan: membandingkan spec, implementasi, dan test untuk menemukan gap. Script berikut merakit ketiga input itu menjadi satu prompt analisis.
1#!/bin/bash
2# scripts/coverage-gap-analysis.sh
3# AI analisis gap antara coverage dan 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 untuk file ini
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" 800Output-nya jauh lebih actionable daripada “usecase coverage: 72%” — AI menyebut AC mana yang belum ter-cover dan test spesifik apa yang perlu ditambahkan.
07.5 Workflow: Coverage Gap Comment di PR
Analisis gap paling berguna jika muncul langsung di PR yang bersangkutan. Workflow berikut mendeteksi feature yang berubah, menjalankan gap analysis, lalu mem-posting laporan sebagai komentar PR yang selalu di-update.
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 yang berubah di PR ini
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 }Pola “find or update existing comment” penting agar PR tidak dibanjiri komentar baru di setiap push — satu komentar yang selalu segar jauh lebih enak dibaca reviewer.
07.6 Coverage Report yang Actionable
Report HTML bawaan Go berguna, tapi developer butuh daftar prioritas: fungsi mana yang paling parah coverage-nya. Program Go berikut mem-parse coverage.out dan mengurutkan fungsi dengan coverage rendah agar bisa langsung dikerjakan.
1// cmd/coverage-report/main.go
2// Generate coverage report yang developer bisa 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 // Baca dan 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}Dengan mengurutkan “worst first” dan membatasi 20 baris teratas, report ini memberi developer daftar kerja yang jelas alih-alih lautan angka yang membingungkan.
07.7 Spec-to-Test Mapping
Coverage bermakna berarti setiap AC di spec punya minimal satu test yang mereferensikannya. Script bash berikut mengekstrak semua ID AC dari spec dan memverifikasi bahwa masing-masing muncul di file test.
1#!/bin/bash
2# scripts/spec-to-test-mapping.sh
3# Validate bahwa setiap AC punya minimal 1 test
4
5SPEC_FILE="${1}"
6TEST_FILE="${2}"
7
8# Extract AC IDs dari 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 # Cari test yang mention AC ini
17 # Convention: test function name atau comment harus mention 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 bukan PENDING → 409"
33 echo " func (s *CancelOrderSuite) TestExecute_NonPendingOrder_ReturnNotCancellable() {"
34 exit 1
35fi
36
37echo "All ACs have test coverage ✅"Kunci agar script ini bekerja adalah konvensi: nama test atau komennya harus menyebut ID AC. Contoh test suite berikut memperlihatkan bagaimana setiap AC di-anotasi dengan komentar // AC1:, // AC2:, dan seterusnya.
1// Contoh test yang properly reference 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 menit → cancel berhasil
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 bukan 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 lewat → 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 tidak ada / bukan milik 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, tapi original error masih traceable
89}Dengan setiap test mereferensikan AC secara eksplisit, spec-to-test-mapping.sh bisa memvalidasi coverage secara semantik — bukan sekadar “line dieksekusi”, tapi “AC diverifikasi”.
07.8 Coverage Trending
Satu snapshot coverage tidak cukup; kita perlu melihat trennya dari waktu ke waktu. Langkah CI berikut menyimpan angka coverage sebagai artifact ber-retensi 90 hari sehingga bisa di-agregasi belakangan.
1# Track coverage trend overtime di 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 untuk aggregate dan report trend:
24# Setiap Jumat: download semua coverage artifacts dari sprint
25# Plot trending: apakah coverage naik atau turun?Tren yang menurun adalah sinyal dini bahwa kode baru ditulis tanpa test yang cukup — jauh lebih berharga daripada sekadar angka absolut minggu ini.
07.9 Coverage Badge di README
Coverage yang terlihat mendorong perilaku menjaganya. Script berikut menentukan warna badge berdasarkan angka overall dan meng-commit pembaruan badge ke README secara otomatis di branch main.
1# Auto-generate coverage badge di 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 di 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)Perhatikan [skip ci] di commit message agar update badge tidak memicu pipeline baru yang berujung loop tak berujung.
07.10 Cara Mengukur Coverage yang Meaningful
Selain di CI, kita bisa menegakkan spec compliance langsung di dalam test suite Go. Helper berikut mem-load AC dari spec dan memastikan setiap AC ada di daftar yang diklaim ter-cover oleh suite.
1// Helper untuk check coverage yang lebih semantic
2
3// testutil/coverage.go — digunakan di test files
4package testutil
5
6import (
7 "testing"
8)
9
10// AssertSpecCompliance memastikan setiap AC di-test
11// Gunakan di TestMain atau suite setup
12func AssertSpecCompliance(t *testing.T, feature string, coveredACs []string) {
13 t.Helper()
14
15 // Load spec dan compare dengan 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// Di test file:
33func TestCancelOrderSuite(t *testing.T) {
34 suite.Run(t, new(CancelOrderSuite))
35
36 // Assert semua AC di-cover
37 testutil.AssertSpecCompliance(t, "cancel-order",
38 []string{"AC1", "AC2", "AC3", "AC4"})
39}Dengan pola ini, lupa menambahkan test untuk AC baru akan langsung membuat go test gagal — enforcement pindah dari CI ke test suite itu sendiri.
07.11 Integration Test Coverage
Repository layer butuh database nyata, jadi coverage-nya diukur lewat integration test terpisah. Job berikut menyalakan Postgres dan Redis sebagai service, menjalankan migrasi, lalu mengukur coverage repository dengan build tag integration.
1# Run integration tests terpisah dengan database nyata
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 fiKarena integration test memakai build tag integration, ia tidak ikut jalan saat go test biasa — pipeline unit test tetap cepat, sementara repository tetap terukur di job terpisah.
07.12 AI-Generated Test untuk Missing Coverage
Ketika gap analysis menemukan fungsi dengan coverage rendah, AI bisa membuat draft test-nya. Script berikut memindai fungsi di bawah 60% coverage, mengambil source-nya, dan meminta AI menghasilkan test yang siap ditinjau.
1#!/bin/bash
2# scripts/generate-missing-tests.sh
3# AI generate test untuk functions yang 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 doneTest yang dihasilkan adalah titik awal, bukan hasil akhir: developer tetap wajib meninjau, menyesuaikan, dan menjalankannya sebelum commit.
07.13 Tips & Gotchas Coverage
💡 Tip 1: Per-layer thresholds lebih useful dari overall. Overall 80% bisa menipu — domain 50% + handler 95% = overall 75% yang terlihat OK padahal domain sangat under-tested.
💡 Tip 2: Coverage-generated code. Generated files (mock, protobuf) sangat high coverage atau low coverage secara random. Exclude dari coverage calculation.
💡 Tip 3: Test naming convention yang reference AC. TestExecute_AC2_NonPendingOrder atau comment // AC2: membuat spec-to-test mapping mudah dilakukan.
💡 Tip 4: Integration vs unit coverage. Track terpisah. Unit test coverage target: 85%+. Integration test coverage target: 60%+ (lebih sulit karena butuh infra).
⚠️ Gotcha 1: -covermode=atomic vs set. Untuk race detector (-race), gunakan -covermode=atomic. Untuk regular test, -covermode=set sudah cukup.
⚠️ Gotcha 2: Coverage tidak berarti correctness. Line 80% covered bisa jadi tidak ada assertion. Pair coverage dengan test quality review.
⚠️ Gotcha 3: 100% bukan target. 100% coverage untuk setiap file adalah overkill dan tidak realistic. Focus ke usecase dan domain layer yang punya business logic.
07.14 Benchmark: Sebelum dan Sesudah Coverage Enforcement
Data nyata lebih meyakinkan daripada teori. Ringkasan berikut menunjukkan perubahan angka coverage dan post-deploy bug pada satu tim setelah tiga bulan menerapkan enforcement.
1Tim Santekno, 8 developer, setelah 3 bulan coverage enforcement:
2
3SEBELUM (baseline sprint 1):
4 Overall: 74%
5 Usecase: 65% ← banyak error path tidak di-test
6 Domain: 80%
7 Post-deploy bugs per sprint: 4-6
8
9SESUDAH (sprint 12):
10 Overall: 83%
11 Usecase: 88% ← naik 23 poin
12 Domain: 96%
13 Post-deploy bugs per sprint: 1-2 (70% reduction)
14
15Key driver: usecase error path coverage naik setelah AI gap analysis
16menunjukkan test apa yang missing.Penurunan bug 70% bukan karena angka overall naik 9 poin, melainkan karena error path di usecase akhirnya di-test — persis area yang ditemukan oleh AI gap analysis.
07.15 Coverage untuk Monorepo
Di monorepo, setiap service bisa punya threshold sendiri sesuai kritikalitasnya. Langkah berikut mengiterasi setiap service, menaikkan threshold untuk service kritis, dan menandai yang gagal.
1# Coverage per service di 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: threshold lebih tinggi (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 doneThreshold per service membuat enforcement proporsional dengan risiko: order-service yang menyentuh uang dijaga lebih ketat daripada service pendukung.
07.16 Coverage Enforcement di Pre-commit
Feedback tercepat datang dari local, bukan CI. Hook pre-push berikut menjalankan coverage usecase dan menolak push jika di bawah 80% — mencegah PR yang sudah pasti gagal di CI.
1# .git/hooks/pre-push
2# Quick coverage check sebelum 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
13fiDengan gate di pre-push, developer tahu masalah coverage dalam hitungan detik di mesin sendiri — bukan setelah menunggu pipeline beberapa menit.
07.17 Mutation Testing untuk Coverage yang Lebih Bermakna
Line coverage tinggi tidak menjamin test benar-benar memverifikasi perilaku. Mutation testing menguji seberapa kuat test mendeteksi bug yang disuntikkan. Perintah berikut menjalankan go-mutesting pada domain layer.
1# go-mutesting: verifikasi bahwa tests benar-benar mendeteksi bug
2go install github.com/zimmski/go-mutesting/...@latest
3
4# Run mutation testing (lambat, gunakan secara selektif)
5go-mutesting ./internal/domain/order/...
6
7# Output: mutation score
8# > 80% = tests cukup kuat mendeteksi mutations
9# < 60% = tests ada tapi tidak verify behaviorMutation score rendah adalah tanda bahaya: test-nya ada dan line ter-cover, tapi tidak ada assertion yang benar-benar memverifikasi perilaku. Karena lambat, jalankan secara selektif di layer paling kritis.
07.18 Coverage sebagai Onboarding Tool
Coverage report visual juga alat belajar yang bagus untuk developer baru. Perintah berikut menghasilkan report HTML di mana area merah menandai kode yang belum di-test.
1# Untuk developer baru: identify area yang perlu dipelajari
2go test -coverprofile=cov.out ./...
3go tool cover -html=cov.out -o coverage.html
4
5# Open coverage report: area merah = kode yang belum di-test
6# Cocok untuk:
7# 1. Identify gap yang perlu di-fix
8# 2. Developer baru belajar codebase dari area yang paling testedArea merah menunjukkan gap yang perlu diisi, sekaligus memandu developer baru mulai membaca dari bagian codebase yang paling tuntas di-test.
07.19 Ringkasan
Test coverage enforcement yang meaningful bertumpu pada beberapa prinsip inti:
Layer-based thresholds: domain 90%, usecase 85%, handler 70% — bukan satu angka overall yang menipu.
Spec-to-test mapping: setiap AC di spec harus punya test yang mereferensikannya, sehingga coverage mengukur perilaku, bukan sekadar line.
AI gap analysis: mengidentifikasi test apa yang missing berdasarkan spec, bukan hanya melaporkan berapa persen.
Integration vs unit: di-track terpisah dengan threshold yang berbeda karena tingkat kesulitannya berbeda.
Ketika ketiga pilar ini bekerja bersama, skor 75% yang bermakna lebih berharga daripada 90% yang kosong.
07.20 Transisi ke Artikel Berikutnya
Coverage enforcement menjaga bahwa perilaku diverifikasi. Tapi ada dimensi kualitas lain yang tidak tertangkap oleh test: apakah struktur kode masih menghormati batas-batas arsitektur? Test bisa 90% hijau sementara handler diam-diam memanggil repository langsung.
Di artikel berikutnya kita naik satu tingkat: menegakkan Clean Architecture secara otomatis di pipeline, dari depguard sampai custom linter dan AI architecture review.