第 8 章
Day 8:測試
測試是 Go 的原生功能。建立以 _test.go 結尾的檔案,再執行 go test 即可。
內建測試提供共同基礎,讓開發者不必先被「要選哪個測試框架」或「怎麼建立測試案例」阻擋。
第一個測試:SimpleString
從 pkg/resp/value.go 的 SimpleString 開始。在 value_test.go 建立 TestSimpleString_Marshal:
func TestSimpleString_Marshal(t *testing.T) {
testcases := []struct {
data []byte
expect []byte
}{
{
data: []byte("OK"),
expect: []byte("+OK\r\n"),
},
{
data: []byte("PONG"),
expect: []byte("+PONG\r\n"),
},
}
for _, tc := range testcases {
t.Run(string(tc.data), func(t *testing.T) {
s := SimpleString(tc.data)
marshaled := s.Marshal()
if string(marshaled) != string(tc.expect) {
t.Errorf("expect '%s', got '%s'", tc.expect, marshaled)
}
})
}
}
*_test.go 中以 Test 開頭、符合測試函式簽名的函式會被視為測試。
我通常把測試分成測試案例定義與 test runner。Runner 再依序分為 Arrange、Act、Assert。
測試案例與 runner
func TestFoo(t *testing.T) {
// Test cases definition
testcases := []struct{
name string
}{
{
name: "test case 1",
},
{
name: "test case 2",
},
}
for _, tc := range testcases {
// Test Runner
t.Run(tc.name, func (t *testing.T) {})
}
}
測試函式或 method 時,通常會有兩個以上的案例。Go 提供 t.Run(),可以在同一個測試中執行 subtests。
注意:只有一個案例時,不一定需要
t.Run()。清楚、可讀、易維護的程式碼更重要。
Arrange、Act、Assert
AAA pattern 將單元測試分成三階段:
- Arrange:準備資料,例如初始化 struct 或建立暫存資源。
- Act:執行要測試的操作。
- Assert:檢查結果是否符合預期。
for _, tc := range testcases {
t.Run(string(tc.data), func(t *testing.T) {
s := SimpleString(tc.data)
marshaled := s.Marshal()
if !slices.Equal(tc.expect, marshaled) {
t.Errorf("expect '%s', got '%s'", tc.expect, marshaled)
}
})
}
對應的程式片段如下:
// arrange: create SimpleString by tc.data
s := SimpleString(tc.data)
// act: Invoke the s.Marshal() to get result
marshaled := s.Marshal()
// assert: check the marshaled and tc.expect is the same
if !slices.Equals(tc.expected, marshaled) {
t.Errorf("expect '%s', got '%s'", tc.expect, marshaled)
}
Benchmarking
Benchmark 也是 Go 內建的測試功能。
我為 SimpleString.Marshal() 寫了兩種實作:
func (v SimpleString) bufferMarshal() []byte {
var buf bytes.Buffer
buf.WriteRune(MAGIC_SIMPLE_STRING)
buf.Write(v)
buf.WriteString(SENTINEL)
return buf.Bytes()
}
func (s SimpleString) copyMarshaler() []byte {
l := len(s)
marshaled := make([]byte, l+3)
marshaled[0] = MAGIC_SIMPLE_STRING
marshaled[l+1] = '\r'
marshaled[l+2] = '\n'
copy(marshaled[1:], s)
return marshaled
}
注意:不應太早最佳化效能。這裡是為了學習 benchmark,不會把相同實驗套用到其他 RESP values。
Benchmark 函式以 Benchmark 開頭,參數為 b *testing.B。
func BenchmarkSimpleString_bufferMarshal(b *testing.B) {
s := SimpleString("OK")
b.StartTimer()
for b.Loop() {
_ = s.bufferMarshal()
}
b.StopTimer()
}
func BenchmarkSimpleString_copyMarshal(b *testing.B) {
s := SimpleString("OK")
b.StartTimer()
for b.Loop() {
_ = s.copyMarshaler()
}
b.StopTimer()
若 Arrange 不耗時,就不需要手動呼叫 b.StartTimer() 與 b.EndTimer();這裡只是示範。
執行測試
使用 go test ./pkg/resp/value_test.go 執行測試,並以 go test -bench=. ./pkg/resp.value_test.go 執行 benchmark。
也可以在專案根目錄執行 go test ./... 與 go test -bench=. ./...,測試整個專案。
我會將命令寫入 Makefile,簡化為 make test:
test:
go test ./...
Redis Command 測試
回顧 Command interface:加入新指令時,只需要實作 internal/service/cmd/cmd.go 的介面,再註冊到 NewCommands()。GET 與 SET 也是如此。
type Command interface {
Command() string
Exec(context.Context, *resp.Command) (resp.Value, error)
}
func NewCommands(storage repo.Storage) []Command {
return []Command{
&Ping{},
NewSet(storage),
NewGet(storage),
}
}
Storage interface
NewGet() 與 NewSet() 都需要 repo.Storage。測試時該如何提供?可以使用 mock 或 stub。
有時不需要關心 storage 的細節,只需要 Set 接受輸入、Get 回傳指定資料。這時能用 stub:
func TestGet_Exec(t *testing.T) {
cmd := NewGet(&stubStorage{})
ret, err := cmd.Exec(t.Context(),
resp.NewTestCommand(
resp.NewArray([]resp.Value{
resp.NewBulkString("GET"),
resp.NewBulkString("foo"),
}),
),
)
if err != nil {
t.Error(err)
}
if !slices.Equals(ret.Marshal(), []byte("$3\r\nbar\r\n")) {
t.Error("expect '%s', got '%s', []byte("$3\r\nbar\r\n"), ret.Marshal())
}
}
type stubStorage struct {}
func (*stubStorage) Set(ctx context.Context, k, v string) error { return nil }
func (*stubStorage) Get(ctx context.Context, k string) (string, error) { return "bar", nil }
stubStorage 實作 Set() 與 Get(),滿足 repo.Storage,因此能傳入 NewGet()。
Mock generator
有些開發者使用 stretchr/testify,但我不喜歡它的 mock,這讓我想起寫 Java 的經驗。我改用 matryer/moq:
$ go get -tool github.com/matryer/moq
注意:moq README 使用
go install github.com/matryer/moq@latest安裝全域執行檔。Go 1.24 起可以使用go get -tool管理 module 範圍的工具。
在 Storage interface 加入 go generate 註解:
//go:generate go tool moq -rm -out storage_mock.go . Storage
type Storage interface {
Set(ctx context.Context, k, v string) error
Get(ctx context.Context, k string) (string, error)
}
執行 go generate ./...,moq 會產生 storage_mock.go。
使用 mock storage 測試
使用 moq 的範例如下:
func TestGet_Exec(t *testing.T) {
cmd := NewGet(&repo.StorageMock{
GetFunc: func(ctx context.Context, k string) (string, error) { return "bar", nil }
})
ret, err := cmd.Exec(t.Context(),
resp.NewTestCommand(
resp.NewArray([]resp.Value{
resp.NewBulkString("GET"),
resp.NewBulkString("foo"),
}),
),
)
if err != nil {
t.Error(err)
}
if !slices.Equals(ret.Marshal(), []byte("$3\r\nbar\r\n")) {
t.Error("expect '%s', got '%s', []byte("$3\r\nbar\r\n"), ret.Marshal())
}
}
接著可以查看 d8/testing 分支的 internal/service/cmd/get_test.go 與 internal/service/set_test.go。