การเขียน Tests ด้วย go test
เตรียม module สำหรับรันตัวอย่างในบทนี้เอง
หัวข้อที่มีชื่อว่า “เตรียม module สำหรับรันตัวอย่างในบทนี้เอง”ตัวอย่างทุกอันในบทนี้เป็นโค้ด Go จริงที่รันได้ — แต่ go test ต้องมี module อยู่บน disk ซึ่ง playground ในเบราว์เซอร์ให้ไม่ได้ เตรียม module ไว้ครั้งเดียว:
mkdir myapp && cd myappgo mod init example.com/myappเซฟไฟล์ _test.go จากบทนี้ไว้คู่กับไฟล์ .go ต้นทางที่ตรงกันใน directory นั้น แล้วรัน go test ./... ทุกคำสั่งและทุกบรรทัด output ในบทนี้มาจากขั้นตอนนี้จริง บน Go 1.26.5
ไฟล์ test และ naming conventions
หัวข้อที่มีชื่อว่า “ไฟล์ test และ naming conventions”test runner ของ Go ค้นหา tests ผ่านกฎ 2 ข้อที่บังคับใช้โดย toolchain:
- ชื่อไฟล์ ลงท้ายด้วย
_test.go— compiler จะไม่รวมไฟล์เหล่านี้ใน build ปกติ - ชื่อ function ขึ้นต้นด้วย
Testตามด้วยตัวพิมพ์ใหญ่ —TestAdd,TestParseURLเป็นต้น
ไฟล์ test ขั้นต่ำสำหรับ source file math.go อยู่ใน package เดียวกัน และ directory เดียวกัน:
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) }}รันด้วย:
go test ./... # run all tests in all packagesgo test -v ./... # verbose: print each test name and PASS/FAILgo test -run TestAdd # run only tests whose name matches the regexPackage เดียวกัน หรือ package แบบ _test?
หัวข้อที่มีชื่อว่า “Package เดียวกัน หรือ package แบบ _test?”ไฟล์ test เลือกประกาศ package ได้สองแบบ:
package math(package เดียวกับโค้ดที่ทดสอบ) — เรียกว่า “white-box” test เรียก function ที่ไม่ export และอ่าน field ที่ไม่ export ได้ ซึ่งเป็นสิ่งที่ test ส่วนใหญ่ต้องการpackage math_test(ชื่อ package ต่อท้ายด้วย_test) — เรียกว่า “black-box” test เห็นได้เฉพาะสิ่งที่ผู้ import จากภายนอกเห็น เพราะต้องimport "example.com/myapp/math"เหมือน consumer ทั่วไป toolchain ของgo build/go testรองรับกรณีพิเศษนี้: ทั้งสอง package อยู่ใน directory เดียวกันได้ และ compile รวมเป็น test binary เดียวกัน
ใช้ _test เมื่อต้องการพิสูจน์ว่า API สาธารณะทำงานตามที่ documented — นี่คือเหตุผลที่ example ของ standard library เอง (fmt_test, strings_test) เป็น black-box แทบทั้งหมด ใช้ package เดียวกันเมื่อต้องเรียก helper ภายในโดยตรง หรือเมื่อ black-box test ต้องพึ่ง import cycle เพื่อเข้าถึง type ที่ทดสอบ
t.Errorf vs t.Fatalf
หัวข้อที่มีชื่อว่า “t.Errorf vs t.Fatalf”ทั้งคู่บันทึกความล้มเหลว แต่ต่างกันตรงที่ test จะหยุดหรือดำเนินต่อ:
| Function | บันทึกความล้มเหลว | หยุด test? |
|---|---|---|
t.Errorf | ใช่ | ไม่ — test ดำเนินต่อ |
t.Fatalf | ใช่ | ใช่ — test หยุดทันที |
ใช้ t.Fatalf เมื่อดำเนินต่อไปไม่มีประโยชน์อะไร — เช่น err != nil และบรรทัดถัดไปจะ dereference ผลลัพธ์ที่เป็น 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}t.Fatalf (รวมถึง t.Fatal, t.FailNow, t.SkipNow) ทำงานด้วยการเรียก runtime.Goexit บน goroutine ที่กำลังรัน test นั้น มันคลี่ (unwind) เฉพาะ goroutine นั้น เท่านั้น — ดูว่าเกิดอะไรขึ้นเมื่อ goroutine ที่ถูกสร้างขึ้นเรียกมันแทนใน “อ่านโค้ดที่ AI เขียน” ด้านล่าง
Table-driven tests ด้วย t.Run
หัวข้อที่มีชื่อว่า “Table-driven tests ด้วย t.Run”รูปแบบ idiomatic ของ Go สำหรับการทดสอบ input หลายค่ากับ function เดียวกันคือ table-driven test: slice ของ struct cases ที่วนซ้ำด้วย t.Run แต่ละ sub-test มีชื่อ, error message, และ — เมื่อใช้ -v — บรรทัดผลลัพธ์เป็นของตัวเอง
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 { 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 แต่ละตัว:
(โค้ดเก่ามีบรรทัด tc := tc ก่อน t.Run; ตั้งแต่ Go 1.22 แต่ละ iteration ได้ tc เป็นของตัวเองอยู่แล้ว การ copy จึงไม่จำเป็น และ go fix จะลบมันออกให้)
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)ไฟล์นี้ไม่มี func main — จึงรันผ่านปุ่ม Run ของคอร์สนี้ไม่ได้ เพราะปุ่มนั้นต้องการ package main ที่รันได้โดยตรง ให้ paste ลงใน https://go.dev/play/ แทน — Go Playground ตัวจริงจะตรวจพบ function Test แล้วรัน go test ให้เอง พิมพ์ output แบบ -v เหมือนด้านบนทุกอย่าง
t.Parallel(): parent หยุดรอ, subtest กลับมารันหลัง loop จบ
หัวข้อที่มีชื่อว่า “t.Parallel(): parent หยุดรอ, subtest กลับมารันหลัง loop จบ”การเรียก t.Parallel() ใน subtest ไม่ได้ทำให้มันเริ่มรันขนานทันที มันแค่ mark ว่า subtest นี้ parallel-eligible แล้ว หยุด (pause) มันไว้ก่อน; loop ของ parent ที่เรียก t.Run จะเดินหน้าไปสร้าง subtest ถัดไปโดยไม่รอ ต่อเมื่อ function ของ parent return ออกจาก loop แล้วเท่านั้น subtest ที่ถูก pause ไว้ทั้งหมดจึง กลับมารัน (resume) และรันขนานกันจริง ๆ
func TestParallelGroup(t *testing.T) { tests := []string{"a", "b", "c"} for _, name := range tests { t.Run(name, func(t *testing.T) { t.Parallel() t.Logf("running %s", name) }) } t.Log("parent reached end of loop")}Output จริงจาก go test -v -run TestParallelGroup .:
=== RUN TestParallelGroup=== RUN TestParallelGroup/a=== PAUSE TestParallelGroup/a=== RUN TestParallelGroup/b=== PAUSE TestParallelGroup/b=== RUN TestParallelGroup/c=== PAUSE TestParallelGroup/c=== NAME TestParallelGroup parallel_test.go:15: parent reached end of loop=== CONT TestParallelGroup/a parallel_test.go:12: running a=== CONT TestParallelGroup/b parallel_test.go:12: running b=== CONT TestParallelGroup/c parallel_test.go:12: running c--- PASS: TestParallelGroup (0.00s) --- PASS: TestParallelGroup/a (0.00s) --- PASS: TestParallelGroup/b (0.00s) --- PASS: TestParallelGroup/c (0.00s)PASSสังเกตลำดับ: ทุก subtest เป็น RUN แล้ว PAUSE ทันที จากนั้น "parent reached end of loop" ถึงพิมพ์ออกมา แล้วบรรทัด CONT ทั้งสามถึงปรากฏ — ในตัวอย่างนี้เรียงติดกัน แต่จริง ๆ แล้วมันรันขนานกันจริง ดังนั้นบรรทัด log ภายในของแต่ละตัวสลับกันได้เมื่อ test ยุ่งกว่านี้ นี่คือเหตุผลที่ cleanup ที่ parent ทำ หลัง loop (ปิดไฟล์ที่ใช้ร่วมกัน, หยุด server) อาจ race กับ subtest ที่ parallel ซึ่งยังไม่รัน: มันแค่ถูกเข้าคิวไว้เท่านั้น
Test helper: t.Helper, t.Cleanup, t.TempDir, t.Setenv, t.Context, t.Chdir
หัวข้อที่มีชื่อว่า “Test helper: t.Helper, t.Cleanup, t.TempDir, t.Setenv, t.Context, t.Chdir”t.Helper() mark function ที่เรียกมันว่าเป็น test helper ทำให้ error ที่รายงานจากข้างในถูก attribute ไปที่บรรทัดของ caller ไม่ใช่บรรทัดของ helper เอง:
func assertPositive(t *testing.T, n int) { t.Helper() if n <= 0 { t.Fatalf("got %d, want positive", n) }}
func TestHelperFailure(t *testing.T) { assertPositive(t, -1) // line 16}Output จริงตอน fail — สังเกตเลขบรรทัด:
--- FAIL: TestHelperFailure (0.00s) helper_test.go:16: got -1, want positiveบรรทัดที่ 16 คือจุดที่เรียกใน TestHelperFailure ไม่ใช่บรรทัด t.Fatalf ข้างใน assertPositive — นี่คือสิ่งที่ t.Helper() ทำ ถ้าไม่มีมัน ทุก error จาก assertion helper ที่ใช้ร่วมกันจะชี้ไปที่ไฟล์ helper แทน แล้วคุณต้องเดาเองว่า call site ไหนที่ fail จริง
ส่วนที่เหลือของกลุ่มนี้จัดการ fixture ให้อัตโนมัติ ไม่ต้องเขียน teardown เอง:
t.Cleanup(f)ลงทะเบียนfให้รันหลัง test (และ subtest ของมัน) จบ ตามลำดับ เพิ่มทีหลังสุด เรียกก่อนสุด — สลับกับdeferใน function เดียว แต่เป็นแนวคิดเดียวกันในระดับ test ทั้งตัวt.TempDir()คืน directory ชั่วคราวใหม่ ถูกลบอัตโนมัติเมื่อ test จบ แต่ละครั้งที่เรียกจะได้ directory ที่ไม่ซ้ำกันt.Setenv(key, value)ตั้งค่า environment variable แล้วคืนค่าเดิมผ่านt.Cleanupให้อัตโนมัติt.Context()(Go 1.24) คืนcontext.Contextที่ถูก cancel ก่อน function ของCleanupจะรันเล็กน้อย — ส่งให้โค้ดที่ทดสอบซึ่งต้องการ context ที่ผูกกับอายุของ testt.Chdir(dir)(Go 1.24) เปลี่ยน working directory ของ process แล้วคืนค่าเดิมผ่านCleanup
t.Setenv และ t.Chdir ทั้งคู่แก้ state ระดับ ทั้ง process (environment variable และ working directory ไม่ได้แยกตาม goroutine) ดังนั้น go doc จึงระบุชัดว่าใช้ทั้งสองตัวใน parallel test หรือ test ที่มี parallel ancestor ไม่ได้ — toolchain จะปฏิเสธตอน runtime ด้วย t.Setenv called after t.Parallel
Output จริงเมื่อรันทั้งห้าตัวรวมกัน:
=== RUN TestCleanupOrder helper_test.go:22: test body helper_test.go:21: cleanup 2 helper_test.go:20: cleanup 1--- PASS: TestCleanupOrder (0.00s)=== RUN TestTempDirAndSetenv helper_test.go:31: tempdir=/var/folders/.../TestTempDirAndSetenv1802591350/001--- PASS: TestTempDirAndSetenv (0.00s)=== RUN TestContextAndChdir--- PASS: TestContextAndChdir (0.00s)TestCleanupOrder ลงทะเบียน “cleanup 1” ก่อนแล้วค่อย “cleanup 2” แต่พิมพ์ออกมาสลับลำดับกัน ยืนยันกฎเพิ่มทีหลังสุด-เรียกก่อนสุด
TestMain: setup และ teardown สำหรับทั้ง package
หัวข้อที่มีชื่อว่า “TestMain: setup และ teardown สำหรับทั้ง package”ไฟล์หนึ่งไฟล์ประกาศ func TestMain(m *testing.M) ได้ครั้งเดียวต่อ package เพื่อครอบการรัน test ทั้งหมด — เริ่ม resource ที่ใช้ร่วมกันก่อน test ใด ๆ จะรัน แล้วปล่อยมันหลังจบ:
func TestMain(m *testing.M) { fmt.Println("setup: acquiring shared resource") code := m.Run() fmt.Println("teardown: releasing shared resource") os.Exit(code)}Output จริง:
setup: acquiring shared resource=== RUN TestA main_test.go:16: A--- PASS: TestA (0.00s)=== RUN TestB main_test.go:17: B--- PASS: TestB (0.00s)PASSteardown: releasing shared resourcem.Run() รัน test ทุกตัวแล้วคืน exit code ของ process; คุณต้องเรียก os.Exit(code) เอง — ถ้า TestMain return โดยไม่เรียกมัน test binary จะ exit ด้วย 0 ไม่ว่าจะมี test fail หรือไม่ ใช้ TestMain สำหรับ setup ที่มีค่าใช้จ่ายสูงและครอบทั้ง package (spin up test database container); ใช้ t.Cleanup สำหรับสิ่งที่ scope อยู่แค่ test เดียว
Flag ที่สำคัญ
หัวข้อที่มีชื่อว่า “Flag ที่สำคัญ”| Flag | ผล |
|---|---|
-count=1 | ปิด cache ผลลัพธ์ของ test — บังคับให้รันจริงอีกครั้ง go test cache ผลที่ผ่านไว้โดย default เมื่อไม่มีอะไรเกี่ยวข้องเปลี่ยน |
-shuffle=on | สุ่มลำดับที่ test และ top-level benchmark รัน (ตาม go help testflag) เพื่อเปิดโปง ordering dependency ที่ซ่อนอยู่ระหว่าง test |
-failfast | หยุดรัน test ใหม่หลัง fail ตัวแรก — มีประโยชน์สำหรับ feedback loop ที่เร็วตอน local |
-run 'TestGroup/sub1' | filter ทั้งชื่อ test ระดับบนสุดและ ชื่อ subtest หลัง slash — แต่ละครึ่งเป็น regular expression ของตัวเอง |
-timeout d | panic ทั้ง test binary ถ้ารันนานเกิน d (default 10 นาที); -timeout 0 ปิดมัน |
Output จริงจากการรันแบบ filter:
go test -v -run 'TestGroup/sub1' .# === RUN TestGroup# === RUN TestGroup/sub1# sub_test.go:6: sub1# --- PASS: TestGroup (0.00s)# --- PASS: TestGroup/sub1 (0.00s)มีแค่ sub1 ที่รัน — sub2 ไม่ได้เริ่มเลย เพราะไม่ตรงกับ pattern หลัง slash
เปรียบเทียบค่า: reflect.DeepEqual, go-cmp, testify และ errors.Is
หัวข้อที่มีชื่อว่า “เปรียบเทียบค่า: reflect.DeepEqual, go-cmp, testify และ errors.Is”reflect.DeepEqual คือวิธีของ standard library สำหรับเปรียบเทียบ composite value แต่มันมีมุมแหลมคม:
var nilSlice []intemptySlice := []int{}reflect.DeepEqual(nilSlice, emptySlice) // false — a nil slice and an empty slice are NOT DeepEqualยืนยันแล้วจริง: ค่านี้ได้ false test ที่สร้าง want := []int{} แต่ function ที่ทดสอบคืน nil ในกรณีว่าง จะ fail ตรงนี้ ทั้งที่ทั้งสองค่า “ว่าง” เหมือนกันทุกทางปฏิบัติ
github.com/google/go-cmp/cmp (cmp.Diff, cmp.Equal) คือเครื่องมือ third-party ที่ Go codebase ส่วนใหญ่หันไปใช้แทน: มัน diff ให้อ่านง่ายตอน fail และ — ต่างจาก DeepEqual — มัน panic เมื่อเจอ field ที่ไม่ export เว้นแต่คุณเลือกใช้ cmpopts.IgnoreUnexported หรือคล้ายกัน ยืนยันแล้วจริงกับ go-cmp v0.7.0:
type Point struct { X, Y int label string // unexported}cmp.Diff(Point{1, 2, "a"}, Point{1, 2, "b"})panic: cannot handle unexported field at {cmpdemo.Point}.label: "example.com/cmpdemo".Pointconsider using cmpopts.EquateComparable to compare comparable Go typespanic นั้นคือ feature ไม่ใช่ bug: การเมิน field ที่ไม่ export แบบเงียบ ๆ (ซึ่ง DeepEqual ไม่ได้ทำแบบนั้นจริง ๆ — มัน เปรียบเทียบ field ที่ไม่ export ด้วย ซึ่งอาจทำให้สองค่าที่เท่ากันในทางตรรกะเทียบไม่เท่ากันเพราะ field cache ภายใน) เป็น false-negative test แบบที่ go-cmp ออกแบบมาเพื่อบังคับให้คุณตัดสินใจอย่างชัดเจน github.com/stretchr/testify (assert.Equal, require.Equal) ใช้วิธีที่สาม — ใช้ ObjectsAreEqual (คร่าว ๆ คือ reflect.DeepEqual บวก fast path สำหรับ byte slice) และเป็นที่นิยมหลัก ๆ เพราะ assertion helper และการจัด format ข้อความ error ไม่ใช่เพราะ algorithm การเปรียบเทียบต่างออกไปโดยพื้นฐาน
สำหรับ error อย่าเปรียบเทียบด้วย == หรือ DeepEqual — ใช้ errors.Is ซึ่งเดินตาม Unwrap chain:
if !errors.Is(err, os.ErrNotExist) { t.Fatalf("got %v, want a not-exist error", err)}ผ่านแม้ err จะถูก wrap ไว้ (fmt.Errorf("open config: %w", os.ErrNotExist)) ซึ่งกรณีนี้ err == os.ErrNotExist จะได้ false
Fuzzing ด้วย *testing.F
หัวข้อที่มีชื่อว่า “Fuzzing ด้วย *testing.F”fuzz test เริ่มจาก seed corpus ที่เป็น input ตัวอย่าง แล้ว mutation engine ของ Go จะสร้าง input ใหม่มาลองทำลาย invariant ที่คุณ assert ไว้:
func Reverse(s string) string { r := []rune(s) for i, j := 0, len(r)-1; i < j; i, j = i+1, j-1 { r[i], r[j] = r[j], r[i] } return string(r)}func FuzzReverse(f *testing.F) { f.Add("hello") f.Add("") f.Fuzz(func(t *testing.T, s string) { rev := Reverse(s) doubleRev := Reverse(rev) if s != doubleRev { t.Errorf("Reverse(Reverse(%q)) = %q, want %q", s, doubleRev, s) } })}go test -run FuzzReverse . รันเฉพาะสอง seed corpus ที่เพิ่มด้วย f.Add เหมือน test ปกติ go test -fuzz FuzzReverse -fuzztime 2s . คือ fuzz จริง ๆ — และกับ function นี้เป๊ะ ๆ มันเจอ bug จริงที่ reproduce ได้ภายในสองวินาที:
fuzz: elapsed: 0s, gathering baseline coverage: 2/2 completed, now fuzzing with 11 workersfuzz: minimizing 47-byte failing input file--- FAIL: FuzzReverse (0.01s) --- FAIL: FuzzReverse (0.00s) reverse_test.go:12: Reverse(Reverse("\xb1")) = "�", want "\xb1"
Failing input written to testdata/fuzz/FuzzReverse/ffedc6f1114c9b4fTo re-run:go test -run=FuzzReverse/ffedc6f1114c9b4fbyte \xb1 ตัวเดียวไม่ใช่ UTF-8 ที่ valid Reverse แปลง string ด้วย []rune(s) ซึ่งแทนที่ byte ที่ไม่ valid ด้วย U+FFFD (replacement character) — reverse สองครั้งจึงเสีย information แทนที่จะ round-trip กลับมาเหมือนเดิม นี่คือ bug ประเภทที่ fuzzing มีไว้เพื่อหา: function ที่ดูถูกต้องชัดเจนสำหรับ string “ปกติ” แต่พังเงียบ ๆ กับ input ที่ผิดรูปแบบ ซึ่ง test suite ที่ AI เขียนด้วยมือไม่มีทางคิดจะใส่ input แบบนี้ กรณีที่ fail ถูกบันทึกไว้ใต้ testdata/fuzz/; การ commit ไฟล์นั้นเปลี่ยนการค้นพบให้กลายเป็น regression test ถาวรที่รันได้ด้วย go test ธรรมดาเช่นกัน
Example functions พร้อม // Output:
หัวข้อที่มีชื่อว่า “Example functions พร้อม // Output:”function Example ทำหน้าที่สองอย่าง: เป็น documentation (แสดงบน pkg.go.dev) และเป็น test จริงที่รันได้ — go test จับทุกอย่างที่ function พิมพ์ลง stdout แล้วเทียบทีละบรรทัดกับ comment // Output:
func ExampleGreet() { fmt.Println(Greet("Ada")) // Output: Hello, Ada!}Output จริง:
=== RUN ExampleGreet--- PASS: ExampleGreet (0.00s)ถ้าลบหรือทำบรรทัด // Output: เพี้ยน go test จะเลิกนับ function นั้นเป็น test ไปเลย — มัน compile เป็น function เฉย ๆ ที่ไม่ถูกเรียก — ดังนั้น output comment ที่หายไปจึง fail แบบเงียบแทนที่จะดังชัดเจน // Unordered output: รับบรรทัดในลำดับใดก็ได้ มีประโยชน์กับ output ที่มาจากการวน map
Mocking ผ่าน interface ด้วย httptest
หัวข้อที่มีชื่อว่า “Mocking ผ่าน interface ด้วย httptest”Go ไม่ต้องใช้ mocking framework สำหรับ HTTP: net/http/httptest ให้ server จริงที่ผูกกับ local port แบบสุ่ม จึงทดสอบ code path ของ client จริง แทนที่จะเป็น fake ที่เขียนขึ้นเอง
func FetchUser(client *http.Client, baseURL string) (User, error) { resp, err := client.Get(baseURL + "/user") if err != nil { return User{}, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return User{}, fmt.Errorf("unexpected status: %s", resp.Status) } var u User if err := json.NewDecoder(resp.Body).Decode(&u); err != nil { return User{}, err } return u, nil}func TestFetchUser(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.Write([]byte(`{"name":"Ada"}`)) })) defer srv.Close()
u, err := FetchUser(srv.Client(), srv.URL) if err != nil { t.Fatalf("FetchUser: %v", err) } if u.Name != "Ada" { t.Errorf("Name = %q, want Ada", u.Name) }}Output จริง: --- PASS: TestFetchUser (0.00s) สำหรับทดสอบ handler แยกเดี่ยว ๆ (ไม่มี network socket จริงเลย) ให้ใช้ httptest.NewRecorder() คู่กับ handler.ServeHTTP(recorder, request) แทน — เร็วกว่า และเป็นตัวเลือกมาตรฐานสำหรับ unit-test handler ตัวเดียว มากกว่า client ที่เรียกมัน
Golden files
หัวข้อที่มีชื่อว่า “Golden files”golden file เก็บ output ที่คาดหวังไว้บน disk แล้วเปรียบเทียบกับมัน ซึ่ง scale ได้ดีกว่า string literal แบบ inline เมื่อ output ที่คาดหวังยาวเกินไม่กี่บรรทัด (report ที่ render แล้ว, JSON payload, โค้ดที่ generate)
var update = flag.Bool("update", false, "update golden files")
func TestRenderReport(t *testing.T) { got := RenderReport([]string{"apples", "bananas"}) golden := filepath.Join("testdata", "report.golden")
if *update { os.WriteFile(golden, []byte(got), 0o644) }
want, err := os.ReadFile(golden) if err != nil { t.Fatal(err) } if got != string(want) { t.Errorf("RenderReport() mismatch\ngot:\n%s\nwant:\n%s", got, want) }}ยืนยันแล้วจริง: รันครั้งแรกด้วย go test -run TestRenderReport -update . เพื่อเขียน testdata/report.golden จาก output ปัจจุบัน (ที่รีวิวแล้ว!) แล้ว commit ไฟล์นั้นไว้ ทุกครั้งที่รัน go test หลังจากนั้นโดยไม่ใส่ -update จะเทียบกับไฟล์นั้น และ fail ดังทันทีที่ output เปลี่ยนไป:
=== RUN TestRenderReport render_test.go:24: open testdata/report.golden: no such file or directory--- FAIL: TestRenderReport (0.00s)นี่คือ error จริงจากการรันครั้งแรก ก่อนที่ golden file จะมีอยู่ — สัญญาณแบบที่คุณต้องการเป๊ะ: golden file ที่หายไปหรือเก่าเกินจะ fail build แทนที่จะผ่านแบบเงียบ ๆ
go tool cover -html
หัวข้อที่มีชื่อว่า “go tool cover -html”-coverprofile เขียนข้อมูล coverage ที่เครื่องอ่านได้; go tool cover -html เปลี่ยนมันเป็น report ที่เปิดในเบราว์เซอร์ได้ โดยบรรทัดที่ถูก cover เป็นสีเขียวและที่ไม่ถูก cover เป็นสีแดง:
go test -coverprofile=coverage.out ./...go tool cover -html=coverage.out -o coverage.htmlยืนยันแล้วจริง: คำสั่งนี้สร้าง coverage.html บน disk จริง (ยืนยันด้วย ls -la) และ go tool cover -func=coverage.out พิมพ์ข้อมูลเดียวกันเป็นตาราง percentage ต่อ function ใน terminal โดยไม่ต้องใช้เบราว์เซอร์เลย — มีประโยชน์ใน CI log ที่ไม่มีใครจะเปิดไฟล์ HTML
ทดสอบโค้ด concurrent ด้วย testing/synctest (Go 1.25+)
หัวข้อที่มีชื่อว่า “ทดสอบโค้ด concurrent ด้วย testing/synctest (Go 1.25+)”test ที่มี goroutine, timer หรือ timeout เคยต้องใช้ sleep จริงและมักจะ flaky testing/synctest รัน function ใน bubble ที่เวลาเป็นแบบ virtual: time.Sleep และ timer เดินหน้าทันทีเมื่อ goroutine ทุกตัวใน bubble block อยู่ และ synctest.Wait() จะ block จนกว่าทุกตัวจะ block
// cache_test.go — a test file; run it with: go test ./...package cache_test
import ( "testing" "testing/synctest" "time")
// Cache is a minimal TTL cache: entries expire lazily, checked on Get —// there is no background goroutine driving expiry.type Cache struct { ttl time.Duration items map[string]cacheItem}
type cacheItem struct { value string expires time.Time}
func NewCache(ttl time.Duration) *Cache { return &Cache{ttl: ttl, items: make(map[string]cacheItem)}}
func (c *Cache) Set(key, value string) { c.items[key] = cacheItem{value: value, expires: time.Now().Add(c.ttl)}}
func (c *Cache) Get(key string) (string, bool) { item, ok := c.items[key] if !ok || time.Now().After(item.expires) { return "", false } return item.value, true}
func TestCacheExpiry(t *testing.T) { synctest.Test(t, func(t *testing.T) { c := NewCache(1 * time.Minute) c.Set("k", "v")
time.Sleep(2 * time.Minute) // the fake clock jumps ahead once every goroutine in the bubble is blocked
if _, ok := c.Get("k"); ok { t.Fatal("entry should have expired") } })}ในตัวอย่างนี้ไม่มี synctest.Wait() เพราะ Get เช็ก expiry แบบ synchronous กับ fake clock โดยตรง จึงไม่มี goroutine พื้นหลังใดต้องรอ Wait() จำเป็นเฉพาะตอนที่ goroutine อื่น ใน bubble ต้องทำงานให้เสร็จก่อนคุณจะ assert ผลลัพธ์ของมันได้
Go 1.27 เพิ่ม synctest.Sleep(d) (sleep แล้ว Wait ต่อ) และ httptest.NewTestServer() ซึ่งเป็น httptest.Server บน fake network ใน memory ที่ทำงานใน bubble ได้ — HTTP client ที่ตั้ง timeout 30 วินาทีจึงทดสอบได้ในระดับ microsecond ทั้งสองอย่างยังไม่มีใน toolchain ท้องถิ่นของคอร์สนี้ (Go 1.26.5) — snippet ด้านบน compile และ pass บน 1.26.5 ได้ตามที่เขียน ส่วน synctest.Sleep/NewTestServer ให้ลองบน go.dev หรือ Go 1.27 ในเครื่อง
Artifact และ attribute ของ test (Go 1.25–1.26)
หัวข้อที่มีชื่อว่า “Artifact และ attribute ของ test (Go 1.25–1.26)”t.Attr(key, value)(1.25) แนบ metadata ที่โผล่ใน output แบบ-json— สะดวกสำหรับผูก test กับ issue หรือ trace IDt.Output()(1.25) คือio.Writerที่ส่งให้ logger ได้ เพื่อให้บรรทัด log แทรกอยู่ในลำดับที่ถูกต้องกับ log ของ testt.ArtifactDir()(1.26) คืน directory สำหรับไฟล์ที่ test อยากเก็บไว้ — screenshot, dump, golden file รันด้วยgo test -artifactsแล้ว runner จะพิมพ์=== ARTIFACTS TestName /pathและเก็บ directory ไว้ใต้-outputdir
Race detector
หัวข้อที่มีชื่อว่า “Race detector”เพิ่ม -race เพื่อตรวจจับ data races ในเวลา test — นี่คือสิ่งที่ต้องมีใน CI:
go test -race -coverprofile=coverage.out -covermode=atomic ./...go test -race เปลี่ยน coverage mode เป็น -covermode=atomic ให้อัตโนมัติ — go help testflag ระบุว่าค่า default คือ set เว้นแต่เปิด -race ซึ่งจะกลายเป็น atomic การใส่ -covermode=atomic เองจึงไม่จำเป็น เพียงแต่ทำให้อ่านชัดขึ้น
go test ยังรัน go vet ชุดย่อยเล็ก ๆ ก่อนทุกครั้ง ตั้งแต่ Go 1.27 ชุดนั้นรวม stdversion ที่ทำให้ build fail เมื่อคุณเรียก API ของ standard library ที่ใหม่กว่าบรรทัด go ใน go.mod — module ที่ระบุ go 1.25 จึงแอบพึ่ง function ของ 1.27 ไม่ได้
วงจรชีวิตของ go test
หัวข้อที่มีชื่อว่า “วงจรชีวิตของ go test”flowchart TD
A["go test ./..."] --> B["compile _test.go files into a test binary"]
B --> C{"TestMain defined?"}
C -->|yes| D["TestMain(m) calls m.Run()"]
C -->|no| E["default runner calls each TestXxx in file order"]
D --> E
E --> F{"t.Parallel called?"}
F -->|yes| G["subtest PAUSEs, next subtest starts"]
F -->|no| H["subtest runs to completion"]
G --> I["once the parent function returns, all paused subtests CONT and run concurrently"]
H --> J["go vet subset + PASS/FAIL report"]
I --> J ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| สิ่งที่ได้ | ประโยชน์ | ต้นทุน |
|---|---|---|
| table-driven test | test หลาย case ใน 1 function | ถ้า table ยาวเกิน อ่านยาก |
t.Run() subtests | isolate failure, parallel-friendly | nested output อ่านยากขึ้น |
t.Parallel() | รัน test ขนาน เร็วขึ้น | ต้องระวัง shared state; helper ระดับ process อย่าง t.Setenv/t.Chdir ใช้ไม่ได้อีก |
-race flag | ตรวจหา race condition อัตโนมัติ | ช้าลง 2-10x — ใช้ใน CI ไม่ใช่ทุก local run |
Fuzzing (*testing.F) | หา edge case ที่ไม่มีใครคิดจะเขียนด้วยมือ | ต้องใช้เวลา CPU ถึงจะได้ผล — fuzz run สะอาด 2 วินาทีพิสูจน์ได้น้อยกว่ารันเป็นชั่วโมง |
| Golden files | รองรับ expected output ขนาดใหญ่ diff ได้ตอน review | ต้องมี flag -update และวินัยในการ review diff ก่อน commit |
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”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) และ
_testpackage (black-box) — เลือกตาม use case t.Helper()optional —t.Helper()ทำให้ error report ชี้ไปยัง caller ไม่ใช่ helper function — ควรเรียกเสมอใน helpert.Parallel()ทำให้ subtest เริ่มรันทันที — จริง ๆ มันหยุด subtest ไว้ก่อน; subtest ที่ถูกหยุดทั้งหมดจะกลับมารันพร้อมกันก็ต่อเมื่อ loopt.Runของ parent return แล้วเท่านั้น- fuzz test ที่รันสะอาดไม่กี่วินาทีพิสูจน์ว่า function ถูกต้อง — มันพิสูจน์แค่ว่าไม่พบ input ที่ fail ในเวลานั้น;
Reverseด้านบนดูถูกต้องจนกระทั่ง fuzz รันได้สองวินาที
💡 ตัวอย่างจากของจริง
Go standard library —
strconv/number_test.goเก็บ test case เป็นตารางparseInt64Testsแล้ววนตรวจในTestParseInt; เพิ่ม edge case ใหม่คือเพิ่มแถวเดียวในตาราง (เปิดดูได้ที่$(go env GOROOT)/src/strconv/number_test.goในเครื่องที่ติดตั้ง Go) packagestringsของ standard library เองใช้ functionExampleอย่างหนัก (strings/example_test.go) — คือสิ่งที่คุณเห็น render อยู่บนpkg.go.dev/strings
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”test ที่ generate มา compile ผ่านบ่อยกว่าที่มันจับ bug ได้จริง เช็กเรื่องพวกนี้:
t.Parallel()ถูกเรียกในforloop วน slice บน Go version ก่อน 1.22 โดยไม่มีบรรทัด capturetc := tcหรือเปล่า? ถ้าใช่ ทุก subtest จะแชร์ loop value ตัวสุดท้ายร่วมกัน- goroutine ที่ถูก spawn ขึ้นข้างใน test เรียก
t.Fatal,t.FailNowหรือt.Fatalfหรือเปล่า? function พวกนี้ต้องถูกเรียกจาก goroutine ของ test เอง — เอกสารระบุชัดว่าเรียกจาก goroutine อื่น แม้แต่ตัวที่ test เป็นคนสร้างขึ้นเอง ก็เป็น bug - benchmark หรือ test ทำ setup ที่มีค่าใช้จ่ายสูง (สร้าง slice ขนาดใหญ่, เปิดไฟล์) ข้างใน loop ที่ควรวัดหรือทดสอบเฉพาะ operation ที่สนใจหรือเปล่า?
- closure
f.Fuzzของ*testing.Fเช็ก round-trip หรือ invariant จริง ๆ หรือแค่ assert ซ้ำกับ input ที่ใส่เข้าไปเฉย ๆ — fuzz test ที่ fail ไม่ได้เลยไม่ได้ทดสอบอะไรจริง ๆ - test เปรียบเทียบสอง struct ด้วย
==หรือreflect.DeepEqualทั้งที่ตัวใดตัวหนึ่งมีtime.Timeหรือ struct อื่นที่การเปรียบเทียบควร ignore field ภายในบางตัวหรือเปล่า? - มีไฟล์
_test.goที่ชื่อ package ไม่ตรงกับ package ของ directory หรือ<package>_testหรือเปล่า — เป็น copy-paste mistake ที่พบบ่อยและทำให้ compile ไม่ผ่าน
🐛 หา bug ในโค้ดนี้
func TestGroupPreGo122(t *testing.T) { names := []string{"a", "b", "c"} for _, name := range names { t.Run(name, func(t *testing.T) { t.Parallel() if name == "" { t.Fatal("empty name") } t.Logf("checked %s", name) }) }}เฉลย
บน Go version ก่อน 1.22, name เป็นตัวแปรตัวเดียวที่ loop ทุก iteration ใช้ร่วมกัน เพราะ t.Parallel() หยุด subtest แต่ละตัวไว้ก่อนที่มันจะอ่าน name ใน closure subtest ทั้งสามตัวจึงกลับมารันพร้อมกัน หลัง loop จบไปแล้ว ตอนนั้น name มีค่าสุดท้ายคือ "c" แล้ว ดังนั้นทุก subtest จึง log checked c แทนที่จะเป็นชื่อของตัวเอง ตั้งแต่ Go 1.22 แต่ละ iteration ได้ name เป็นของตัวเอง โค้ดนี้เป๊ะ ๆ จึงปลอดภัยบน 1.22 ขึ้นไป — แต่การอ่านโค้ดแบบนี้ใน codebase เก่า (หรือที่ยังไม่ยก go.mod line ขึ้น) โดยไม่รู้จักกับดักนี้ ก็คือสิ่งที่คอร์สนี้มีไว้เพื่อจับให้ได้ วิธีแก้ที่ใช้ได้ทุก version คือ capture ให้ชัดเจน: ใส่ name := name เป็นบรรทัดแรกในลูป ก่อน t.Run