1 · ระดับของการทดสอบ — ปิรามิดเทส
╱╲
╱ ╲ E2E น้อย · ช้า · แพง · เปราะ
╱────╲ ทดสอบทั้งระบบเสมือนผู้ใช้จริง
╱ ╲
╱ Integr. ╲ Integration ปานกลาง
╱──────────╲ ทดสอบว่าโมดูลคุยกันรู้เรื่อง
╱ ╲
╱ Unit Tests ╲ Unit เยอะ · เร็ว · ถูก · เสถียร
╱────────────────╲ ทดสอบฟังก์ชันเดียวแบบแยกเดี่ยว
| ระดับ | ทดสอบอะไร | ต่อ database/network ไหม | ความเร็ว |
|---|---|---|---|
| Unit | ฟังก์ชัน/method เดียว แยกเดี่ยว (isolated) | ไม่ | มิลลิวินาที |
| Integration | หลายโมดูลทำงานร่วมกัน รับส่งข้อมูลถูกไหม | ใช่ | วินาที |
| E2E | ตั้งแต่ต้นจนจบ จำลองการใช้งานจริงของผู้ใช้ | ใช่ ทุกอย่าง | นาที |
ทำไมฐานต้องกว้าง: unit test พัง = รู้ทันทีว่าฟังก์ชันไหน · E2E พัง = รู้แค่ว่า "ระบบพัง" ต้องไปไล่หาเอง เทสที่บอกไม่ได้ว่าอะไรพัง มีค่าน้อยกว่าเทสที่บอกได้ Day 5 ทั้งวันโฟกัสที่ชั้นล่างสุด — unit test
2 · กฎข้อที่ 1: ชื่อไฟล์ต้องลงท้าย _test.go
// ไฟล์ชื่อ mytests.go (ไม่มี _test)
package notest
import "testing"
func TestSomething(t *testing.T) { t.Log("hi") }
สั่ง go test ./... แล้วเกิดอะไรขึ้น?
? notest [no test files] — ผมรันทดสอบแล้ว
Go ไม่รู้จักไฟล์นี้ว่าเป็นเทสเลย ไม่มี error ไม่มี warning
แค่บอกว่าไม่มีไฟล์เทส — คนที่เพิ่งเริ่มมักงงว่า "เขียนเทสแล้วทำไมไม่รัน"
คำตอบคือชื่อไฟล์
กฎ: ชื่ออะไรก็ได้ แต่ต้องลงท้ายด้วย _test.go
นิยมตั้งชื่อคู่กับไฟล์จริง:
admin_handler.go
admin_handler_test.go ← วางไว้คู่กัน folder เดียวกัน
ออฟฟิศหลายที่มีตะกร้าวางหน้าห้องบัญชี ติดป้ายไว้ว่า “ใบเสร็จ”
เจ้าหน้าที่หยิบเฉพาะซองที่หน้าซองเขียนว่าใบเสร็จ เขาไม่ได้แกะทุกซองมาดูข้างใน
ถ้าคุณใส่ใบเสร็จจริง ๆ ลงในซองที่เขียนหน้าซองว่า “เอกสารทั่วไป” ซองนั้นก็แค่ถูกวางกองไว้เฉย ๆ
go test ทำแบบเดียวกันเป๊ะ มันคัดจากชื่อไฟล์ก่อนเสมอ
ไฟล์ที่ไม่ลงท้าย _test.go คือไฟล์โปรแกรมธรรมดาในสายตามัน
ต่อให้ข้างในเขียนเทสไว้เต็มไฟล์ก็ตาม
ที่ไม่เหมือนกัน: เจ้าหน้าที่บัญชียังเดินมาบอกคุณทีหลังได้ว่าเขียนป้ายผิด
แต่ Go ไม่บอกอะไรเลย มันขึ้นแค่ [no test files] ซึ่งอ่านเผิน ๆ เหมือนเป็นเรื่องปกติ
- test runner
- “ตัวรันเทส” — คนที่ไล่หาไฟล์เทส รันให้ แล้วสรุปว่าผ่านหรือไม่ผ่าน ·
ของ Go คือคำสั่ง
go testเอง · พูดกับ dev ภาษาอื่นว่า “Go มี test runner มาในตัว ไม่ต้องลง Jest หรือ JUnit” เขาเข้าใจทันที - naming convention
- “ข้อตกลงเรื่องการตั้งชื่อ” — ข้อตกลงว่าชื่อแบบไหนแปลว่าอะไร เช่น ตัวแปรเขียน
camelCaseหรือไฟล์เทสลงท้าย_test.go· ส่วนใหญ่เป็นข้อตกลงระหว่างคน ไม่มีเครื่องมือบังคับ · ถามได้ว่า “naming convention ของไฟล์เทสภาษานี้คืออะไร” - convention over configuration
- “ถือชื่อเป็นกฎ แทนที่จะให้เขียนไฟล์ตั้งค่า” — กรณีที่เครื่องมือบังคับตามข้อตกลงชื่อจริงจัง ·
go testตัดสินจาก_test.goล้วน ๆ ไม่มีไฟล์ config ให้ไปแก้ คือตัวอย่างเป๊ะ · นี่คือคำที่ควรใช้เวลาอยากบอกว่า “เครื่องมือมันดูจากชื่อเอาเลย” - silent failure
- “พังแบบเงียบ ๆ” — ไม่มี error ไม่มี warning แต่ผลไม่ใช่อย่างที่ตั้งใจ ·
เจอ
[no test files]ทั้งที่เขียนเทสไว้แล้ว คือ silent failure ตัวคลาสสิกของบทนี้
3 · กฎข้อที่ 2: ชื่อฟังก์ชันต้องขึ้นต้นด้วย Test
func TestDiscount(t *testing.T) {
// โค้ดเทส
}
- ต้องขึ้นต้นด้วย
Test(T ตัวใหญ่) - ตัวถัดจาก
Testต้องเป็นตัวใหญ่หรือ_—Testdiscountจะไม่ถูกมองว่าเป็นเทส - รับ
t *testing.Tตัวเดียว ไม่คืนค่าอะไร tคือตัวที่ test runner ส่งเข้ามาให้ควบคุมการทดสอบและเก็บผลลัพธ์ — รายละเอียดอยู่ใน บทเรียน 15
4 · กฎข้อที่ 3 (ข้อยกเว้นพิเศษ): 1 folder มีได้ 2 package
ปกติ Go บังคับว่า 1 folder = 1 package —
แต่มีข้อยกเว้นเดียวในภาษา คือ package ที่ลงท้ายด้วย _test
อยู่ร่วม folder เดียวกันได้ ทำให้เกิดสองสไตล์:
สมมติคุณต้องตรวจคุณภาพร้านก๋วยเตี๋ยวร้านหนึ่ง ทำได้สองท่า
· เดินเข้าไปในครัว — เปิดหม้อชิมน้ำซุปได้ ชิมซอสลับที่ไม่มีในเมนูก็ได้ เห็นทุกอย่างที่ร้านมี
· นั่งโต๊ะแล้วสั่งจากเมนู — เห็นเฉพาะที่ร้านตั้งใจขาย
สั่งของที่ไม่มีในเมนูไม่ได้ พนักงานจะตอบว่า “ไม่มีค่ะ”
Internal test คือคนที่เดินเข้าครัว · External test คือลูกค้าที่นั่งโต๊ะ
นี่คือเหตุผลที่ไฟล์ external ต้อง import package ตัวเองเข้ามา —
เพราะมันยืนอยู่นอกร้านจริง ๆ ต้องเดินเข้าประตูหน้าเหมือนคนอื่น
ที่ไม่เหมือนกัน: ในร้านจริง ครัวกับโต๊ะลูกค้าอยู่คนละที่ แต่ใน Go ไฟล์สองแบบนี้วางอยู่โฟลเดอร์เดียวกันได้ และไม่ต้องเลือกอย่างใดอย่างหนึ่ง — เขียนคู่กันทั้งสองแบบได้เลย
Internal — package calc
// calc_test.go
package calc // ชื่อเดียวกับไฟล์จริง
import "testing"
func TestPrivate(t *testing.T) {
// เรียก unexported ได้เลย
if double(21) != 42 {
t.Error("พัง")
}
}
External — package calc_test
// calc_external_test.go
package calc_test // ลงท้าย _test
import (
"testing"
"myapp/calc" // ต้อง import!
)
func TestPublic(t *testing.T) {
calc.Discount(100, 10)
}
Internal (package calc) | External (package calc_test) | |
|---|---|---|
| ต้อง import package ตัวเอง | ❌ ไม่ต้อง | ✅ ต้อง |
| เทส unexported (ตัวเล็ก) ได้ | ✅ ได้ | ❌ ไม่ได้ |
| มุมมอง | คนใน — เห็นทุกอย่าง | คนนอก — เห็นเท่าที่ผู้ใช้จริงเห็น |
| เหมาะกับ | ตรรกะภายในที่ซับซ้อน | ตรวจว่า public API ใช้งานได้จริง |
package calc_test
func TestX(t *testing.T) {
calc.double(3) // double เป็น unexported
}
โค้ดนี้จะได้ error ว่าอะไร?
undefined: calc.double — ผมรันทดสอบแล้ว (Go 1.26)
เต็ม ๆ คือ calc/calc_api_test.go:10:7: undefined: calc.double
ตามด้วย FAIL myapp/calc [build failed]
คอมไพเลอร์ไม่พูดคำว่า "unexported" ตรง ๆ —
จากมุมของ calc_test ชื่อตัวเล็กมันมองไม่เห็นเลย
เลยรายงานว่า "ไม่มีชื่อนี้"
นี่คือข้อดีไม่ใช่ข้อจำกัด — external test บังคับให้คุณ
ทดสอบผ่านประตูหน้าเหมือนผู้ใช้จริง ถ้าเทสผ่านแปลว่า
คนอื่นเอา package คุณไปใช้ได้จริง
ส่วน internal test ถ้าเทสผ่านก็แค่แปลว่าโค้ดข้างในทำงานตามที่คิด
ในทางปฏิบัติใช้ทั้งสองแบบพร้อมกันได้ในโฟลเดอร์เดียว
- exported / unexported
- ชื่อที่ขึ้นต้นด้วยตัวใหญ่ = exported (คนนอก package เรียกได้) · ขึ้นต้นด้วยตัวเล็ก = unexported (เห็นได้แค่ใน package เดียวกัน) · Go ตัดสินจากตัวอักษรตัวแรกล้วน ๆ ไม่มีคำว่า public/private ให้เขียน · พูดว่า “ฟังก์ชันนี้ unexported อยู่ เทสจากข้างนอกไม่ได้” จะสื่อกว่าพูดว่า “ตัวเล็กเลยเรียกไม่ได้”
- public API
- “เมนูหน้าร้าน” — ชุดของที่ package เปิดให้คนอื่นเรียกใช้ ก็คือของที่ exported ทั้งหมด · ถามได้ว่า “เทสตัวนี้ทดสอบผ่าน public API หรือแตะข้างในตรง ๆ”
- black-box / white-box testing
- สองคำนี้พูดถึงมุมมองของคนเขียนเทสว่ารู้ไส้ในหรือเปล่า ไม่ได้พูดถึงว่าไฟล์เทสอยู่ package ไหน · black-box = เทสจากข้างนอกโดยไม่สนใจว่าข้างในเขียนยังไง · white-box = เทสโดยเห็นไส้ในทั้งหมด · ในโลก Go: external test คือท่าที่ใกล้ black-box ที่สุดเท่าที่ทำได้ในระดับ package ส่วน internal test คือฝั่ง white-box ชัด ๆ — แต่ไม่ใช่คำเดียวกันเป๊ะ เพราะ black-box ตัวจริงมักหมายถึงเทสระดับ API/E2E ที่คนละคนกับเจ้าของโค้ดเป็นคนเขียน
5 · คำสั่ง go test
go test [options] [packages]
packages — จะรันตรงไหน
| เขียน | ความหมาย |
|---|---|
go test . | รันเฉพาะ folder ปัจจุบัน |
go test ./... | รันทุก folder ลงไปทุกชั้น — ใช้บ่อยที่สุด |
go test ./internal/user | ระบุ folder ตรง ๆ |
go test ./user/user_test.go | ระบุไฟล์เดียว |
options ที่ใช้จริงทุกวัน
| Option | ทำอะไร | ผลรันจริง |
|---|---|---|
-v | โชว์ชื่อเทสทุกตัว + t.Log | === RUN / --- PASS ทีละบรรทัด |
-cover | วัดว่าเทสครอบคลุมกี่ % | coverage: 42.9% of statements |
-run <regex> | รันเฉพาะเทสที่ชื่อตรง | ดูตัวอย่างข้างล่าง |
-count=1 | ล้าง cache บังคับรันใหม่ | ไม่ขึ้น (cached) |
-short | เปิด short mode ให้เทสหนัก ๆ ข้ามตัวเอง | --- SKIP: |
-race | จับ data race (จะได้ใช้ตอนเรียน concurrency) | WARNING: DATA RACE |
สไลด์เขียน -tag=<tagName> แต่ของจริงคือ -tags
(มี s) ผมรันทดสอบแล้ว ใช้ -tag ได้ผลนี้:
flag provided but not defined: -tag
-count=1 สำคัญกว่าที่คิด
$ go test .
ok day5check 0.212s
$ go test .
ok day5check (cached) ← ไม่ได้รันจริง! ใช้ผลเดิม
$ go test -count=1 .
ok day5check 0.207s ← บังคับรันใหม่
Go จำผลเทสรอบก่อนไว้ถ้าโค้ดไม่เปลี่ยน เรียกว่า cache
ปกติแล้วดี เพราะรันซ้ำได้เร็วมาก
แต่ถ้าเทสของคุณพึ่งพาสิ่งภายนอกที่ Go มองไม่เห็น (เวลา, database, network)
ผลที่ cache ไว้จะหลอกคุณได้
-count=1 คือวิธีมาตรฐานในการปิด cache
ส่วน -count=10 ใช้จับ flaky test — เทสที่บางทีผ่านบางทีไม่ผ่าน
ไปโรงพยาบาลตรวจเลือดวันนี้ พรุ่งนี้กลับไปถามเรื่องเดิม
หมอไม่ได้เจาะเลือดใหม่ แต่เปิดผลใบเดิมมาอ่านให้ฟัง เร็วดีและไม่ต้องเจ็บตัวซ้ำ
แต่ถ้าเมื่อคืนคุณเพิ่งกินยาตัวใหม่เข้าไป ผลใบเดิมไม่ได้บอกสภาพตัวคุณตอนนี้แล้ว
(cached) คือผลใบเดิม · -count=1 คือการบอกว่า “เจาะใหม่เถอะ”
ที่ไม่เหมือนกัน: หมอดูวันที่ประกอบ แต่ Go ดูเฉพาะสิ่งที่มันตามรอยได้ —
ไฟล์โค้ด, flag ที่สั่ง, env var ที่เทสอ่าน และไฟล์ใน module ที่เทสเปิดอ่าน (เช่นใน testdata/)
ของพวกนี้เปลี่ยนเมื่อไหร่ Go รันใหม่ให้เอง ไม่ต้องสั่ง
ที่มันตามรอยไม่ได้เลยคือ database, network และนาฬิกา —
ถ้าพวกนี้เปลี่ยน Go ไม่รู้ มันจะยื่นผลเดิมให้คุณอย่างมั่นใจ
- test cache
- ที่เก็บผลเทสรอบก่อนของ Go · ประโยคที่ช่วยเพื่อนได้บ่อยมากคือ
“ผลนี้มัน cache มานะ ลอง
-count=1ก่อน” - flaky test
- เทสที่บางทีผ่านบางทีไม่ผ่านทั้งที่ไม่ได้แก้โค้ดเลย
มักเกิดจากไปพึ่งเวลา ลำดับการรัน หรือของข้างนอก ·
“เทสตัวนี้ flaky หรือเปล่า ลองรัน
-count=10ดู” - deterministic
- “ให้ผลเดิมทุกครั้ง” — คุณสมบัติที่เทสที่ดีต้องมี ตรงข้ามกับ flaky · “ทำให้มัน deterministic ก่อนดีกว่า”
-run เจาะจงถึง subtest ได้
subtest = เทสย่อยที่ซ้อนอยู่ข้างในเทสตัวใหญ่อีกที (วิธีเขียนอยู่ในบทเรียน 16) ·
ส่วน -run รับ regex คือรูปแบบข้อความเอาไว้จับคู่ชื่อ ไม่ใช่ชื่อเต็ม ๆ
จึงใส่ | เพื่อบอกว่า “ตัวนี้หรือตัวนั้น” ได้
go test -v -run 'TestCalculateDiscount' . # ทั้งฟังก์ชัน
go test -v -run 'TestCalculateDiscount/ลด_10' . # เฉพาะ subtest
go test -v -run 'TestUser|TestOrder' . # regex OR
สังเกต _ ในชื่อ subtest — ผมตั้งชื่อว่า
"ลด 10%" (มีเว้นวรรค) แต่ Go แสดงเป็น ลด_10%
Go แปลงเว้นวรรคเป็น _ ให้อัตโนมัติ
จริง ๆ แล้ว -run รับได้ทั้งสองแบบ
(Go แปลงเว้นวรรคใน pattern ให้ด้วย — ดูรายละเอียดในบทเรียน 16)
แต่ให้ติดนิสัยก็อปชื่อจากผลลัพธ์ -v มาวาง —
เพราะชื่อที่มีเว้นวรรคต้องใส่ quote ใน shell ให้ถูก
ลืมเมื่อไหร่คือหาเทสไม่เจอแบบเงียบ ๆ
6 · Coverage — ดูว่าโค้ดบรรทัดไหนยังไม่ถูกเทส
# รันเทส + บันทึกข้อมูล coverage ลงไฟล์
go test -coverprofile=c.out .
# เปิดดูเป็นตาราง
go tool cover -func=c.out
# เปิดดูเป็นหน้าเว็บ ระบายสีทีละบรรทัด
go tool cover -html=c.out
ผลรันจริงของ -func:
day5check/calc.go:7: CalculateDiscount 100.0%
day5check/calc.go:14: Division 0.0% ← ยังไม่มีเทสเลย
day5check/calc.go:21: double 0.0%
total: (statements) 42.9%
coverage บอกแค่ว่าบรรทัดนั้นถูกรันผ่าน ไม่ได้บอกว่า
คุณตรวจผลลัพธ์ถูกต้องหรือเปล่า — เขียนเทสที่เรียกฟังก์ชันแล้วไม่ assert อะไรเลย
ก็ได้ 100% เหมือนกัน
ใช้ coverage เพื่อหา "ที่ยังไม่ได้แตะ" ไม่ใช่เพื่อไล่ล่าตัวเลข
ส่วนที่ 0% คือสัญญาณจริง ส่วนที่ 85% กับ 92% แทบไม่ต่างกัน
วันตรวจรับบ้าน คุณเดินเข้าไปครบทั้ง 8 ห้อง แล้วจดไว้ว่า “เข้าครบแล้ว 8/8”
ตัวเลข 8/8 บอกได้แค่ว่าคุณเดินไปถึงทุกห้อง
ไม่ได้บอกว่าคุณเปิดก๊อกน้ำ กดชักโครก หรือลองสวิตช์ไฟในห้องพวกนั้นหรือเปล่า
ส่วนห้องที่จดไว้ว่ายังไม่ได้เข้า อันนั้นคือสัญญาณจริง เพราะยังไม่มีใครดูเลยแม้แต่แวบเดียว
coverage คือตัวเลข “เข้าไปกี่ห้องแล้ว” ไม่ใช่ “ตรวจดีแค่ไหน”
ที่ไม่เหมือนกัน: บ้านมีจำนวนห้องตายตัว แต่ Go นับเป็นคำสั่ง (statement)
ทีละตัว ไม่ใช่ทีละบรรทัด — บรรทัดเดียวมีได้หลายคำสั่ง อย่าง
if err != nil { return err } · จำนวนคำสั่งเพิ่มลดทุกครั้งที่แก้โค้ด
ตัวเลขจึงขยับได้เองทั้งที่คุณไม่ได้เขียนเทสเพิ่มเลยสักตัว
- code coverage
- สัดส่วนของโค้ดที่เทสเดินผ่าน · ของ Go นับเป็น statement coverage คือนับทีละคำสั่ง · “ตรงนี้ coverage 0% เลย แปลว่ายังไม่มีเทสแตะใช่ไหม”
- assert / assertion
- “ตรวจว่าผลลัพธ์ตรงกับที่คาดไว้” เช่น เช็คว่า
Discount(100, 10)ได้ 90 จริง · เทสที่เรียกฟังก์ชันแล้วไม่ assert อะไรเลยก็ได้ coverage เต็มเหมือนกัน · “เทสนี้ assert อะไรอยู่บ้าง” คือคำถามแรกที่คนรีวิวโค้ดถาม
7 · Build tag — แยก integration test ออกจาก unit test
//go:build integration
← ต้องเว้นบรรทัดว่างก่อน package เสมอ
package user
import "testing"
func TestRealDatabase(t *testing.T) {
// เทสที่ต้องต่อ database จริง
}
$ go test ./... # unit test เท่านั้น — เร็ว
ok day5check 0.433s ← TestRealDatabase ไม่ถูกนับเข้ามาเลย
$ go test -v ./... | grep RealDatabase # พิสูจน์ว่ามันหายไปจริง
(ไม่มีบรรทัดไหนออกมา)
$ go test -v -tags integration ./... # รวม integration ด้วย
=== RUN TestRealDatabase
--- PASS: TestRealDatabase (0.00s)
=== RUN TestHi
--- PASS: TestHi (0.00s)
PASS
ok day5check 0.434s
วิธีเช็คที่ชัวร์ที่สุดคือดูว่าชื่อเทสโผล่ใน -v หรือไม่ —
อย่าดูแค่บรรทัด ok เพราะถ้าใน package ยังมีเทสตัวอื่นอยู่
มันก็ขึ้น ok เหมือนเดิม
unit test ต้องรันได้ทุกครั้งที่กด save — ถ้ามันช้าเพราะต้องรอ
database คนก็จะเลิกรัน · แยกด้วย build tag แล้วให้ CI
(ระบบที่รันเทสให้อัตโนมัติทุกครั้งที่ push โค้ดขึ้น repo) รัน
-tags integration ตอน merge (ตอนรวมโค้ดเข้า branch หลัก) เท่านั้น
สไลด์เขียนว่า "ต้องเว้นหนึ่งบรรทัดก่อน package เสมอ" —
ผมรันทดสอบทั้งสองแบบบน Go 1.26 ได้ผลนี้:
| เขียนแบบ | ผลจริง |
|---|---|
//go:build อยู่บนสุด + มีบรรทัดว่าง | ✅ ถูกต้องทุกอย่าง |
//go:build อยู่บนสุด + ไม่มีบรรทัดว่าง | ⚠️ ยังทำงานได้ แต่ gofmt -l ฟ้อง และ gofmt จะแทรกบรรทัดว่างให้เอง |
//go:build อยู่ใต้ package | 💥 misplaced compiler directive → build failed |
สรุป: กฎที่ compiler บังคับจริงคือ "ต้องอยู่ก่อน package"
ส่วนบรรทัดว่างเป็นข้อกำหนดของ gofmt — ซึ่งก็ควรทำตามอยู่ดี
เพราะ CI ส่วนใหญ่ตรวจ gofmt -l
- build tag / build constraint
- บรรทัด
//go:build ...ที่วางไว้ก่อนpackage— บอก Go ว่าจะนับไฟล์นี้เข้า build เมื่อไหร่ · ถ้าเงื่อนไขไม่ตรง ไฟล์นั้นหายไปจาก package เลย ไม่ใช่แค่ถูกข้ามตอนรัน · ชื่อทางการในเอกสาร Go คือ build constraint แต่คนเรียกติดปากว่า build tag - integration test
- เทสที่ต่อของจริง — database, API ข้างนอก, คิว · ตรงข้ามกับ unit test ที่รันจบในตัวเองและเร็ว · “ตัวนี้เป็น integration test นะ ต้องมี database ขึ้นก่อนถึงจะรันได้”
- CI (continuous integration)
- ระบบที่รัน build + เทสให้อัตโนมัติทุกครั้งที่ push โค้ดขึ้น repo ·
เป็นที่ที่เหมาะจะรัน
-tags integrationเพราะช้าหน่อยก็ไม่มีใครต้องนั่งรอ · “ให้ CI รัน integration test ตอน merge พอ”
8 · ทดสอบความจำ
1. ไฟล์ชื่อ mytests.go มี func TestFoo(t *testing.T) — go test ทำยังไง?
Go หาไฟล์เทสจากชื่อไฟล์ก่อน ไม่ใช่จากเนื้อใน —
ไม่ลงท้าย _test.go ก็ถือว่าเป็นไฟล์โปรแกรมธรรมดา
ไม่มี error ไม่มี warning เงียบสนิท เจอ
[no test files] เมื่อไหร่ ให้เช็คชื่อไฟล์เป็นอย่างแรก
2. package calc_test (external) เทสฟังก์ชัน double() ตัวเล็กได้ไหม?
calc_test เป็นคนละ package กับ calc จริง ๆ —
error ที่ได้คือ undefined: calc.double
นี่คือข้อดี มันบังคับให้เทสผ่านประตูหน้าเหมือนผู้ใช้จริง
ถ้าอยากเทสของข้างในให้ใช้ internal test (package calc) แทน
3. go test . ขึ้น ok (cached) — แปลว่าอะไร?
Go จำผลไว้ถ้าโค้ดไม่เปลี่ยน — ประหยัดเวลามาก แต่หลอกได้ถ้าเทส
พึ่งพาสิ่งภายนอก (ไฟล์ เวลา database)
-count=1 บังคับรันใหม่ · -count=10 ใช้จับ flaky test
4. coverage: 100% รับประกันอะไรได้บ้าง?
coverage นับแค่ว่าบรรทัดนั้นถูกเดินผ่าน — เทสที่เรียกฟังก์ชันแล้วไม่ตรวจผลอะไรเลยก็ได้ 100% ใช้มันหาส่วนที่ยังไม่ได้แตะ (0%) ไม่ใช่ไล่ล่าตัวเลข ทีมที่ตั้ง KPI ว่าต้องได้ 90% มักได้เทสปลอมมาเต็มไปหมด
9 · ลงมือทำ
ภารกิจ: ตั้งโปรเจกต์เทสตัวแรก
- สร้างโฟลเดอร์ใหม่ แล้ว
go mod init calcdemo - เขียน
calc.goที่มีfunc Discount(price, percent int) (int, error)(ลดราคา, คืน error ถ้า percent ไม่อยู่ระหว่าง 0–100) และfunc double(n int) int(ตัวเล็ก) - เขียน
calc_test.goแบบ internal (package calcdemo) ที่เทสdouble - เขียน
calc_api_test.goแบบ external: บรรทัดแรกเป็นpackage calcdemo_testแล้วimport ("testing"; "calcdemo")— ชื่อที่ import คือชื่อ module ที่ตั้งไว้ตอนgo mod initเป๊ะ ๆ เพราะ package นี้อยู่ที่โฟลเดอร์รากของ module พอดี (ไม่ใช่calcdemo/calcdemo) จากนั้นเทสcalcdemo.Discount(100, 10) - ลองเพิ่มบรรทัด
calcdemo.double(3)ในไฟล์ external — ต้องได้undefined: calcdemo.double(ถ้าได้undefined: calcdemoเฉย ๆ แปลว่ายังไม่ได้ import) แล้วลบออก - รันตามลำดับนี้แล้วเทียบผล:
go test ./... go test -v ./... go test ./... # ← สังเกตคำว่า (cached) go test -count=1 ./... go test -cover ./... - สั่ง
go test -coverprofile=c.out . && go tool cover -html=c.out— ดูว่าบรรทัดไหนเป็นสีแดง แล้วเขียนเทสเพิ่มให้เขียว - ท้าทาย 1: เปลี่ยนชื่อไฟล์เทสเป็น
calc_tests.go(มี s) แล้วรันใหม่ — ได้ผลว่าอะไร? - ท้าทาย 2: สร้าง
db_test.goที่มี//go:build integrationอยู่บรรทัดแรก แล้วพิสูจน์ว่าgo test ./...ไม่รันมัน แต่go test -tags integration ./...รัน
จากนั้นลองสองอย่างนี้แล้วเทียบกับตารางในหัวข้อ 7:- ลบบรรทัดว่างระหว่าง
//go:buildกับpackage→ รันgofmt -l .ดูว่าฟ้องไหม - ย้าย
//go:build integrationไปไว้ใต้ บรรทัดpackage→ อันนี้พังจริง
- ลบบรรทัดว่างระหว่าง
อ่านต่อจากต้นทาง
แหล่งหลักที่แนะนำ:
Add a test — Go tutorial
สั้นมาก (10 นาที) พาเขียนเทสตัวแรกจนรันได้จริง
พออยากรู้ว่ามี option อะไรบ้างครบ ๆ ให้สั่ง go help test
และ go help testflag ในเทอร์มินัล — เอกสารอยู่ในเครื่องคุณอยู่แล้ว
ไม่ต้องเปิดเว็บ
💬 ติดตรงไหนถามได้เลย — ท้าทายข้อ 2 (build tag) เป็นข้อที่คนพลาดเพราะเรื่องบรรทัดว่างมากที่สุด ถ้าลบแล้วงงว่าทำไมยังรันอยู่ ส่งไฟล์มาได้
อ้างอิง
- Add a test — tutorial ทางการ เขียนเทสตัวแรก
- pkg.go.dev — testing — เอกสารของ package testing ทั้งหมด
- cmd/go — Test packages — กฎการหาไฟล์เทสและ package
_test - cmd/go — Testing flags — รายการ flag ครบทุกตัว
- cmd/go — Build constraints — กฎของ
//go:build - The Go Blog — The cover story — coverage ทำงานยังไงข้างใน