@vincent119/go-copilot-rules 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,573 @@
1
+ ---
2
+ name: go-testing-advanced
3
+ description: |
4
+ Go 進階測試策略:Table-driven tests 進階模式、Mocking 策略(uber-go/mock)、
5
+ 整合測試設計、Benchmark 與 Fuzz testing、測試覆蓋率要求、測試金字塔原則。
6
+
7
+ **適用場景**:撰寫單元測試、設計 Mock、實作整合測試、效能測試(Benchmark)、
8
+ 模糊測試(Fuzz)、提升測試覆蓋率、測試 Repository/Use Case。
9
+
10
+ **關鍵字**:testing, unit test, integration test, mock, gomock, uber-go/mock, mockery,
11
+ table driven test, benchmark, fuzz testing, test coverage, testify, test fixtures,
12
+ test doubles, test pyramid, t.Helper, t.Cleanup, t.Context
13
+ ---
14
+
15
+ # Go 進階測試策略
16
+
17
+ > **相關 Skills**:本規範建議搭配 `go-dependency-injection`(測試 DI 容器配置)
18
+
19
+ ---
20
+
21
+ ## Table-Driven Tests 進階模式
22
+
23
+ ### 基礎模式
24
+
25
+ ```go
26
+ func TestAdd(t *testing.T) {
27
+ tests := []struct {
28
+ name string
29
+ a, b int
30
+ expected int
31
+ }{
32
+ {name: "positive numbers", a: 2, b: 3, expected: 5},
33
+ {name: "negative numbers", a: -1, b: -2, expected: -3},
34
+ {name: "zero", a: 0, b: 0, expected: 0},
35
+ }
36
+
37
+ for _, tt := range tests {
38
+ t.Run(tt.name, func(t *testing.T) {
39
+ result := Add(tt.a, tt.b)
40
+ if result != tt.expected {
41
+ t.Errorf("Add(%d, %d) = %d, want %d", tt.a, tt.b, result, tt.expected)
42
+ }
43
+ })
44
+ }
45
+ }
46
+ ```
47
+
48
+ ### 進階模式:測試錯誤情境
49
+
50
+ ```go
51
+ func TestProcessData(t *testing.T) {
52
+ tests := []struct {
53
+ name string
54
+ input []byte
55
+ wantErr bool
56
+ errTarget error // 使用 errors.Is 檢查
57
+ }{
58
+ {
59
+ name: "valid data",
60
+ input: []byte(`{"key":"value"}`),
61
+ wantErr: false,
62
+ },
63
+ {
64
+ name: "invalid json",
65
+ input: []byte(`{invalid}`),
66
+ wantErr: true,
67
+ errTarget: ErrInvalidJSON,
68
+ },
69
+ {
70
+ name: "empty data",
71
+ input: nil,
72
+ wantErr: true,
73
+ errTarget: ErrEmptyData,
74
+ },
75
+ }
76
+
77
+ for _, tt := range tests {
78
+ t.Run(tt.name, func(t *testing.T) {
79
+ err := ProcessData(tt.input)
80
+
81
+ if tt.wantErr {
82
+ if err == nil {
83
+ t.Fatal("expected error, got nil")
84
+ }
85
+ if tt.errTarget != nil && !errors.Is(err, tt.errTarget) {
86
+ t.Errorf("expected error %v, got %v", tt.errTarget, err)
87
+ }
88
+ } else {
89
+ if err != nil {
90
+ t.Errorf("unexpected error: %v", err)
91
+ }
92
+ }
93
+ })
94
+ }
95
+ }
96
+ ```
97
+
98
+ ### 使用 t.Helper() 簡化斷言
99
+
100
+ ```go
101
+ // 測試輔助函式
102
+ func assertNoError(t *testing.T, err error) {
103
+ t.Helper() // 標記為輔助函式,錯誤會指向呼叫者行號
104
+ if err != nil {
105
+ t.Fatalf("unexpected error: %v", err)
106
+ }
107
+ }
108
+
109
+ func assertEqual(t *testing.T, got, want interface{}) {
110
+ t.Helper()
111
+ if got != want {
112
+ t.Errorf("got %v, want %v", got, want)
113
+ }
114
+ }
115
+
116
+ // 使用範例
117
+ func TestMyFunction(t *testing.T) {
118
+ result, err := MyFunction()
119
+ assertNoError(t, err)
120
+ assertEqual(t, result, "expected")
121
+ }
122
+ ```
123
+
124
+ ---
125
+
126
+ ## Mocking 策略(uber-go/mock)
127
+
128
+ ### 安裝與配置
129
+
130
+ ```bash
131
+ # 安裝 uber-go/mock(原 gomock)
132
+ go install go.uber.org/mock/mockgen@latest
133
+
134
+ # 或使用 mockery
135
+ go install github.com/vektra/mockery/v2@latest
136
+ ```
137
+
138
+ ### Interface 設計原則
139
+
140
+ **原則**:
141
+ - Repository / Service 皆以 **interface** 暴露;實作為 private struct
142
+ - Interface 定義於 Domain 或 Application 層,實作於 Infra 層
143
+
144
+ **範例**:
145
+ ```go
146
+ // internal/order/domain/repository.go
147
+ package domain
148
+
149
+ //go:generate mockgen -source=repository.go -destination=../../mocks/order_repository_mock.go -package=mocks
150
+ type Repository interface {
151
+ Save(ctx context.Context, order *Order) error
152
+ FindByID(ctx context.Context, id string) (*Order, error)
153
+ }
154
+ ```
155
+
156
+ ### 產生 Mock
157
+
158
+ ```bash
159
+ # 在專案根目錄執行
160
+ go generate ./...
161
+
162
+ # 或手動產生
163
+ mockgen -source=internal/order/domain/repository.go \
164
+ -destination=internal/mocks/order_repository_mock.go \
165
+ -package=mocks
166
+ ```
167
+
168
+ **目錄結構**:
169
+ ```
170
+ internal/
171
+ ├── order/
172
+ │ └── domain/
173
+ │ └── repository.go
174
+ └── mocks/
175
+ └── order_repository_mock.go # 統一放置 Mock
176
+ ```
177
+
178
+ ### 使用 Mock 撰寫單元測試
179
+
180
+ ```go
181
+ // internal/order/application/create_order_usecase_test.go
182
+ package application_test
183
+
184
+ import (
185
+ "context"
186
+ "testing"
187
+
188
+ "go.uber.org/mock/gomock"
189
+ "myapp/internal/mocks"
190
+ "myapp/internal/order/application"
191
+ "myapp/internal/order/domain"
192
+ )
193
+
194
+ func TestCreateOrderUseCase_Execute(t *testing.T) {
195
+ tests := []struct {
196
+ name string
197
+ setup func(*mocks.MockRepository)
198
+ input application.CreateOrderInput
199
+ wantErr bool
200
+ }{
201
+ {
202
+ name: "success",
203
+ setup: func(m *mocks.MockRepository) {
204
+ // 設定 Mock 預期行為
205
+ m.EXPECT().
206
+ Save(gomock.Any(), gomock.Any()).
207
+ Return(nil).
208
+ Times(1)
209
+ },
210
+ input: application.CreateOrderInput{
211
+ CustomerID: "cust-123",
212
+ Items: []domain.OrderItem{{ProductID: "prod-1", Quantity: 2}},
213
+ },
214
+ wantErr: false,
215
+ },
216
+ {
217
+ name: "repository error",
218
+ setup: func(m *mocks.MockRepository) {
219
+ m.EXPECT().
220
+ Save(gomock.Any(), gomock.Any()).
221
+ Return(domain.ErrDatabaseError).
222
+ Times(1)
223
+ },
224
+ input: application.CreateOrderInput{
225
+ CustomerID: "cust-123",
226
+ Items: []domain.OrderItem{{ProductID: "prod-1", Quantity: 2}},
227
+ },
228
+ wantErr: true,
229
+ },
230
+ }
231
+
232
+ for _, tt := range tests {
233
+ t.Run(tt.name, func(t *testing.T) {
234
+ // Arrange
235
+ ctrl := gomock.NewController(t)
236
+ defer ctrl.Finish()
237
+
238
+ mockRepo := mocks.NewMockRepository(ctrl)
239
+ tt.setup(mockRepo)
240
+
241
+ uc := application.NewCreateOrderUseCase(mockRepo)
242
+
243
+ // Act
244
+ err := uc.Execute(t.Context(), tt.input) // Go 1.24+ 使用 t.Context()
245
+
246
+ // Assert
247
+ if (err != nil) != tt.wantErr {
248
+ t.Errorf("Execute() error = %v, wantErr %v", err, tt.wantErr)
249
+ }
250
+ })
251
+ }
252
+ }
253
+ ```
254
+
255
+ ### 進階 Mock 技巧
256
+
257
+ #### 1. 驗證參數內容
258
+
259
+ ```go
260
+ m.EXPECT().
261
+ Save(gomock.Any(), gomock.Cond(func(x interface{}) bool {
262
+ order, ok := x.(*domain.Order)
263
+ return ok && order.CustomerID == "expected-id"
264
+ })).
265
+ Return(nil)
266
+ ```
267
+
268
+ #### 2. 模擬多次呼叫
269
+
270
+ ```go
271
+ m.EXPECT().FindByID(gomock.Any(), "id-1").Return(&domain.Order{}, nil)
272
+ m.EXPECT().FindByID(gomock.Any(), "id-2").Return(nil, domain.ErrNotFound)
273
+ ```
274
+
275
+ #### 3. 使用 gomock.InOrder 保證順序
276
+
277
+ ```go
278
+ gomock.InOrder(
279
+ m.EXPECT().Save(gomock.Any(), gomock.Any()).Return(nil),
280
+ m.EXPECT().FindByID(gomock.Any(), "id").Return(&domain.Order{}, nil),
281
+ )
282
+ ```
283
+
284
+ ---
285
+
286
+ ## 整合測試設計
287
+
288
+ ### 測試金字塔原則
289
+
290
+ ```
291
+ ╱╲
292
+ ╱ ╲ E2E Tests (10%)
293
+ ╱────╲
294
+ ╱ ╲ Integration Tests (30%)
295
+ ╱────────╲
296
+ ╱ ╲ Unit Tests (60%)
297
+ ────────────
298
+ ```
299
+
300
+ ### 整合測試範例(使用真實 DB)
301
+
302
+ ```go
303
+ // internal/order/infra/order_repository_integration_test.go
304
+ // +build integration
305
+
306
+ package infra_test
307
+
308
+ import (
309
+ "context"
310
+ "testing"
311
+
312
+ "github.com/testcontainers/testcontainers-go"
313
+ "github.com/testcontainers/testcontainers-go/wait"
314
+ "gorm.io/driver/postgres"
315
+ "gorm.io/gorm"
316
+ )
317
+
318
+ func setupTestDB(t *testing.T) *gorm.DB {
319
+ t.Helper()
320
+
321
+ // 使用 testcontainers 啟動 PostgreSQL
322
+ ctx := context.Background()
323
+ req := testcontainers.ContainerRequest{
324
+ Image: "postgres:15-alpine",
325
+ ExposedPorts: []string{"5432/tcp"},
326
+ Env: map[string]string{
327
+ "POSTGRES_PASSWORD": "test",
328
+ "POSTGRES_DB": "testdb",
329
+ },
330
+ WaitingFor: wait.ForListeningPort("5432/tcp"),
331
+ }
332
+
333
+ container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
334
+ ContainerRequest: req,
335
+ Started: true,
336
+ })
337
+ if err != nil {
338
+ t.Fatalf("failed to start container: %v", err)
339
+ }
340
+
341
+ t.Cleanup(func() {
342
+ _ = container.Terminate(ctx)
343
+ })
344
+
345
+ // 取得連線資訊
346
+ host, _ := container.Host(ctx)
347
+ port, _ := container.MappedPort(ctx, "5432")
348
+
349
+ dsn := fmt.Sprintf("host=%s port=%s user=postgres password=test dbname=testdb sslmode=disable",
350
+ host, port.Port())
351
+
352
+ db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
353
+ if err != nil {
354
+ t.Fatalf("failed to connect db: %v", err)
355
+ }
356
+
357
+ // 執行 Migration
358
+ _ = db.AutoMigrate(&OrderModel{})
359
+
360
+ return db
361
+ }
362
+
363
+ func TestOrderRepository_Integration(t *testing.T) {
364
+ db := setupTestDB(t)
365
+ repo := infra.NewOrderRepository(db)
366
+
367
+ t.Run("save and find", func(t *testing.T) {
368
+ ctx := context.Background()
369
+ order := domain.NewOrder("cust-123", []domain.OrderItem{})
370
+
371
+ // Save
372
+ err := repo.Save(ctx, order)
373
+ if err != nil {
374
+ t.Fatalf("Save() error: %v", err)
375
+ }
376
+
377
+ // Find
378
+ found, err := repo.FindByID(ctx, order.ID())
379
+ if err != nil {
380
+ t.Fatalf("FindByID() error: %v", err)
381
+ }
382
+
383
+ if found.ID() != order.ID() {
384
+ t.Errorf("ID mismatch: got %s, want %s", found.ID(), order.ID())
385
+ }
386
+ })
387
+ }
388
+ ```
389
+
390
+ ### 執行整合測試
391
+
392
+ ```bash
393
+ # 僅執行整合測試
394
+ go test -tags=integration ./...
395
+
396
+ # 排除整合測試(預設)
397
+ go test ./...
398
+ ```
399
+
400
+ ---
401
+
402
+ ## Benchmark(效能測試)
403
+
404
+ ### 基礎 Benchmark
405
+
406
+ ```go
407
+ func BenchmarkProcessData(b *testing.B) {
408
+ data := generateTestData()
409
+
410
+ // 重置計時器(排除準備時間)
411
+ b.ResetTimer()
412
+
413
+ for i := 0; i < b.N; i++ {
414
+ ProcessData(data)
415
+ }
416
+ }
417
+ ```
418
+
419
+ ### 並行 Benchmark
420
+
421
+ ```go
422
+ func BenchmarkProcessDataParallel(b *testing.B) {
423
+ data := generateTestData()
424
+
425
+ b.RunParallel(func(pb *testing.PB) {
426
+ for pb.Next() {
427
+ ProcessData(data)
428
+ }
429
+ })
430
+ }
431
+ ```
432
+
433
+ ### 報告記憶體分配
434
+
435
+ ```go
436
+ func BenchmarkEncodeJSON(b *testing.B) {
437
+ obj := &MyStruct{Field: "value"}
438
+
439
+ b.ReportAllocs() // 報告記憶體分配
440
+ b.ResetTimer()
441
+
442
+ for i := 0; i < b.N; i++ {
443
+ _, _ = json.Marshal(obj)
444
+ }
445
+ }
446
+ ```
447
+
448
+ ### 執行 Benchmark
449
+
450
+ ```bash
451
+ # 執行所有 Benchmark
452
+ go test -bench=. -benchmem ./...
453
+
454
+ # 執行特定 Benchmark
455
+ go test -bench=BenchmarkProcessData -benchmem
456
+
457
+ # 比較兩次結果
458
+ go test -bench=. -benchmem > old.txt
459
+ # ...程式碼修改...
460
+ go test -bench=. -benchmem > new.txt
461
+ benchstat old.txt new.txt
462
+ ```
463
+
464
+ ---
465
+
466
+ ## Fuzz Testing(模糊測試)
467
+
468
+ ### 基礎 Fuzz Test(Go 1.18+)
469
+
470
+ ```go
471
+ func FuzzParseInput(f *testing.F) {
472
+ // Seed corpus(種子資料)
473
+ f.Add("valid input")
474
+ f.Add("123")
475
+ f.Add("")
476
+
477
+ f.Fuzz(func(t *testing.T, input string) {
478
+ // 測試不應 panic
479
+ result, err := ParseInput(input)
480
+
481
+ // 驗證錯誤處理
482
+ if err != nil {
483
+ // 錯誤是預期的,但不應 panic
484
+ return
485
+ }
486
+
487
+ // 驗證結果屬性
488
+ if len(result) > 0 && result[0] == 0 {
489
+ t.Errorf("unexpected zero value in result")
490
+ }
491
+ })
492
+ }
493
+ ```
494
+
495
+ ### 執行 Fuzz Test
496
+
497
+ ```bash
498
+ # 執行 Fuzz Test(預設 1 分鐘)
499
+ go test -fuzz=FuzzParseInput
500
+
501
+ # 指定執行時間
502
+ go test -fuzz=FuzzParseInput -fuzztime=10m
503
+
504
+ # Fuzz 發現的問題會存入 testdata/fuzz/
505
+ ```
506
+
507
+ ---
508
+
509
+ ## 測試覆蓋率
510
+
511
+ ### 產生覆蓋率報告
512
+
513
+ ```bash
514
+ # 產生覆蓋率檔案
515
+ go test -coverprofile=coverage.out ./...
516
+
517
+ # 查看總覆蓋率
518
+ go tool cover -func=coverage.out
519
+
520
+ # 產生 HTML 報告
521
+ go tool cover -html=coverage.out -o coverage.html
522
+ ```
523
+
524
+ ### 覆蓋率要求
525
+
526
+ - **單元測試覆蓋率**:≥ 80%(可透過 PR 調整)
527
+ - **核心業務邏輯**:≥ 90%
528
+ - **整合測試**:涵蓋關鍵路徑與錯誤情境
529
+
530
+ ### CI 整合
531
+
532
+ ```yaml
533
+ # .github/workflows/test.yml
534
+ - name: Run tests with coverage
535
+ run: |
536
+ go test -race -coverprofile=coverage.out -covermode=atomic ./...
537
+ go tool cover -func=coverage.out | grep total | awk '{print $3}' | sed 's/%//' | \
538
+ awk '{if ($1 < 80) exit 1}'
539
+ ```
540
+
541
+ ---
542
+
543
+ ## 檢查清單
544
+
545
+ **單元測試**
546
+ - [ ] 使用 table-driven tests
547
+ - [ ] 使用 `t.Run` 建立子測試
548
+ - [ ] 使用 `t.Helper()` 標記輔助函式
549
+ - [ ] 使用 `t.Cleanup()` 清理資源
550
+ - [ ] 使用 `t.Context()` 獲取 context(Go 1.24+)
551
+ - [ ] Mock 統一放置於 `internal/mocks/`
552
+ - [ ] 使用 `go generate` 自動產生 Mock
553
+
554
+ **整合測試**
555
+ - [ ] 使用 build tags(`// +build integration`)
556
+ - [ ] 使用 testcontainers 或測試資料庫
557
+ - [ ] 測試真實的 DB/Cache 互動
558
+ - [ ] 執行 Migration 初始化 Schema
559
+
560
+ **Benchmark**
561
+ - [ ] 使用 `b.ResetTimer()` 排除準備時間
562
+ - [ ] 使用 `b.ReportAllocs()` 報告記憶體
563
+ - [ ] 關鍵路徑提供 Benchmark
564
+
565
+ **Fuzz Testing**
566
+ - [ ] 輸入解析函式提供 Fuzz Test
567
+ - [ ] 驗證不會 panic
568
+ - [ ] 種子資料涵蓋邊界情況
569
+
570
+ **覆蓋率**
571
+ - [ ] 單元測試覆蓋率 ≥ 80%
572
+ - [ ] CI 自動檢查覆蓋率門檻
573
+ - [ ] 核心業務邏輯覆蓋率 ≥ 90%
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.