ข้ามไปยังเนื้อหา

การเขียน Tests ด้วย go test

test runner ของ Go ค้นหา tests ผ่านกฎ 2 ข้อที่บังคับใช้โดย toolchain:

  1. ชื่อไฟล์ ลงท้ายด้วย _test.go — compiler จะไม่รวมไฟล์เหล่านี้ใน build ปกติ
  2. ชื่อฟังก์ชัน ขึ้นต้นด้วย Test ตามด้วยตัวพิมพ์ใหญ่ — TestAdd, TestParseURL เป็นต้น

ไฟล์ test ขั้นต่ำสำหรับ source file math.go อยู่ใน package เดียวกัน และ directory เดียวกัน:

math_test.go
package math
import "testing"
func TestAdd(t *testing.T) {
got := Add(2, 3)
want := 5
if got != want {
t.Errorf("Add(2, 3) = %d; want %d", got, want)
}
}

รันด้วย:

Terminal window
go test ./... # รัน tests ทั้งหมดใน packages ทั้งหมด
go test -v ./... # verbose: แสดงชื่อ test แต่ละตัวและ PASS/FAIL
go test -run TestAdd # รันเฉพาะ tests ที่ชื่อตรงกับ regex

ทั้งคู่บันทึกความล้มเหลว แต่ต่างกันตรงที่ test จะหยุดหรือดำเนินต่อ:

ฟังก์ชันบันทึกความล้มเหลวหยุด test?
t.Errorfใช่ไม่ — test ดำเนินต่อ
t.Fatalfใช่ใช่ — test หยุดทันที

ใช้ t.Fatalf เมื่อ assertion ถัดไปอาจ panic หาก assertion ก่อนหน้าล้มเหลว (เช่น การ dereference pointer ที่เพิ่งตรวจพบว่าเป็น nil)

func TestDivide(t *testing.T) {
result, err := Divide(10, 0)
if err == nil {
t.Fatalf("expected an error for division by zero, got nil")
}
_ = result
}

รูปแบบ idiomatic ของ Go สำหรับการทดสอบ input หลายค่ากับฟังก์ชันเดียวกันคือ table-driven test: slice ของ struct cases ที่วนซ้ำด้วย t.Run แต่ละ sub-test มีชื่อ, error message, และ — เมื่อใช้ -v — บรรทัดผลลัพธ์เป็นของตัวเอง

fibonacci_test.go
package main
import "testing"
func TestFibonacci(t *testing.T) {
cases := []struct {
name string
n int
want int
}{
{"fib(0)", 0, 0},
{"fib(1)", 1, 1},
{"fib(5)", 5, 5},
{"fib(10)", 10, 55},
}
for _, tc := range cases {
tc := tc // capture range variable (pre-Go 1.22)
t.Run(tc.name, func(t *testing.T) {
got := Fibonacci(tc.n)
if got != tc.want {
t.Errorf("Fibonacci(%d) = %d; want %d", tc.n, got, tc.want)
}
})
}
}

รันด้วย -v เพื่อดู sub-test แต่ละตัว:

Terminal window
go test -v -run TestFibonacci ./...
# --- PASS: TestFibonacci (0.00s)
# --- PASS: TestFibonacci/fib(0) (0.00s)
# --- PASS: TestFibonacci/fib(1) (0.00s)
# --- PASS: TestFibonacci/fib(5) (0.00s)
# --- PASS: TestFibonacci/fib(10) (0.00s)

เพิ่ม -race เพื่อตรวจจับ data races ในเวลา test — นี่คือสิ่งที่ต้องมีใน CI:

Terminal window
go test -race -coverprofile=coverage.out -covermode=atomic ./...

flag -covermode=atomic จำเป็นต้องใช้ร่วมกับ -race เพื่อให้ได้ coverage counts ที่แม่นยำ เนื่องจาก race detector เขียนทับ memory accesses

สิ่งที่ได้ประโยชน์ต้นทุน
table-driven testtest หลาย case ใน 1 functionถ้า table ยาวเกิน อ่านยาก
t.Run() subtestsisolate failure, parallel-friendlynested output อ่านยากขึ้น
t.Parallel()รัน test ขนาน เร็วขึ้นต้องระวัง shared state — race condition ง่าย
-race flagตรวจหา race condition อัตโนมัติช้าลง 2-10x — ใช้ใน CI ไม่ใช่ทุก local run
  • t.Fatal() กับ t.Error() เหมือนกันt.Fatal() หยุด test ทันที, t.Error() mark failed แต่รัน assertion ต่อไปได้
  • go test -cover 100% = code ถูกต้อง — coverage วัดว่า line ถูกรันหรือไม่ ไม่ได้วัดว่า behavior ถูกต้อง — test ไม่มี assertion ก็ทำให้ 100% ได้
  • ต้องเขียน test file แยก package — Go รองรับทั้ง same package (white-box) และ _test package (black-box) — เลือกตาม use case
  • t.Helper() optionalt.Helper() ทำให้ error report ชี้ไปยัง caller ไม่ใช่ helper function — ควรเรียกเสมอใน helper

💡 ตัวอย่างจากของจริง

Kubernetes controller package ใช้ table-driven test — test ร้อยกรณีใน 1 function, เพิ่ม edge case ได้โดยแค่เพิ่มแถวในตาราง

Go standard library ใช้ table-driven test ทั้งหมด — เป็น pattern ที่ทุก Go codebase ควรทำตาม

Docker enforce -race flag ใน CI pipeline ทุก PR เพื่อตรวจสอบ concurrent code

Go toolchain ต้องการ suffix ใดสำหรับไฟล์ test?
ความแตกต่างระหว่าง t.Errorf และ t.Fatalf คืออะไร?
ใน table-driven test, t.Run ให้อะไรแก่แต่ละ case?
ทำไมจึงต้องใช้ -covermode=atomic ร่วมกับ -race?