# 軟體測試
## 本週目標
學習:
- 單元測試
- 整合測試
- 模糊測試
等測試技巧,以及學習使用 `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`,讓開發者能用非常簡單的方式撰寫模糊測試。

> *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` 完整整合 | 無須安裝額外工具 |