Skip to content
Santekno.com | Level Up Your Engineering Skills
ID
📖 0%
31 Jul 2026 · 22 mnt baca ·Artikel 13 / 208
Go

Unit Test dari Spec: TDD yang Dipercepat AI untuk Golang

Cara menulis unit test Golang dari feature specification dengan pendekatan TDD yang dipercepat Claude Code. Dari acceptance criteria ke test suite yang comprehensive dengan testify dan gomock.

IH
Ihsan Arif
Penulis di Santekno · Backend Engineer

Unit Test dari Spec: TDD yang Dipercepat AI

Salah satu keuntungan paling konkret dari SDD adalah ini: begitu kamu terbiasa menurunkan unit test dari spec di Golang dengan disiplin TDD, test cases practically menulis dirinya sendiri. Setiap AC menjadi satu test function. Setiap EC menjadi satu atau lebih test scenario. Setiap NFR menjadi satu benchmark atau integration test.

Di artikel ini, kita pelajari cara mengubah spec menjadi test suite yang comprehensive — menggunakan Claude Code untuk mempercepat proses, tapi memastikan setiap test benar-benar memverifikasi behavior yang ada di spec, bukan sekadar membuktikan kode tidak crash.


13.1 Dari Acceptance Criteria ke Test Function: Mapping Langsung

Titik awal yang paling natural adalah memetakan setiap AC menjadi satu test function. Cuplikan spec berikut memperlihatkan satu AC dengan format yang rapi — lengkap dengan error code dan message yang diharapkan.

markdown
1# Spec: Cancel Order
2
3AC9: Order status bukan PENDING → 409 Conflict
4     error_code: ORDER_NOT_CANCELLABLE
5     message: "order cannot be cancelled: current status is {status}"

Format AC yang eksplisit seperti ini memberi kita semua bahan untuk test: nama kondisi, hasil yang diharapkan, dan detail error sudah tersedia tanpa perlu menebak.

AC di atas bisa diterjemahkan langsung menjadi satu test function dengan struktur Given-When-Then seperti berikut.

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

Perhatikan pola Given-When-Then yang konsisten. Ini bukan sekadar gaya penulisan — struktur inilah yang membuat test mudah dipahami dan cepat di-debug ketika suatu saat gagal.


13.2 Prompt untuk Generate Test Suite dari Spec

Boilerplate test suite bisa dipercepat dengan prompt yang terstruktur. Prompt berikut menginstruksikan Claude Code untuk menurunkan setiap AC dan EC menjadi test function dengan aturan yang eksplisit.

text
 1Generate comprehensive test suite untuk CancelOrderUseCase berdasarkan
 2spec berikut.
 3
 4Untuk setiap AC dan EC di spec:
 51. Buat satu test function dengan nama format:
 6   Test{Layer}_{Condition}_{ExpectedBehavior}
 7   Contoh: TestCancelOrder_ConfirmedOrder_ReturnsNotCancellableError
 8
 92. Setiap test harus menggunakan struktur Given-When-Then
10   (komentar eksplisit // Given, // When, // Then)
11
123. Mock setup harus eksplisit tentang:
13   - Apa yang di-return mock
14   - Berapa kali mock dipanggil (gomock.Times, gomock.Once, dll)
15
164. Assertions harus verify:
17   - Return value (error type yang benar)
18   - Side effects (apakah repo method dipanggil? apakah publisher dipanggil?)
19   - Async behavior (goroutine yang selesai dalam timeout)
20
215. Gunakan testify/suite dan gomock
22
23Spec: [paste specs/order/cancel-order.md]
24Interface yang sudah ada: [paste interfaces]

Kunci prompt ini ada pada aturan yang spesifik — format nama, struktur Given-When-Then, dan kejelasan mock setup — sehingga output-nya konsisten dan bisa langsung dipakai, bukan asal jadi.


13.3 Test Suite Structure yang Direkomendasikan

Output dari prompt tadi idealnya mengikuti struktur test suite yang rapi. Berikut kerangka lengkap yang kita pakai di Santekno Shop, mulai dari setup suite hingga helper untuk operasi async.

go
  1// internal/usecase/order/cancel_order_test.go
  2package order_test
  3
  4import (
  5    "context"
  6    "errors"
  7    "testing"
  8    "time"
  9
 10    "github.com/golang/mock/gomock"
 11    "github.com/google/uuid"
 12    "github.com/stretchr/testify/suite"
 13
 14    domain "github.com/santekno/shop/internal/domain/order"
 15    mockdomain "github.com/santekno/shop/internal/domain/order/mock"
 16    orderuc "github.com/santekno/shop/internal/usecase/order"
 17)
 18
 19// ══════════════════════════════════════════════
 20// Test Suite Setup
 21// ══════════════════════════════════════════════
 22
 23type CancelOrderSuite struct {
 24    suite.Suite
 25
 26    ctrl      *gomock.Controller
 27    repo      *mockdomain.MockOrderRepository
 28    publisher *mockdomain.MockOrderEventPublisher
 29    uc        *orderuc.CancelOrderUseCase
 30    clock     *mockClock
 31
 32    // Shared test fixtures
 33    orderID uuid.UUID
 34    userID  uuid.UUID
 35    ctx     context.Context
 36}
 37
 38func (s *CancelOrderSuite) SetupTest() {
 39    s.ctrl = gomock.NewController(s.T())
 40    s.repo = mockdomain.NewMockOrderRepository(s.ctrl)
 41    s.publisher = mockdomain.NewMockOrderEventPublisher(s.ctrl)
 42    s.clock = &mockClock{now: time.Date(2025, 7, 15, 10, 0, 0, 0, time.UTC)}
 43
 44    base := orderuc.NewCancelOrderUseCase(s.repo, s.publisher)
 45    s.uc = base.WithClock(s.clock)
 46
 47    s.orderID = uuid.New()
 48    s.userID = uuid.New()
 49    s.ctx = context.Background()
 50}
 51
 52func (s *CancelOrderSuite) TearDownTest() {
 53    s.ctrl.Finish()
 54}
 55
 56func TestCancelOrderSuite(t *testing.T) {
 57    suite.Run(t, new(CancelOrderSuite))
 58}
 59
 60// ══════════════════════════════════════════════
 61// Test Helpers
 62// ══════════════════════════════════════════════
 63
 64func (s *CancelOrderSuite) newPendingOrder(ageMinutes int) *domain.Order {
 65    return &domain.Order{
 66        ID:        s.orderID,
 67        UserID:    s.userID,
 68        Status:    domain.StatusPending,
 69        CreatedAt: s.clock.Now().Add(-time.Duration(ageMinutes) * time.Minute),
 70        Items: []domain.OrderItem{
 71            {ProductID: uuid.New(), Quantity: 2, PriceCents: 150000},
 72        },
 73    }
 74}
 75
 76func (s *CancelOrderSuite) validInput() orderuc.CancelOrderInput {
 77    return orderuc.CancelOrderInput{
 78        OrderID: s.orderID,
 79        UserID:  s.userID,
 80    }
 81}
 82
 83// waitForAsync waits for async operations (goroutines) to complete
 84func waitForAsync(fn func() bool, timeout time.Duration) bool {
 85    deadline := time.Now().Add(timeout)
 86    for time.Now().Before(deadline) {
 87        if fn() {
 88            return true
 89        }
 90        time.Sleep(10 * time.Millisecond)
 91    }
 92    return false
 93}
 94
 95// ══════════════════════════════════════════════
 96// Happy Path Tests
 97// AC1-AC7: Successful cancel flow
 98// ══════════════════════════════════════════════
 99
100func (s *CancelOrderSuite) TestSuccess_PendingOrder_Within15Min_CancelsSuccessfully() {
101    // Given: PENDING order created 5 minutes ago (within 15-min window)
102    order := s.newPendingOrder(5)
103    publishCalled := make(chan struct{}, 1)
104
105    s.repo.EXPECT().
106        GetByIDAndUserID(gomock.Any(), s.orderID, s.userID).
107        Return(order, nil).
108        Times(1)
109
110    s.repo.EXPECT().
111        CancelWithStockRestore(gomock.Any(), s.orderID).
112        Return(nil).
113        Times(1)
114
115    s.publisher.EXPECT().
116        PublishOrderCancelled(gomock.Any(), gomock.Any()).
117        DoAndReturn(func(_ context.Context, _ domain.OrderCancelledEvent) error {
118            close(publishCalled)
119            return nil
120        }).
121        Times(1)
122
123    // When
124    err := s.uc.Execute(s.ctx, s.validInput())
125
126    // Then: cancel succeeds
127    s.NoError(err)
128
129    // And: event published asynchronously
130    s.True(
131        waitForAsync(func() bool {
132            select {
133            case <-publishCalled:
134                return true
135            default:
136                return false
137            }
138        }, 100*time.Millisecond),
139        "publisher should be called asynchronously",
140    )
141}
142
143func (s *CancelOrderSuite) TestSuccess_JustWithinWindow_14Min59Sec_Succeeds() {
144    // Given: order created 14 min 59 sec ago (edge of window)
145    order := &domain.Order{
146        ID:        s.orderID,
147        UserID:    s.userID,
148        Status:    domain.StatusPending,
149        CreatedAt: s.clock.Now().Add(-(15*time.Minute - time.Second)),
150    }
151
152    s.repo.EXPECT().GetByIDAndUserID(gomock.Any(), s.orderID, s.userID).Return(order, nil)
153    s.repo.EXPECT().CancelWithStockRestore(gomock.Any(), s.orderID).Return(nil)
154    s.publisher.EXPECT().PublishOrderCancelled(gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
155
156    // When
157    err := s.uc.Execute(s.ctx, s.validInput())
158
159    // Then: still within window, succeeds
160    s.NoError(err)
161}
162
163// ══════════════════════════════════════════════
164// AC8: Order not found
165// ══════════════════════════════════════════════
166
167func (s *CancelOrderSuite) TestAC8_OrderNotFound_ReturnsErrOrderNotFound() {
168    // Given: order does not exist
169    s.repo.EXPECT().
170        GetByIDAndUserID(gomock.Any(), s.orderID, s.userID).
171        Return(nil, nil). // nil order, nil error = not found
172        Times(1)
173
174    // When
175    err := s.uc.Execute(s.ctx, s.validInput())
176
177    // Then: ErrOrderNotFound
178    s.ErrorIs(err, domain.ErrOrderNotFound)
179}
180
181func (s *CancelOrderSuite) TestAC8_OrderBelongsToOtherUser_SameErrorAsNotFound() {
182    // Given: GetByIDAndUserID returns nil for order belonging to another user
183    // (repository hides existence from other users)
184    s.repo.EXPECT().
185        GetByIDAndUserID(gomock.Any(), s.orderID, s.userID).
186        Return(nil, nil). // same response as not found
187        Times(1)
188
189    // When
190    err := s.uc.Execute(s.ctx, s.validInput())
191
192    // Then: same error as not found (AC8: don't expose existence)
193    s.ErrorIs(err, domain.ErrOrderNotFound)
194}
195
196// ══════════════════════════════════════════════
197// AC9: Status not PENDING
198// ══════════════════════════════════════════════
199
200func (s *CancelOrderSuite) TestAC9_ConfirmedOrder_ReturnsOrderNotCancellableError() {
201    // Given: CONFIRMED order
202    order := &domain.Order{
203        ID: s.orderID, UserID: s.userID,
204        Status:    domain.StatusConfirmed,
205        CreatedAt: s.clock.Now().Add(-3 * time.Minute),
206    }
207    s.repo.EXPECT().GetByIDAndUserID(gomock.Any(), s.orderID, s.userID).Return(order, nil)
208
209    // When
210    err := s.uc.Execute(s.ctx, s.validInput())
211
212    // Then: OrderNotCancellableError with current status
213    var notCancellable *domain.OrderNotCancellableError
214    s.True(errors.As(err, &notCancellable), "should be OrderNotCancellableError")
215    s.Equal(domain.StatusConfirmed, notCancellable.CurrentStatus)
216}
217
218func (s *CancelOrderSuite) TestAC9_ShippedOrder_ReturnsOrderNotCancellableError() {
219    order := &domain.Order{
220        ID: s.orderID, UserID: s.userID,
221        Status:    domain.StatusShipped,
222        CreatedAt: s.clock.Now().Add(-2 * time.Hour),
223    }
224    s.repo.EXPECT().GetByIDAndUserID(gomock.Any(), s.orderID, s.userID).Return(order, nil)
225
226    err := s.uc.Execute(s.ctx, s.validInput())
227
228    var notCancellable *domain.OrderNotCancellableError
229    s.True(errors.As(err, &notCancellable))
230    s.Equal(domain.StatusShipped, notCancellable.CurrentStatus)
231}
232
233// ══════════════════════════════════════════════
234// AC10: Cancellation window expired
235// ══════════════════════════════════════════════
236
237func (s *CancelOrderSuite) TestAC10_OrderCreatedExactly15MinAgo_WindowExpired() {
238    // Given: order created exactly 15 minutes ago (window just closed)
239    order := &domain.Order{
240        ID: s.orderID, UserID: s.userID,
241        Status:    domain.StatusPending,
242        CreatedAt: s.clock.Now().Add(-15 * time.Minute),
243    }
244    s.repo.EXPECT().GetByIDAndUserID(gomock.Any(), s.orderID, s.userID).Return(order, nil)
245
246    err := s.uc.Execute(s.ctx, s.validInput())
247
248    s.ErrorIs(err, domain.ErrCancelWindowExpired)
249}
250
251func (s *CancelOrderSuite) TestAC10_OrderCreated1HourAgo_WindowExpired() {
252    order := s.newPendingOrder(60) // 60 minutes old
253    s.repo.EXPECT().GetByIDAndUserID(gomock.Any(), s.orderID, s.userID).Return(order, nil)
254
255    err := s.uc.Execute(s.ctx, s.validInput())
256
257    s.ErrorIs(err, domain.ErrCancelWindowExpired)
258}
259
260// ══════════════════════════════════════════════
261// Edge Case Tests (from spec ECs)
262// ══════════════════════════════════════════════
263
264func (s *CancelOrderSuite) TestEC2_KafkaPublishFails_CancelStillSucceeds() {
265    // Given: everything works except Kafka
266    order := s.newPendingOrder(5)
267
268    s.repo.EXPECT().GetByIDAndUserID(gomock.Any(), s.orderID, s.userID).Return(order, nil)
269    s.repo.EXPECT().CancelWithStockRestore(gomock.Any(), s.orderID).Return(nil)
270
271    kafkaCalled := make(chan struct{}, 1)
272    s.publisher.EXPECT().
273        PublishOrderCancelled(gomock.Any(), gomock.Any()).
274        DoAndReturn(func(_ context.Context, _ domain.OrderCancelledEvent) error {
275            close(kafkaCalled)
276            return errors.New("kafka: connection refused") // Kafka fails
277        }).
278        Times(1)
279
280    // When
281    err := s.uc.Execute(s.ctx, s.validInput())
282
283    // Then: cancel succeeds despite Kafka failure (EC2: best effort)
284    s.NoError(err, "cancel should succeed even when Kafka fails")
285
286    // And: publisher was still called (best effort)
287    s.True(
288        waitForAsync(func() bool {
289            select {
290            case <-kafkaCalled:
291                return true
292            default:
293                return false
294            }
295        }, 100*time.Millisecond),
296        "publisher should be called even if it returns error",
297    )
298}
299
300func (s *CancelOrderSuite) TestEC3_StockRestoreFails_ReturnsError() {
301    // Given: DB transaction fails (EC3: partial failure = rollback)
302    order := s.newPendingOrder(5)
303    dbError := errors.New("pq: deadlock detected")
304
305    s.repo.EXPECT().GetByIDAndUserID(gomock.Any(), s.orderID, s.userID).Return(order, nil)
306    s.repo.EXPECT().
307        CancelWithStockRestore(gomock.Any(), s.orderID).
308        Return(dbError)
309
310    // Publisher should NOT be called if cancel fails
311    // (no EXPECT for publisher means test fails if it IS called)
312
313    // When
314    err := s.uc.Execute(s.ctx, s.validInput())
315
316    // Then: error returned, no event published
317    s.Error(err)
318    s.ErrorContains(err, "cancel with stock restore")
319}

Struktur ini memisahkan setup, helper, dan test per-kategori (happy path, per-AC, per-EC), sehingga file test tetap terbaca meski jumlah test-nya banyak dan mudah dicari saat kamu butuh menambah skenario baru.


13.4 Testing Async Behavior: Goroutine dalam Test

Salah satu tantangan terbesar adalah mentest kode yang memakai goroutine untuk operasi best-effort. Pola berikut menunjukkan cara menunggu goroutine selesai secara deterministik, bukan mengandalkan time.Sleep yang rapuh.

go
 1// Helper untuk wait async operations
 2func (s *CancelOrderSuite) waitForPublish() bool {
 3    return waitForAsync(func() bool {
 4        select {
 5        case <-s.publishCalled:
 6            return true
 7        default:
 8            return false
 9        }
10    }, 100*time.Millisecond)
11}
12
13// Alternative: gunakan channel dengan buffer
14publishCalled := make(chan struct{}, 1) // buffer=1 agar goroutine tidak block
15s.publisher.EXPECT().
16    PublishOrderCancelled(gomock.Any(), gomock.Any()).
17    DoAndReturn(func(_ context.Context, _ domain.OrderCancelledEvent) error {
18        publishCalled <- struct{}{}
19        return nil
20    })
21
22// Assert setelah Execute
23s.NoError(err)
24select {
25case <-publishCalled:
26    // OK
27case <-time.After(100 * time.Millisecond):
28    s.Fail("publisher not called within 100ms")
29}

Dengan channel berbuffer dan select bertimeout, assertion async menjadi deterministik — test tidak lagi bergantung pada timing yang bisa lolos di laptop cepat tapi gagal di CI yang lambat.


13.5 Table-Driven Tests untuk Multiple Status Scenarios

Ketika ada banyak variasi dari kondisi yang sama, table-driven test menghindari duplikasi. Contoh berikut menguji seluruh status non-PENDING dalam satu test dengan sub-test terpisah.

go
 1func (s *CancelOrderSuite) TestAC9_AllNonPendingStatuses_ReturnNotCancellable() {
 2    nonPendingStatuses := []domain.Status{
 3        domain.StatusConfirmed,
 4        domain.StatusShipped,
 5        domain.StatusDelivered,
 6        domain.StatusCancelled, // Already cancelled
 7    }
 8
 9    for _, status := range nonPendingStatuses {
10        s.Run(string(status), func() {
11            // Reset mocks for each sub-test
12            ctrl := gomock.NewController(s.T())
13            defer ctrl.Finish()
14            repo := mockdomain.NewMockOrderRepository(ctrl)
15
16            order := &domain.Order{
17                ID: s.orderID, UserID: s.userID,
18                Status:    status,
19                CreatedAt: s.clock.Now().Add(-2 * time.Minute),
20            }
21            repo.EXPECT().GetByIDAndUserID(gomock.Any(), s.orderID, s.userID).Return(order, nil)
22
23            base := orderuc.NewCancelOrderUseCase(repo, s.publisher)
24            uc := base.WithClock(s.clock)
25
26            err := uc.Execute(s.ctx, s.validInput())
27
28            var notCancellable *domain.OrderNotCancellableError
29            s.True(errors.As(err, &notCancellable),
30                "status %s should return OrderNotCancellableError", status)
31            s.Equal(status, notCancellable.CurrentStatus)
32        })
33    }
34}

Setiap status dijalankan sebagai sub-test tersendiri, sehingga jika satu status gagal, laporan test menunjuk persis ke status mana yang bermasalah — bukan sekadar “TestAC9 gagal” tanpa konteks.


13.6 Spec-to-Test Coverage Report

Setelah test ditulis, kita perlu memastikan setiap AC dan EC benar-benar ter-cover. Prompt berikut meminta Claude membuat laporan coverage yang di-trace langsung ke spec, lengkap dengan tabel status.

text
 1Setelah unit test selesai, generate spec compliance report:
 2
 3Untuk setiap AC dan EC di specs/order/cancel-order.md:
 41. Tunjukkan test function yang meng-cover AC/EC tersebut
 52. Status: ✅ Covered / ❌ Not Covered / ⚠️ Partially Covered
 63. Untuk "Partially Covered": jelaskan apa yang belum ter-test
 7
 8Format output:
 9| AC/EC | Test Function(s) | Status | Notes |
10|-------|-----------------|--------|-------|
11| AC1   | TestSuccess_... | ✅ | |
12| AC8   | TestAC8_*      | ✅ | Two scenarios: not found + wrong user |
13| EC1   | TestEC1_*      | ❌ | Perlu integration test (real DB) |
14
15Test file: internal/usecase/order/cancel_order_test.go
16Spec: specs/order/cancel-order.md

Laporan seperti ini mengubah pertanyaan subjektif “apakah test sudah cukup?” menjadi tabel objektif yang bisa direview siapa saja di tim — dan langsung memperlihatkan AC yang masih menganga tanpa test.


13.7 Handler Test: Integration-Style

Handler test membutuhkan pendekatan berbeda karena melibatkan HTTP layer. Suite berikut menguji handler cancel order dari request masuk sampai response code keluar.

go
 1// internal/delivery/http/handler/order_handler_test.go
 2package handler_test
 3
 4import (
 5    "encoding/json"
 6    "net/http"
 7    "net/http/httptest"
 8    "testing"
 9
10    "github.com/labstack/echo/v4"
11    "github.com/stretchr/testify/suite"
12    // ...
13)
14
15type CancelOrderHandlerSuite struct {
16    suite.Suite
17    e           *echo.Echo
18    cancelUC    *mockusecase.MockCancelOrderUseCase
19    handler     *handler.OrderHandler
20}
21
22func (s *CancelOrderHandlerSuite) SetupTest() {
23    s.e = echo.New()
24    s.cancelUC = mockusecase.NewMockCancelOrderUseCase(s.ctrl)
25    s.handler = handler.NewOrderHandler(s.cancelUC)
26}
27
28func (s *CancelOrderHandlerSuite) TestCancelOrder_ValidRequest_Returns204() {
29    // Given
30    orderID := uuid.New()
31    userID := uuid.New()
32
33    s.cancelUC.EXPECT().
34        Execute(gomock.Any(), gomock.MatchedBy(func(input orderuc.CancelOrderInput) bool {
35            return input.OrderID == orderID && input.UserID == userID
36        })).
37        Return(nil)
38
39    // When: make HTTP request
40    req := httptest.NewRequest(http.MethodDelete, "/", nil)
41    rec := httptest.NewRecorder()
42    c := s.e.NewContext(req, rec)
43    c.SetParamNames("id")
44    c.SetParamValues(orderID.String())
45    c.Set("user_id", userID) // simulated from auth middleware
46
47    err := s.handler.CancelOrder(c)
48
49    // Then: 204 No Content
50    s.NoError(err)
51    s.Equal(http.StatusNoContent, rec.Code)
52    s.Empty(rec.Body.String()) // no response body
53}
54
55func (s *CancelOrderHandlerSuite) TestCancelOrder_OrderNotFound_Returns404() {
56    orderID := uuid.New()
57    userID := uuid.New()
58
59    s.cancelUC.EXPECT().
60        Execute(gomock.Any(), gomock.Any()).
61        Return(domain.ErrOrderNotFound)
62
63    req := httptest.NewRequest(http.MethodDelete, "/", nil)
64    rec := httptest.NewRecorder()
65    c := s.e.NewContext(req, rec)
66    c.SetParamNames("id")
67    c.SetParamValues(orderID.String())
68    c.Set("user_id", userID)
69
70    s.handler.CancelOrder(c)
71
72    s.Equal(http.StatusNotFound, rec.Code)
73
74    var resp map[string]string
75    s.NoError(json.Unmarshal(rec.Body.Bytes(), &resp))
76    s.Equal("ORDER_NOT_FOUND", resp["error_code"])
77}
78
79func (s *CancelOrderHandlerSuite) TestCancelOrder_InvalidUUID_Returns400() {
80    req := httptest.NewRequest(http.MethodDelete, "/", nil)
81    rec := httptest.NewRecorder()
82    c := s.e.NewContext(req, rec)
83    c.SetParamNames("id")
84    c.SetParamValues("not-a-valid-uuid") // invalid UUID
85
86    s.handler.CancelOrder(c)
87
88    s.Equal(http.StatusBadRequest, rec.Code)
89}

Dengan httptest, kita memverifikasi kontrak HTTP-nya — status code, body kosong pada 204, dan error_code pada 404 — tanpa perlu menjalankan server sungguhan atau menyentuh database.


13.8 Benchmark Tests untuk NFR Performance

Dari NFR performa di spec, kita turunkan benchmark test. Contoh berikut mengukur latency satu operasi cancel dengan semua dependency di-mock agar yang diukur murni logika usecase.

go
 1// internal/usecase/order/cancel_order_bench_test.go
 2package order_test
 3
 4import (
 5    "context"
 6    "testing"
 7    "time"
 8
 9    "github.com/golang/mock/gomock"
10    "github.com/google/uuid"
11)
12
13// BenchmarkCancelOrder_Typical verifies NFR-P2: p95 < 500ms
14func BenchmarkCancelOrder_Typical(b *testing.B) {
15    ctrl := gomock.NewController(b)
16    defer ctrl.Finish()
17
18    repo := mockdomain.NewMockOrderRepository(ctrl)
19    publisher := mockdomain.NewMockOrderEventPublisher(ctrl)
20    clock := &mockClock{now: time.Now()}
21
22    order := &domain.Order{
23        ID:        uuid.New(),
24        UserID:    uuid.New(),
25        Status:    domain.StatusPending,
26        CreatedAt: clock.Now().Add(-5 * time.Minute),
27        Items:     []domain.OrderItem{{ProductID: uuid.New(), Quantity: 1, PriceCents: 100000}},
28    }
29
30    repo.EXPECT().GetByIDAndUserID(gomock.Any(), gomock.Any(), gomock.Any()).
31        Return(order, nil).AnyTimes()
32    repo.EXPECT().CancelWithStockRestore(gomock.Any(), gomock.Any()).
33        Return(nil).AnyTimes()
34    publisher.EXPECT().PublishOrderCancelled(gomock.Any(), gomock.Any()).
35        Return(nil).AnyTimes()
36
37    base := orderuc.NewCancelOrderUseCase(repo, publisher)
38    uc := base.WithClock(clock)
39
40    input := orderuc.CancelOrderInput{
41        OrderID: order.ID,
42        UserID:  order.UserID,
43    }
44
45    b.ResetTimer()
46    for i := 0; i < b.N; i++ {
47        if err := uc.Execute(context.Background(), input); err != nil {
48            b.Fatal(err)
49        }
50    }
51}

Benchmark ini menjadi baseline yang bisa dibandingkan antar perubahan: begitu ada regresi latency di logika usecase, angka ns/op akan naik dan mengingatkan sebelum NFR-P2 terlanggar di production.


13.9 Property-Based Testing untuk Edge Cases

Untuk kondisi batas yang sulit diprediksi, kita gunakan pendekatan table-driven sebagai aproksimasi property-based testing. Contoh berikut menguji perilaku tepat di sekitar batas 15 menit.

go
 1// Dengan gopter atau rapid untuk property-based testing
 2func TestCancelOrder_WindowBoundary_PropertyBased(t *testing.T) {
 3    // Property: order yang dibuat < 15 menit lalu SELALU bisa cancel
 4    // (jika status PENDING dan tidak ada error lain)
 5
 6    ctrl := gomock.NewController(t)
 7    defer ctrl.Finish()
 8
 9    // Test dengan berbagai age values mendekati 15 menit
10    testCases := []struct {
11        name    string
12        ageSec  int
13        wantErr bool
14    }{
15        {"1 second old", 1, false},
16        {"5 minutes old", 300, false},
17        {"14 minutes old", 840, false},
18        {"14 min 59 sec", 899, false},
19        {"exactly 15 min", 900, true},  // ErrCancelWindowExpired
20        {"15 min 1 sec", 901, true},    // ErrCancelWindowExpired
21        {"1 hour old", 3600, true},
22    }
23
24    for _, tc := range testCases {
25        t.Run(tc.name, func(t *testing.T) {
26            repo := mockdomain.NewMockOrderRepository(ctrl)
27            publisher := mockdomain.NewMockOrderEventPublisher(ctrl)
28            fixedNow := time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC)
29            clock := &mockClock{now: fixedNow}
30
31            order := &domain.Order{
32                ID:        uuid.New(),
33                Status:    domain.StatusPending,
34                CreatedAt: fixedNow.Add(-time.Duration(tc.ageSec) * time.Second),
35            }
36            repo.EXPECT().GetByIDAndUserID(gomock.Any(), gomock.Any(), gomock.Any()).
37                Return(order, nil)
38
39            if !tc.wantErr {
40                repo.EXPECT().CancelWithStockRestore(gomock.Any(), gomock.Any()).Return(nil)
41                publisher.EXPECT().PublishOrderCancelled(gomock.Any(), gomock.Any()).
42                    Return(nil).AnyTimes()
43            }
44
45            base := orderuc.NewCancelOrderUseCase(repo, publisher)
46            uc := base.WithClock(clock)
47
48            err := uc.Execute(context.Background(), orderuc.CancelOrderInput{
49                OrderID: order.ID,
50                UserID:  uuid.New(),
51            })
52
53            if tc.wantErr {
54                assert.Error(t, err)
55            } else {
56                assert.NoError(t, err)
57            }
58        })
59    }
60}

Dengan menaruh nilai-nilai di sekitar batas (899, 900, 901 detik), kita memaksa test membuktikan perilaku boundary yang tepat — persis titik di mana bug <= versus < biasanya bersembunyi.


13.10 Test Naming Convention dari Spec

Konsistensi penamaan membuat test mudah dicari dan bisa dibaca sebagai dokumentasi. Format berikut kita pakai untuk seluruh suite, diturunkan langsung dari AC, EC, dan NFR.

text
 1Format: Test{Layer}_{Condition}_{ExpectedBehavior}
 2
 3Dari AC:
 4- TestCancelOrder_PendingOrder_ReturnsNilError
 5- TestCancelOrder_ConfirmedOrder_ReturnsNotCancellableError
 6- TestCancelOrder_15MinuteWindowExpired_ReturnsCancelWindowExpiredError
 7- TestCancelOrder_OrderNotFound_ReturnsErrOrderNotFound
 8
 9Dari EC:
10- TestCancelOrder_KafkaPublishFails_CancelStillSucceeds (EC2)
11- TestCancelOrder_DBTransactionFails_ReturnsError (EC3)
12- TestCancelOrder_Concurrent_OnlyOneSucceeds (EC1 — integration)
13
14Dari NFR:
15- BenchmarkCancelOrder_TypicalLoad (NFR-P1,P2)
16- TestCancelOrder_NoSensitiveDataInLogs (NFR-S12)

Dengan format Test{Layer}_{Condition}_{ExpectedBehavior}, nama test langsung menceritakan skenario yang diuji tanpa perlu membuka isinya — sangat membantu saat membaca output CI yang berisi ratusan baris.


13.11 Menggunakan Claude untuk Review Test Quality

Setelah test suite selesai, minta Claude mereview kualitasnya — bukan hanya angka coverage. Prompt berikut mengarahkan review ke hal yang benar-benar penting.

text
 1Review test suite berikut untuk CancelOrderUseCase:
 2
 3Evaluasi:
 41. Coverage: apakah semua AC dari spec di-cover oleh minimal satu test?
 52. Test quality: apakah test benar-benar verify behavior yang benar,
 6   bukan hanya bahwa kode tidak crash?
 73. Assertions: apakah assertions spesifik (verify exact error type, exact message)?
 84. Setup/teardown: apakah ada yang perlu dihindari (shared state, dll)?
 95. Missing tests: apa yang seharusnya ada tapi tidak ada?
10
11Spec: [paste spec]
12Test file: [paste test code]

Fokus review pada “apakah test benar-benar memverifikasi behavior” jauh lebih berharga daripada sekadar mengejar persentase coverage — test yang lulus tapi longgar justru memberi rasa aman palsu.


13.12 Contract Testing: Verify Interface Boundaries

Contract test memverifikasi bahwa implementasi konkret memenuhi kontrak interface — khususnya untuk repository yang menyentuh DB nyata. Test berikut membuktikan dua asumsi penting yang dipegang unit test.

go
 1// TestOrderRepository_ContractCompliance verifies all repository methods
 2// work correctly with the domain model
 3func TestOrderRepository_ContractCompliance(t *testing.T) {
 4    if testing.Short() {
 5        t.Skip("contract test requires DB")
 6    }
 7
 8    db := setupTestDB(t)
 9    repo := postgres.NewOrderRepository(db)
10
11    t.Run("GetByIDAndUserID returns nil for wrong user", func(t *testing.T) {
12        // Create order for user A
13        orderID := createTestOrder(t, db, userA)
14
15        // Try to get with user B
16        result, err := repo.GetByIDAndUserID(context.Background(), orderID, userB)
17
18        // Contract: return nil, nil (not an error — just not found)
19        assert.NoError(t, err)
20        assert.Nil(t, result)
21    })
22
23    t.Run("CancelWithStockRestore is atomic", func(t *testing.T) {
24        // Setup order with 3 items
25        orderID := createTestOrderWithItems(t, db, 3)
26        stockBefore := getProductStocks(t, db, orderID)
27
28        // Cancel
29        err := repo.CancelWithStockRestore(context.Background(), orderID)
30        require.NoError(t, err)
31
32        // Verify: all stocks restored, order is CANCELLED
33        stockAfter := getProductStocks(t, db, orderID)
34        for productID, before := range stockBefore {
35            assert.Greater(t, stockAfter[productID], before,
36                "stock for product %s should be restored", productID)
37        }
38
39        order, _ := repo.GetByID(context.Background(), orderID)
40        assert.Equal(t, domain.StatusCancelled, order.Status)
41    })
42}

Test ini menjaga agar asumsi di unit test — misalnya “wrong user mengembalikan nil, nil” dan “restore stok bersifat atomik” — benar-benar dipegang oleh implementasi PostgreSQL, bukan hanya oleh mock.


13.13 Snapshot Testing untuk Response Format

Snapshot test menjaga agar format response tidak berubah tanpa sengaja. Test berikut membandingkan body 404 dengan format yang dijanjikan spec secara persis.

go
 1func TestCancelOrderHandler_404Response_MatchesSpec(t *testing.T) {
 2    // ... setup handler dan mock
 3
 4    // Act
 5    rec := httptest.NewRecorder()
 6    handler.CancelOrder(c)
 7
 8    // Assert: response body matches spec exactly
 9    expectedBody := `{"error_code":"ORDER_NOT_FOUND","message":"order not found"}`
10    actualBody := strings.TrimSpace(rec.Body.String())
11
12    assert.JSONEq(t, expectedBody, actualBody,
13        "response format must match spec: should have error_code and message")
14}

Dengan assert.JSONEq, perubahan format response yang tidak disengaja — misalnya field error_code yang tiba-tiba di-rename — langsung gagal di CI sebelum sampai ke konsumen API.


13.14 Paralel Test Execution

Untuk test yang independen dan perlu cepat, eksekusi paralel sangat membantu. Pola berikut menandai test dan sub-test dengan t.Parallel().

go
 1func TestCancelOrder_AllErrorCases(t *testing.T) {
 2    t.Parallel() // Jalankan paralel dengan test lain
 3
 4    testCases := []struct {
 5        name     string
 6        setup    func(repo *mock.MockOrderRepository)
 7        wantErr  error
 8    }{
 9        {
10            name: "order not found",
11            setup: func(r *mock.MockOrderRepository) {
12                r.EXPECT().GetByIDAndUserID(gomock.Any(), gomock.Any(), gomock.Any()).
13                    Return(nil, nil)
14            },
15            wantErr: domain.ErrOrderNotFound,
16        },
17        // ... more cases
18    }
19
20    for _, tc := range testCases {
21        tc := tc // capture for goroutine
22        t.Run(tc.name, func(t *testing.T) {
23            t.Parallel()
24            // ... run test
25        })
26    }
27}

Perhatikan baris tc := tc — tanpa capture ini, closure paralel bisa membaca variabel loop yang salah, sumber bug klasik di Go sebelum versi 1.22 yang sering lolos justru karena test-nya paralel.


13.15 Test Documentation: Linking Test to Spec

Mendokumentasikan hubungan test-ke-spec di header file membuat traceability eksplisit. Komentar berikut memetakan setiap AC dan EC ke test function yang meng-cover-nya.

go
 1// cancel_order_test.go
 2
 3// TestSuite_CancelOrder tests the CancelOrderUseCase.
 4// Spec reference: specs/order/cancel-order.md v1.3
 5//
 6// AC Coverage:
 7//   AC1-AC7 (happy path): TestSuccess_PendingOrder_Within15Min_CancelsSuccessfully
 8//   AC8 (not found):      TestAC8_OrderNotFound_ReturnsErrOrderNotFound
 9//                         TestAC8_OrderBelongsToOtherUser_SameErrorAsNotFound
10//   AC9 (status check):   TestAC9_*
11//   AC10 (window):        TestAC10_*
12//
13// EC Coverage:
14//   EC1 (concurrent):     Integration test in test/integration/
15//   EC2 (kafka fail):     TestEC2_KafkaPublishFails_CancelStillSucceeds
16//   EC3 (db fail):        TestEC3_StockRestoreFails_ReturnsError
17type CancelOrderSuite struct {
18    // ...
19}

Peta ini menjadi indeks hidup: siapa pun yang membuka file test langsung tahu AC mana ter-cover di test mana, dan mana yang sengaja dilempar ke integration test.


13.16 Test untuk Observability (Metric dan Log)

NFR observability juga perlu di-test, bukan hanya diasumsikan berjalan. Dua test berikut memverifikasi bahwa metric ter-increment dan tidak ada data sensitif yang bocor ke log.

go
 1func TestCancelOrder_MetricsIncrement_OnSuccess(t *testing.T) {
 2    // Given: metrics collector
 3    registry := prometheus.NewRegistry()
 4    metrics := ordermetrics.New(registry)
 5    uc := orderuc.NewCancelOrderUseCase(repo, publisher, metrics)
 6
 7    // When: cancel succeeds
 8    uc.Execute(ctx, input)
 9
10    // Then: success counter incremented (NFR-O2)
11    counter, _ := registry.Gather()
12    // Assert cancel_order_total{status="success"} == 1
13}
14
15func TestCancelOrder_NoSensitiveDataInLogs(t *testing.T) {
16    // Capture logs
17    var logBuf strings.Builder
18    logger := slog.New(slog.NewJSONHandler(&logBuf, nil))
19    uc := orderuc.NewCancelOrderUseCase(repo, publisher, orderuc.WithLogger(logger))
20
21    // Execute cancel
22    uc.Execute(ctx, input)
23
24    // Assert: no sensitive data in logs (NFR-S12)
25    logOutput := logBuf.String()
26    s.NotContains(logOutput, creditCardNumber)
27    s.NotContains(logOutput, userPassword)
28}

Test seperti ini mengubah NFR yang biasanya “hanya di dokumen” menjadi jaminan yang dieksekusi setiap CI run — kebocoran data sensitif ke log jadi gagal build, bukan temuan audit yang terlambat.


13.17 Mendefinisikan Test Coverage Targets

Target coverage dari NFR-T1 (minimum 80%) sebaiknya ditegakkan otomatis di CI. Konfigurasi berikut menggagalkan build jika coverage turun di bawah ambang.

yaml
 1# .github/workflows/test.yml
 2- name: Run tests with coverage
 3  run: |
 4    go test -coverprofile=coverage.out ./internal/usecase/...
 5    COVERAGE=$(go tool cover -func=coverage.out | grep total | awk '{print $3}')
 6    echo "Coverage: $COVERAGE"
 7
 8    # Fail if below 80% (NFR-T1)
 9    COVERAGE_INT=$(echo $COVERAGE | sed 's/%//' | cut -d. -f1)
10    if [ "$COVERAGE_INT" -lt 80 ]; then
11      echo "Coverage $COVERAGE is below 80% minimum (NFR-T1)"
12      exit 1
13    fi

Dengan gate ini, coverage bukan lagi janji manual melainkan syarat lulus pipeline yang tidak bisa dilewati — regresi coverage langsung memblokir merge.


13.18 Tips & Gotchas

💡 Tip 1: Test function name = mini-documentation — nama test yang baik bisa dibaca sebagai spec: “CancelOrder, ketika status CONFIRMED, mengembalikan OrderNotCancellableError dengan status tersebut.”

💡 Tip 2: Satu test = satu AC, bukan “test semua sekaligus” — test yang terlalu besar sulit di-debug. Jika satu dari 10 assertion gagal, sulit tahu yang mana dan mengapa.

💡 Tip 3: Mock expectations yang too loose adalah test yang tidak bergunagomock.Any() untuk semua argument mungkin lulus tapi tidak benar-benar memverifikasi behavior.

💡 Tip 4: Gunakan gomock.InOrder untuk test yang perlu sequence — ketika urutan pemanggilan mock adalah bagian dari kontrak, tegakkan urutannya secara eksplisit seperti berikut.

go
1gomock.InOrder(
2    s.repo.EXPECT().GetByIDAndUserID(...).Return(order, nil),
3    s.repo.EXPECT().CancelWithStockRestore(...).Return(nil),
4)

Dengan InOrder, test akan gagal jika CancelWithStockRestore sempat dipanggil sebelum GetByIDAndUserID — menjaga urutan yang memang disyaratkan spec.

⚠️ Gotcha 1: Async test yang tidak wait goroutine = flaky test — test yang tidak menunggu goroutine selesai akan lulus kadang dan gagal kadang. Selalu gunakan channel atau waitgroup untuk async assertions.

⚠️ Gotcha 2: Test yang bergantung pada urutan AnyTimes() — jika mock diset AnyTimes() tapi test logic bergantung pada jumlah calls, test bisa memberi false positive.

⚠️ Gotcha 3: Test yang terlalu banyak mock setup = test implementasi, bukan test behavior — jika setup mock jauh lebih panjang dari assertion, mungkin test terlalu detail tentang implementasi, bukan behavior.

⚠️ Gotcha 4: Jangan gunakan time.Now() dalam test — inject clock — test yang bergantung pada waktu sistem akan gagal intermittently. Selalu inject clock interface.


13.19 Mengukur Kualitas Test Suite

Setelah test suite selesai, ukur kualitasnya dengan beberapa metrik konkret, bukan hanya “semua hijau”. Empat metrik berikut memberi gambaran seberapa kuat suite kamu:

Metric 1: Spec coverage — berapa persen AC dan EC punya test?

Metric 2: Assertion ratio — rata-rata berapa assertion per test? (target: 2-5)

Metric 3: Test execution time — semua unit test selesai dalam < 30 detik? (NFR-T4)

Metric 4: Mutation testing score — berapa persen mutasi (perubahan kode kecil) terdeteksi oleh test?

Metrik keempat butuh tooling khusus. Perintah berikut menjalankan mutation testing pada usecase order menggunakan go-mutesting.

bash
1# Gunakan go-mutesting untuk mutation testing
2go-mutesting ./internal/usecase/order/... | tail -20

Skor mutation testing mengungkap test yang “hijau tapi lemah” — lulus tanpa benar-benar menangkap perubahan logika, misalnya operator <= yang diam-diam berubah menjadi <.


13.20 Ringkasan

Unit test yang baik dalam SDD bukan hanya tentang coverage percentage — tapi tentang seberapa akurat test tersebut memverifikasi behavior yang ada di spec.

Mapping yang jelas: Setiap AC → minimal satu test. Setiap EC → minimal satu test. Setiap NFR → benchmark atau integration test.

Kualitas test yang baik:

  • Struktur Given-When-Then yang konsisten
  • Naming yang mencerminkan spec (bukan implementasi)
  • Assertions yang spesifik (exact error type, exact message)
  • Async behavior yang di-handle dengan benar (channel, wait)

Tools:

  • testify/suite untuk organization
  • gomock untuk mocking
  • table-driven tests untuk variasi
  • benchmark tests untuk NFR performance

AI acceleration: Claude Code bisa generate boilerplate test suite, tapi setiap assertion harus di-review untuk memastikan benar-benar memverifikasi spec, bukan hanya “kode tidak crash.”

Di artikel berikutnya, kita akan membahas Code Review dengan AI — bagaimana menggunakan Claude untuk memverifikasi bahwa kode yang sudah diimplementasikan benar-benar sesuai spec, dan teknik review yang sistematis.

Artikel Terkait

💬 Komentar