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.
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.
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.
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, ¬Cancellable))
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.
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.
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, ¬Cancellable), "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, ¬Cancellable))
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.
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.
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, ¬Cancellable),
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.
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.mdLaporan 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.
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.
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.
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.
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.
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.
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.
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().
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.
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.
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.
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 fiDengan 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 berguna — gomock.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.
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.
1# Gunakan go-mutesting untuk mutation testing
2go-mutesting ./internal/usecase/order/... | tail -20Skor 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.