บทเรียน 14 · Day 5 · Testing

Test in Go: กฎเกมที่ต้องรู้ก่อนเขียนบรรทัดแรก

~14 นาที · ต้องมี: บทเรียน 01 (package & module), บทเรียน 13

Go มี test runner (ตัวรันเทสที่ติดมากับภาษา คอยไล่หาไฟล์เทสแล้วรันให้) มาในตัว ไม่ต้องลงเครื่องมือเสริมอะไรเลย แต่มันจะมองเห็นเทสของคุณก็ต่อเมื่อตั้งชื่อไฟล์ถูก — ตั้งผิดตัวเดียวคือ [no test files] เงียบ ๆ บทนี้ว่าด้วยกฎการตั้งชื่อ, internal vs external package, และ option ของ go test ที่ใช้จริงทุกวัน

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) {
    // โค้ดเทส
}

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
สไลด์เขียนผิด — flag เป็นพหูพจน์

สไลด์เขียน -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 100% ≠ ไม่มีบั๊ก

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 · ลงมือทำ

ภารกิจ: ตั้งโปรเจกต์เทสตัวแรก

  1. สร้างโฟลเดอร์ใหม่ แล้ว go mod init calcdemo
  2. เขียน calc.go ที่มี func Discount(price, percent int) (int, error) (ลดราคา, คืน error ถ้า percent ไม่อยู่ระหว่าง 0–100) และ func double(n int) int (ตัวเล็ก)
  3. เขียน calc_test.go แบบ internal (package calcdemo) ที่เทส double
  4. เขียน calc_api_test.go แบบ external: บรรทัดแรกเป็น package calcdemo_test แล้ว import ("testing"; "calcdemo") — ชื่อที่ import คือชื่อ module ที่ตั้งไว้ตอน go mod init เป๊ะ ๆ เพราะ package นี้อยู่ที่โฟลเดอร์รากของ module พอดี (ไม่ใช่ calcdemo/calcdemo) จากนั้นเทส calcdemo.Discount(100, 10)
  5. ลองเพิ่มบรรทัด calcdemo.double(3) ในไฟล์ external — ต้องได้ undefined: calcdemo.double (ถ้าได้ undefined: calcdemo เฉย ๆ แปลว่ายังไม่ได้ import) แล้วลบออก
  6. รันตามลำดับนี้แล้วเทียบผล:
    go test ./...
    go test -v ./...
    go test ./...          # ← สังเกตคำว่า (cached)
    go test -count=1 ./...
    go test -cover ./...
  7. สั่ง go test -coverprofile=c.out . && go tool cover -html=c.out — ดูว่าบรรทัดไหนเป็นสีแดง แล้วเขียนเทสเพิ่มให้เขียว
  8. ท้าทาย 1: เปลี่ยนชื่อไฟล์เทสเป็น calc_tests.go (มี s) แล้วรันใหม่ — ได้ผลว่าอะไร?
  9. ท้าทาย 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) เป็นข้อที่คนพลาดเพราะเรื่องบรรทัดว่างมากที่สุด ถ้าลบแล้วงงว่าทำไมยังรันอยู่ ส่งไฟล์มาได้

อ้างอิง