# 軟體測試 ## 本週目標 學習: - 單元測試 - 整合測試 - 模糊測試 等測試技巧,以及學習使用 `go test` 配合開源套件打造穩度的 CI/CD Pipeline。 ## 單元測試 單元測試是針對程式模組進行正確性檢驗的測試方法,對 Procedural programming 而言,單元可以是單一個程式、函式、過程等。而對於物件導向程式設計來說,最小單元就是方法,包括基礎類別、抽象類、或者衍生類別中的方法。 我們藉由高覆蓋率的單元測試以及實踐回歸測試的精神,保證軟體能夠在有一定品質與信心的情況下達到**持續交付**。 ### 單元測試的基本規則 - 一個 test case 僅用於測試一個最小單位 - 最小的測試單位 - (測試本身)不具備邏輯 - 測試之間不該有相依性 - 測試不與外部環境產生關聯 如何定義什麼是好的單元測試(FIRST): 1. **F**irst:快速執行完畢。 2. **I**ndependent:無相依性。 3. **R**epeatable:可重複執行的。 4. **S**elf-Validating:可反映驗證結果,當測試失敗時能夠知道原因。 5. **T**imely:單元測試與受測的程式碼應同時交付。 如何開始撰寫單元測試(3A): 1. **A**rrange:初始化受測物、參數、結果與互動方式。 2. **A**ct:呼叫受測物的方法。 3. **A**ssert:驗證行為。 ### 使用 `go test` 撰寫測試 Golang 已經提供了 `go test` 讓我們可以撰寫可靠的單元測試,使用 `go test` 時會有一些資訊需要遵守: - 測試檔案必為 `xxx_test.go` 結尾 - 測試函式的開頭必定為大寫且以 `Test` 開頭 ```go= // src: https://github.com/vx416/api/pull/3/files#diff-1a1a6f53abb10453d4a3652333c7e67a3d8abd8f17a8c1eb733929e477043838 func TestAngron(t *testing.T) { password := "your-password-here" hash, err := CreateArgon2Hash(password) require.NoError(t, err) t.Log(string(hash)) ok, err := ComparePasswordAndHash(password, hash) require.NoError(t, err) assert.True(t, ok, "Password should match the hash") wrongPassword := "wrong_password" ok, err = ComparePasswordAndHash(wrongPassword, hash) require.NoError(t, err) assert.False(t, ok, "Wrong password should not match the hash") } ``` 讓我們重構 `TestAngron()`,使它符合 FIRST 與 3A 原則: ```go= func TestAngron(t *testing.T) { // Arrange testCases := []struct { name string arg string ok bool errMsg string }{ { name: "correct pw", arg: "your-password-here", ok: true, }, { name: "wrong pw", arg: "wrong_password", ok: false, }, } password := "your-password-here" hash, err := CreateArgon2Hash(password) require.NoError(t, err) for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { // Act ok, err := ComparePasswordAndHash(tc.arg, hash) // Assert require.NoError(t, err) require.Equal(t, tc.ok, ok) }) } } ``` - Arrange → 建立 testCases、hash - Act → 呼叫 ComparePasswordAndHash - Assert → 驗證 ok 與 error ## Mock、Stub 與 Fake 在單元測試中,為了避免測試真正依賴外部系統(例如資料庫、API、檔案系統),我們會使用 Test Double(測試替身)。常見的替身有 Mock、Stub、Fake,它們的用途不同: ### 🔧 Stub Stub 用來在測試中回傳固定結果,通常不會檢查是否被正確呼叫。 適用情境: - 測試流程需要依賴某個外部回傳值 - 該外部邏輯不重要,只需要提供資料 舉例: ```go= func TestLogin_StubHasher(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() mockHasher := mocks.NewMockHasher(ctrl) // Stub: 無論輸入什麼都回傳固定 hash mockHasher. EXPECT(). Hash(gomock.Any()). Return("fixed-hash", nil) svc := NewLoginService(mockHasher) hash, _ := svc.HashPassword("hello") require.Equal(t, "fixed-hash", hash) } ``` ### 🎭 Mock Mock 會檢查互動(如:是否被呼叫、呼叫次數、參數是否正確)。他用來驗證“行為”。 適用情境: - 需要驗證某個 service 是否有被呼叫 - 需要確認依賴的 method 是否使用預期參數 舉例: ```go= func TestLogin_MockBehavior(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() mockHasher := mocks.NewMockHasher(ctrl) mockHasher. EXPECT(). Compare("pw123", "hash123"). Times(1). Return(true) svc := NewLoginService(mockHasher) ok := svc.Verify("pw123", "hash123") require.True(t, ok) } ``` 🧪 Fake Fake 是「有簡化邏輯的實作」,通常是 in-memory、非正式版本,可獨立運作。 適用情境: - 想取代實際的資料庫 / queue - 需要行為,但不需要完整效能 舉例: ```go= type FakeUserStore struct { users map[string]string } func (f *FakeUserStore) SaveUser(id, name string) { f.users[id] = name } func (f *FakeUserStore) GetUser(id string) string { return f.users[id] } ``` | 類型 |gomock 實作方式 | 用途 | -- | -- | -- | | Stub | `EXPECT().Return` 固定值 | 提供固定資料,不驗證行為 | | Mock | `EXPECT()...Times().WithArgs()` | 驗證呼叫次數與參數 | | Fake | 手動 in-memory 實作 |取代複雜外部系統,如 DB、Cache | 上述三者是各種 Test Double 的典型角色,選擇何者最適合,取決於你想測的重點是「資料結果」還是「互動行為」。 :::info 有關 gomock 與 gock,請參考:https://medium.com/@ianchen0119/%E4%BD%BF%E7%94%A8-golang-%E5%96%AE%E5%85%83%E6%B8%AC%E8%A9%A6%E7%9A%84%E4%B8%80%E4%BA%9B%E6%8A%80%E5%B7%A7-eccd8f34395f ::: ## 補充:go test 使用技巧 ### 1. `t.Parallel()`:平行化測試 Go 允許在同一個 Test Suite 中平行執行多個測試,以加速整體執行時間。 #### 使用方式 ```go func TestA(t *testing.T) { t.Parallel() // 測試邏輯 } func TestB(t *testing.T) { t.Parallel() // 測試邏輯 } ``` #### 注意事項 - **同一測試檔案**內的測試若呼叫 `t.Parallel()`,主測試函式(例如 `TestMain` 或包裹的 `TestXxx`)會先跑完 setup,再同時執行 parallel 測試。 - 若測試會共用資源(檔案、資料庫、全域變數),**你必須自己確保 thread-safe**,否則會與 race detector 衝突。 ### 2. Race Detection:偵測資料競爭 (`go test -race`) Go 提供 built-in 的 race detector 去找出多 goroutine 之間的 data race。 #### 使用方式 ```bash go test -race ./... ``` #### 會抓到哪些問題? - 寫入與讀取同一個變數同時發生 - 多 goroutine 修改共享 map - 使用 unsynchronized 狀態的 struct 欄位 - 測試實際出現 race 會直接報錯 #### 性能影響 - 執行速度會變慢 5–10 倍 - 記憶體使用量會變大,**但非常值得用在 CI** ### 3. Build Tags:依不同 tag 匯入不同檔案或 mock Go 的 build tag 可用來讓不同檔案在不同編譯情境下被啟用,例如: - 為 dev / prod 提供不同的實作 - 測試 mock / fake / stub - platform-specific code(linux、darwin 等) #### 使用方式:在檔案最上方加入 tag #### `foo_prod.go` ```go //go:build prod package foo ``` #### `foo_mock.go` ```go //go:build !prod package foo ``` #### 執行時選擇要啟用哪個 tag ```bash go test -tags=prod go build -tags=prod ``` #### 常見用途 | Tag 應用 | 說明 | |---------|------| | mock / fake | 依 tag 匯入不同的測試用物件 | | integration | 區分單元測試與整合測試 | | prod / dev | 不同環境使用不同實作 | | platform | 特定 OS 才會啟用 | ### 4. Go Test Cache:測試快取 Go 1.10+ 支援測試快取,只要: 1. 測試檔案沒有變更 2. 依賴過的程式碼沒有變更 3. test flags 相同(例如是否加 `-race`) 就會直接回傳快取 #### 啟用快取(預設啟用) ```bash go test ./... ``` #### 關閉快取 ```bash go test -count=1 ./... ``` #### 檢查哪些是 cached 的 ```bash go test -v ./... ``` 輸出中會顯示: ``` ok mypkg 0.002s [cached] ``` #### 會使快取失效的因素 - 任意 source file 修改 - 任意依賴 package 修改 - 加上 `-race`、`-bench` 會強制重新跑 - 加上 `t.Parallel()` 不會影響 cache 本身 ### 總結 | 功能 | 用途 | 注意事項 | |------|------|-----------| | `t.Parallel()` | 平行加速測試 | 共用資源需 thread-safe | | `go test -race` | 偵測 data race | 執行變慢但非常重要 | | Build Tags | 切換 mock / 平台 / 環境實作 | tag 需放在檔案最上方 | | Test Cache | 加速未變更測試 | 用 `-count=1` 停用 | ## 整合測試(Integration Test)介紹 整合測試是介於「單元測試」與「端對端測試(E2E)」之間,用於驗證**多個元件之間的互動是否正常**。 在單元測試中,我們常透過 mock 或 fake 避免依賴外部環境;但整合測試則是刻意引入「真的外部服務」,例如: - 真實資料庫(PostgreSQL / MySQL) - Redis、NATS、Kafka - HTTP server / gRPC server - File system、queue 等 整合測試的目的不是要測每個邏輯細節,而是要確保**不同系統組合後能正常運作**。 ### 為什麼要做整合測試? 整合測試可驗證: 1. 程式是否能與 DB/Cache/外部 API 正確連線 2. ORM、schema、migration 是否正確 3. Query / transaction / commit rollback 行為是否符合預期 4. 多個模組串起來是否會互相影響 整合測試不必追求非常高的覆蓋率,但要針對「功能邊界」進行驗證。 ### 使用 `dockertest` 進行整合測試 [`github.com/ory/dockertest`](https://github.com/ory/dockertest) 是 Go 中最常使用的整合測試工具之一,它會: - 於測試期間自動拉起 Docker 容器 - 等待 Service 準備完成(例如 PostgreSQL ready) - 測試結束後自動清除 container - 不汙染本機或 CI 的環境 這比要求開發者本機手動安裝 DB 更可靠,也讓 CI 更容易重現。 ### 範例:使用 dockertest + go test 測 PostgreSQL 整合行為 以下示範如何用 dockertest 啟動一個 PostgreSQL,並對你的程式邏輯進行整合測試。 ```go package integration_test import ( "database/sql" "fmt" "log" "testing" "time" _ "github.com/lib/pq" "github.com/ory/dockertest/v3" ) func TestPostgresIntegration(t *testing.T) { pool, err := dockertest.NewPool("") if err != nil { t.Fatalf("Could not connect to docker: %s", err) } // 啟動 PostgreSQL 容器 resource, err := pool.Run("postgres", "15-alpine", []string{ "POSTGRES_PASSWORD=secret", "POSTGRES_DB=testdb", }) if err != nil { t.Fatalf("Could not start resource: %s", err) } // 清理 container t.Cleanup(func() { if err := pool.Purge(resource); err != nil { t.Fatalf("Could not purge resource: %s", err) } }) var db *sql.DB port := resource.GetPort("5432/tcp") // 重試:等資料庫 ready if err := pool.Retry(func() error { var err error dsn := fmt.Sprintf("postgres://postgres:secret@localhost:%s/testdb?sslmode=disable", port) db, err = sql.Open("postgres", dsn) if err != nil { return err } return db.Ping() }); err != nil { t.Fatalf("Could not connect to postgres: %s", err) } // 真正的整合測試開始 _, err = db.Exec(`CREATE TABLE users (id SERIAL PRIMARY KEY, name TEXT)`) if err != nil { t.Fatalf("migration error: %s", err) } _, err = db.Exec(`INSERT INTO users (name) VALUES ($1)`, "ian") if err != nil { t.Fatalf("insert error: %s", err) } var name string err = db.QueryRow(`SELECT name FROM users WHERE name=$1`, "ian").Scan(&name) if err != nil { t.Fatalf("query error: %s", err) } if name != "ian" { t.Fatalf("expected name 'ian', got %s", name) } log.Println("Integration test OK!") } ``` ### 為什麼這是一個「整合測試」? 因為: - 測試使用的是**真實 PostgreSQL** - 使用真正的 network connection - 測試 DB driver / ORM / migration / SQL 語法 - 並非 stub、mock、fake - 具有「跨模組、跨系統」的驗證性質 這比單元測試更真實,但又比 E2E 測試更輕量。 ### 整合測試的最佳實務 #### 1. 資料應該用完就清除 不要依賴測試之間的殘留資料。 #### 2. 每次測試都使用獨立的資料庫 / schema 避免彼此污染,並允許平行測試。 #### 3. 在 CI 也必須能跑 dockertest 完全符合這點。 #### 4. 不追求超高覆蓋率 只測「關鍵的外部整合路徑」。 #### 5. 測試必須在合理時間內完成 整合測試一般可接受比 unit test 稍慢(例如 1–10 秒)。 # 使用 TestMain 避免多個測項重複啟動/刪除容器 在整合測試中,如果每個 `TestXxx` 都各自啟動與清除一次 Docker 容器(例如 PostgreSQL、Redis),將會造成: - 🚫 重複建立 container → 測試變慢 - 🚫 重複刪除 container → 還沒跑完的測試可能被意外刪除 - 🚫 測試結果不穩定(尤其平行測試時) 為了解決這個問題,可以透過 **`TestMain`** 管理測試程序的生命週期,使: - 所有測項 **共享同一個容器** - 測試初始化(啟動容器)只進行一次 - 所有測項跑完後才統一清除容器 這樣可以將整合測試速度提升 3~10 倍以上。 --- # `TestMain` 的運作方式 Go 測試框架會檢查是否有以下函數: ```go func TestMain(m *testing.M) ``` 若存在: 1. 先執行 `TestMain` 2. 由 `m.Run()` 執行所有其它測試 3. 最後再回到 `TestMain` 做收尾工作 因此我們可以在: - `TestMain` 開頭 → 建立 dockertest 容器 - `TestMain` 結尾 → 清除容器 ### 進階技巧:用 TestMain 建立一次 PostgreSQL 容器並讓所有測項共享 假設你有多個測項,例如: ```go func TestUserInsert(t *testing.T) { ... } func TestUserQuery(t *testing.T) { ... } func TestUserUpdate(t *testing.T) { ... } ``` 這些測試要共用同一個 PostgreSQL。 建立一個檔案:`main_test.go` ```go package integration_test import ( "database/sql" "fmt" "log" "os" "testing" _ "github.com/lib/pq" "github.com/ory/dockertest/v3" ) var ( db *sql.DB pool *dockertest.Pool resource *dockertest.Resource ) func TestMain(m *testing.M) { var err error // 建立 Docker pool pool, err = dockertest.NewPool("") if err != nil { log.Fatalf("Could not connect to docker: %s", err) } // 建立 PostgreSQL 容器 resource, err = pool.Run("postgres", "15-alpine", []string{ "POSTGRES_PASSWORD=secret", "POSTGRES_DB=testdb", }) if err != nil { log.Fatalf("Could not start resource: %s", err) } // 等 Database ready if err := pool.Retry(func() error { var err error port := resource.GetPort("5432/tcp") dsn := fmt.Sprintf("postgres://postgres:secret@localhost:%s/testdb?sslmode=disable", port) db, err = sql.Open("postgres", dsn) if err != nil { return err } return db.Ping() }); err != nil { log.Fatalf("Could not connect to database: %s", err) } // 執行所有測試 code := m.Run() // 清除容器 if err := pool.Purge(resource); err != nil { log.Fatalf("Could not purge resource: %s", err) } // 回傳測試結果給 OS os.Exit(code) } ``` ### 其他測試檔即可直接使用全域 `db` 例如 `user_test.go`: ```go func TestUserInsert(t *testing.T) { _, err := db.Exec(`INSERT INTO users (name) VALUES ('ian')`) if err != nil { t.Fatalf("insert error: %v", err) } } func TestUserQuery(t *testing.T) { var name string err := db.QueryRow(`SELECT name FROM users WHERE name='ian'`).Scan(&name) if err != nil { t.Fatalf("query error: %v", err) } } ``` ### 為什麼這樣做比較好? #### 容器只啟動一次 → 測試速度大幅提升 若每個測試都啟動一次 PostgreSQL,整套測試可能從 20 秒變成 2 分鐘。 用 `TestMain` 共用容器可避免: - 重複拉 image - 重複啟動 container - 重複等待 DB ready(通常需要 2~6 秒) #### 測試更加穩定 避免以下狀況: - A 先啟動 DB → B 啟動又把 A 的容器蓋掉 - A 測完 purge container → B 還沒測完就掛掉 - 平行測試互相踩到 port #### 減少資源消耗 CI pipeline 也更快、更便宜。 ### 額外技巧:平行測試 + 每測項 reset schema 你可以在每個測項中清除資料避免污染: ```go t.Cleanup(func() { db.Exec(`TRUNCATE TABLE users RESTART IDENTITY`) }) ``` 也可以使用 transaction + rollback 方式隔離每個測項。 ### 總結 整合測試的目的: - 驗證跨模組、跨系統是否能正確互動 - 驗證外部系統(DB、Cache、Queue)與程式邏輯的契合度 - 提早發現 schema、連線、timeout、migration、初始化錯誤 - 使用 `TestMain` 有諸多好處: | 問題 | TestMain 解法 | |------|----------------| | 多個測項會重複啟動容器 | 容器只啟動一次 | | 測試速度慢 | 只等待一次 DB ready | | 測試互相踐踏 | 統一管理容器生命週期 | | 容器容易被 purge 掉 | 統一收尾、避免 race condition | ## 模糊測試 模糊測試(Fuzz Testing / Fuzzing)是一種自動化測試技術,用來找出: - 異常輸入 - 邊界條件問題 - panic / crash - 未預期的邏輯錯誤 - 資安漏洞(如:越界、格式錯誤造成的崩潰) Go 語言自 **Go 1.18** 起內建 fuzzing 支援,並整合進 `go test`,讓開發者能用非常簡單的方式撰寫模糊測試。 ![image](https://hackmd.io/_uploads/BJz_jtqZWx.png) > *Fuzz Testing 流程圖,圖片來源:https://ithelp.ithome.com.tw/articles/10287695* Go 內建 fuzzer 會從三個來源產生 mutation: 1\. Seed corpus(初始 corpus) 你放在: ``` testdata/fuzz/<FuzzFuncName>/ ``` 的檔案,或 `f.Add("xxx")` 增加的初始輸入。 2\. Fuzzer 自動產生的 coverage 增益輸入 當 fuzzer 在執行 fuzz target 時觸發了新的 code path,它會自動把新的 input 加入 corpus。 3\. Mutation Engine 自動變異 包含: - byte-level mutation(逐 byte 修改) - byte insertion / deletion - dictionary-based mutation - crossover(A 與 B corpus 輸入做 mixing) - length extension / trimming - integer boundary mutation(如 0, 1, -1, MaxInt 等) - string-level mutation(大小寫切換、重複字元、特殊符號) ### 為什麼要使用 Fuzz Testing? 一般單元測試只能測: - 我們「想得到」的輸入 - 預期的行為 但永遠會有很多: - 邊界、特殊、破損、惡意輸入 - 意料之外的 encoding - 會造成 panic 的 corner case 模糊測試則會: - 自動產生數千至數百萬種輸入 - 找到你可能永遠想不到的 bug(或攻擊手法) - 自動記錄讓程式 crash 的輸入,以便之後重現 ### Go Fuzz 測試的基本格式 一個 Fuzz 測試函式必須: - 名稱以 `Fuzz` 開頭 - 參數為 `f *testing.F` - 在 f.Fuzz(...) 中呼叫 target function 範例: ```go= func FuzzReverse(f *testing.F) { // 加入初始樣本(Seed Corpus) f.Add("hello") f.Add("世界") f.Add("") f.Fuzz(func(t *testing.T, input string) { output := Reverse(input) // 應保證 reverse(reverse(x)) == x double := Reverse(output) if double != input { t.Fatalf("expected %q, got %q", input, double) } }) } ``` ### 執行方式 Go fuzz 不會在一般 `go test` 下啟動: ``` go test -fuzz=Fuzz ``` 或 fuzz 特定 function: ``` go test -fuzz=FuzzReverse ``` 用 `Ctrl + C` 可中斷,Go 會自動記錄觸發 bug 的輸入。 ### 觸發錯誤後(Crash Corpus)的行為 當 fuzz 找到一個會讓測試失敗的輸入: - 該輸入會自動存到: **`testdata/fuzz/<FuzzName>/xxxxxxx`** - 之後再跑 `go test` 時會自動輸入這些 case,確保 regression 不再發生 例如: ``` testdata/ fuzz/ FuzzReverse/ 9f2a3bc8f5fa6a6c ``` 內容可能是 fuzz 找到的神奇字串,例如: ``` "\x80\xFF\x01" ``` ### 範例:測不安全的 JSON parser 假設你有一個容易崩潰的 JSON parsing 函式: ```go func ParseJSON(s string) error { var dst map[string]any return json.Unmarshal([]byte(s), &dst) } ``` fuzz 測試: ```go func FuzzParseJSON(f *testing.F) { f.Add(`{"name":"ian"}`) f.Add(`{}`) f.Add(`null`) f.Fuzz(func(t *testing.T, input string) { _ = ParseJSON(input) // 不要求成功,只要求不 crash }) } ``` 此 fuzz 能找出: - 非 UTF-8 字元 - 半截的 JSON:`"{\"a\":1"` - 極度深度的巢狀 JSON - 大型隨機垃圾資料 從中挑出可能造成 panic 的 corner case。 #### Seed Corpus(初始語料庫)說明 你可以透過以下方式幫 fuzz 指定初始輸入: - 使用 `f.Add()` - 在 `testdata/fuzz/<Name>/` 放入事先準備的測試資料 Go 會基於這些 seed 去演化出更多異常輸入。 #### 停止條件:用 time 限制 fuzz duration 例如 fuzz 只跑 10 秒: ``` go test -fuzz=Fuzz -fuzztime=10s ``` 常用參數: - `-fuzz=FuzzXxx` 指定 fuzz 函式 - `-fuzztime=30s` 限制 fuzz 時間 - `-run ^$` 跳過一般測試,只跑 fuzz ### Golang Fuzz 測試的最佳實務 #### 1. 測的應該是 deterministic 函式 Fuzz function 不應該依賴: - 時間 - 隨機資料 - 非 deterministic 的外部行為(如網路) #### 2. 不要讓 fuzz 測試有 side effects 避免對檔案、資料庫等做破壞性操作。 #### 3. fuzz 測試應盡量找「邊界錯誤」 例如: - panic - encoding bug - integer overflow - invalid UTF-8 - long input / deep recursion #### 4. 善用 corpus(語料庫) 把你修過的 bug 都丟回 corpus 讓 fuzz 持續驗證。 ### 案例:CVE-2022-43677 - https://nvd.nist.gov/vuln/detail/CVE-2022-43677 - https://free5gc.org/blog/20230809/main/#overview ### 總結 Golang 內建的 fuzz testing 提供強大的自動化錯誤探索能力: | 特性 | 說明 | |------|------| | 自動產生大量輸入 | 覆蓋更多邊界情況 | | 偵測 crash/panic | 找出程式弱點 | | 自動保存錯誤樣本 | 減少 regression | | 與 `go test` 完整整合 | 無須安裝額外工具 |