คุณคงเคยเจอความรู้สึก "โค้ดรันได้บนเครื่องเรา แต่พอ build จริงกลับพัง" มาแล้ว และคุณก็มีเครื่องมือสองตัวจากบท C ไว้หาสาเหตุอยู่แล้ว: gdb สำหรับหยุดโปรแกรมที่กำลังรันอยู่แล้วมองเข้าไปข้างใน และ AddressSanitizer สำหรับจับบั๊กเรื่อง memory ตั้งแต่วินาทีที่มันเกิดขึ้น แทนที่จะมาเจอเป็น crash ปริศนาอีกหลายชั่วโมงถัดมา บทนี้จะพูดถึงทักษะที่อยู่เหนือกว่าเครื่องมือตัวใดตัวหนึ่ง — วิธีหาบั๊กอย่างมีเป้าหมายจริง ๆ ไม่ใช่เจอเพราะโชคช่วย — และอีกครึ่งหนึ่งของปัญหาเดียวกัน: วิธีทำให้บั๊กที่แก้แล้วไม่กลับมาอีก ด้วยการเขียน test การ debug คือการหาบั๊กของวันนี้ ส่วนการ test คือการทำให้บั๊กของเมื่อวานตายอยู่แบบนั้นตลอดไป
นี่คือประโยคที่มีประโยชน์ที่สุดในบทนี้: บั๊กไม่ใช่ปริศนาลึกลับ มันคือจุดที่สิ่งที่คุณเชื่อเกี่ยวกับโค้ด กับสิ่งที่โค้ดทำจริง ๆ ไม่ตรงกัน คุณเชื่อว่าระบบ fall damage จะทำงานก็ต่อเมื่อผู้เล่นตกจริง ๆ เท่านั้น แต่โค้ดตอนนี้กำลังไม่เห็นด้วยกับคุณ มีฝ่ายหนึ่งที่ผิด และมันไม่ใช่โค้ด — โค้ดทำตามที่มันเขียนไว้เป๊ะ ๆ ทุกครั้ง ดังนั้นบั๊กจึงไม่ใช่ "อยู่ตรงไหนสักที่ ขอให้โชคดี" แต่บั๊กคือความไม่ตรงกันที่เจาะจงและหาเจอได้ ระหว่างความเชื่อที่คุณถืออยู่กับข้อเท็จจริงที่เป็นจริงอยู่แล้ว
การมองใหม่แบบนี้เปลี่ยนวิธีที่คุณควรใช้เวลาสิบนาทีถัดไป มือใหม่มักจะ debug ด้วยการ เปลี่ยนอะไรบางอย่างแล้วรันเกมใหม่ หวังว่าจะมีอะไรดูต่างไป: ปรับตัวเลข เพิ่ม null check "เผื่อไว้" สลับลำดับสองบรรทัด วิธีนี้เจอบั๊กได้บ้างเหมือนลองไขกุญแจทุกดอกในพวงกุญแจแล้วบังเอิญเปิดประตูได้ แต่มันก็ทำให้สถานการณ์แย่ลงบ่อยครั้งเช่นกัน เพราะการเปลี่ยนแปลงที่ทำโดยไม่เข้าใจเหตุผลอาจซ่อนอาการไว้ ในขณะที่สาเหตุจริงยังอยู่เหมือนเดิม โปรแกรมเมอร์ที่ทำงานจริงจะ debug ด้วยการ ตั้งสมมติฐาน (hypothesis) แล้วทดสอบมัน — เป็น loop เดียวกันเป๊ะกับ scientific method เพียงแต่เล็งไปที่โค้ดชิ้นเล็ก ๆ แทนที่จะเป็นจักรวาล
ลองใช้กับตัวอย่างจริง: tester รายงานว่า "บางทีผมโดน fall damage ทันทีหลัง load checkpoint ทั้ง ๆ ที่เห็นชัด ๆ ว่าลงพื้นแข็ง ๆ" วิธี ที่ผิด ในการเริ่มต้นคือเปิด PlayerHealth.cs แล้วจ้องมองจนกว่าจะมีอะไรดูน่าสงสัย หรือเพิ่ม guard if (justLoadedCheckpoint) return; เพราะคิดว่า "น่าจะแก้ได้" ซึ่งอาจได้ผล หรืออาจแค่ซ่อนบั๊กจริงไว้ที่ไหนสักที่ที่จะโผล่มาอีกในรูปแบบอื่น วิธี ที่ถูก คือเขียนลงไปเป็นประโยคเดียวว่าตอนนี้คุณเชื่ออะไรอยู่: "ผมเชื่อว่า fall damage คำนวณจากระยะที่ผู้เล่นตก โดยนับตั้งแต่เท้าลอยจากพื้น" แล้วถามว่า: อะไรต้องเป็นจริงตอนนี้ ถ้าความเชื่อนั้นอธิบายอาการนี้ได้? ถ้า fall damage เกิดขึ้นทันทีหลัง load checkpoint ตัวแปรที่ track ระยะตกต้องมีค่าเก่าที่ไม่ใช่ศูนย์ค้างอยู่แล้วตั้งแต่ checkpoint เพิ่ง load เสร็จ — ก่อนที่ผู้เล่นจะตกในรอบนี้ด้วยซ้ำ นี่คือข้อกล่าวอ้างที่เจาะจงและตรวจสอบได้ หัวข้อ 4 จะสอนวิธีตรวจสอบด้วย debugger ส่วนหัวข้อ 2 และ 3 จะสอนอีกสองวิธีในการจำกัดวงว่า ควรมองตรงไหน ก่อนที่จะเริ่มตรวจสอบอะไรเลยด้วยซ้ำ
อีกนิสัยหนึ่งที่ควรพูดถึง: เปลี่ยนทีละอย่างเท่านั้น ถ้าคุณปรับสามอย่างพร้อมกันแล้วอาการหายไป คุณจะรู้แค่ว่าหนึ่งในสามอย่างนั้นแก้ปัญหาได้ — แต่ไม่รู้ว่าอันไหน หรือสองอันที่เหลือแอบสร้างบั๊กใหม่ที่เงียบกว่าเดิมหรือเปล่า ทุกขั้นตอนใน loop ข้างบนทดสอบความเชื่อแค่ข้อเดียวต่อครั้ง นั่นแหละคือสิ่งที่ทำให้ loop นี้เชื่อถือได้แทนที่จะพึ่งดวง
บางครั้งคุณยังไม่มีสมมติฐานเลยด้วยซ้ำ — มีแค่โค้ดกองใหญ่ที่รันทุกเฟรม แล้วมีบางอย่างในนั้นผิด การอ่านทั้งหมดตั้งแต่บนลงล่างทีละบรรทัดนั้นช้า และหมายความว่าคุณกำลังตรวจสอบผู้ต้องสงสัยที่บั๊กอาจไม่ได้อยู่ตรงนั้นด้วยซ้ำ Binary search debugging (ไอเดียเดียวกับ binary search บน array ที่เรียงแล้ว แต่เอามาใช้กับโค้ดของตัวเอง) แก้ปัญหานี้ได้: ตัดโค้ดที่ต้องสงสัยออกเป็นครึ่ง ๆ ดูว่าครึ่งไหนยังมีบั๊กอยู่ ตัดครึ่งที่เหลือทิ้งจากรายชื่อผู้ต้องสงสัย แล้วทำซ้ำ
สมมติว่าเกม hitch (เฟรมตกหลายเฟรม กระตุกให้เห็นชัด) ทุก ๆ สองสามวินาที และคุณสงสัยว่าเป็นหนึ่งในหกระบบที่รันอยู่ใน Update() แต่ไม่รู้ว่าอันไหน:
void Update()
{
UpdateMovement();
UpdateAnimationBlending();
UpdateInventoryUI();
UpdateEnemyAI();
UpdateWeatherSystem();
UpdateMinimapIcons();
}
แทนที่จะอ่านทั้งหกฟังก์ชัน ให้ comment ครึ่งล่างออกแล้วเล่นสักนาที:
void Update()
{
UpdateMovement();
UpdateAnimationBlending();
UpdateInventoryUI();
// UpdateEnemyAI();
// UpdateWeatherSystem();
// UpdateMinimapIcons();
}
มีสองผลลัพธ์ที่เป็นไปได้ และทั้งสองแบบมีประโยชน์:
ไม่ว่าจะแบบไหน คุณก็เพิ่งตัดผู้ต้องสงสัยไปครึ่งหนึ่งด้วยการทดสอบแค่ครั้งเดียว ทำซ้ำกับครึ่งที่เหลือ — comment ฟังก์ชันที่เหลืออีกหนึ่งอันออก หรือแบ่งฟังก์ชันยาว ๆ อันเดียวออกเป็นครึ่งด้วย return ก่อนเวลา — แล้วแต่ละรอบจะตัดพื้นที่ค้นหาลงครึ่งหนึ่งอีก:
เทคนิคเดียวกันนี้ใช้ได้ ภายใน ฟังก์ชันยาว ๆ อันเดียวได้ด้วย ไม่ใช่แค่ข้ามหลายฟังก์ชัน: comment ครึ่งหลังของ body ฟังก์ชันออก (หรือใส่ return; ก่อนเวลาตรงกลาง) แล้วดูว่าอาการยังอยู่หรือไม่เมื่อรันแค่ครึ่งแรก มันยังใช้ได้กับทั้ง scene ด้วย: ถ้าไม่แน่ใจว่าบั๊กขึ้นอยู่กับ object ตัวไหนใน level ให้ลบ object ครึ่งหนึ่งใน scene สำเนาแล้วดูว่ายังเกิดอยู่ไหม
Binary search debugging เปลี่ยน "บั๊กอยู่ที่ไหนสักที่ในโค้ด 2,000 บรรทัด" ให้กลายเป็นการทดสอบ yes/no จำนวนคงที่และน้อย — ประมาณ log2(n) รอบสำหรับผู้ต้องสงสัย n ตัว คณิตศาสตร์เดียวกับที่ทำให้ binary search เร็วบน array ที่เรียงแล้ว หกฟังก์ชันใช้ประมาณสามรอบ หกสิบสี่ฟังก์ชันก็ยังใช้แค่ประมาณหกรอบ
git bisectBinary search ไม่ได้ใช้ได้แค่กับโค้ดที่คุณเห็นตอนนี้เท่านั้น — มันใช้ได้กับเวลาด้วย ถ้าบั๊กยังไม่มีเมื่อเดือนที่แล้ว แต่มีวันนี้ ระหว่างสองจุดนั้นต้องมี commit หนึ่งที่นำมันเข้ามา การอ่าน diff ของทุก commit ทีละอันคือปัญหา "เช็คผู้ต้องสงสัยทีละคน" เดียวกับหัวข้อ 2 และ Git มี binary search ในตัวสำหรับเรื่องนี้: git bisect
สมมติว่าการเคลื่อนที่รู้สึกดีใน release v1.4.0 แต่วันนี้ บน branch main ปัจจุบัน ความเร็วการเคลื่อนที่เปลี่ยนไปตาม frame rate — ซึ่งเป็นบั๊ก เพราะความเร็วควรจะไม่ขึ้นกับ frame rate (จากบท physics) คุณไม่รู้ว่า commit ไหนในบรรดา commit จำนวนมากตั้งแต่ v1.4.0 ที่ทำให้เกิดปัญหา เริ่ม bisect กัน:
git bisect start
git bisect bad HEAD # HEAD (current main) has the bug
git bisect good v1.4.0 # v1.4.0 did not have the bug
Git จะ checkout commit ที่อยู่ประมาณตรงกลางระหว่างสองจุดทันที แล้วรอให้คุณทดสอบและรายงานผลกลับ:
แต่ละรอบตัดจำนวน commit ที่เหลือลงครึ่งหนึ่ง หดตัวแบบ log2(n) เหมือนหัวข้อ 2 — สิบสอง commit ใช้ประมาณสี่ครั้งทดสอบแทนที่จะทดสอบถึงสิบสองครั้ง เมื่อเสร็จแล้ว รัน git bisect reset เพื่อกลับไปที่ branch ที่คุณเริ่มต้นไว้ session bisect ไม่ได้เขียนประวัติศาสตร์ใหม่ มันแค่ checkout commit ต่าง ๆ ชั่วคราวระหว่างที่คุณค้นหา
การทดสอบแต่ละ commit ด้วยมือ (build มัน เล่นมัน ตัดสินว่า good หรือ bad) ใช้ได้ แต่ช้า และง่ายที่จะตัดสินบั๊กที่เกิดไม่สม่ำเสมอว่า "good" ผิด ๆ แค่เพราะมันบังเอิญไม่เกิดขึ้นระหว่าง play-test รอบนั้น ถ้าคุณเขียน script ที่ return exit code 0 สำหรับ "good" และ non-zero สำหรับ "bad" ได้ — เช่น การตรวจสอบแบบ headless ที่วัดความเร็วการเคลื่อนที่ที่ simulated frame rate สองค่าแล้ว fail ถ้าค่าต่างกัน — git bisect run จะช่วยให้การค้นหาทั้งหมดเป็นอัตโนมัติ:
git bisect start
git bisect bad HEAD
git bisect good v1.4.0
git bisect run ./check_frame_rate_independence.sh
Git จะ checkout commit ที่เป็นตัวเลือกแต่ละอัน รัน script อ่าน exit code ของมัน แล้วค่อย ๆ จำกัดวงอัตโนมัติ โดยไม่ต้องให้คุณ build และทดสอบด้วยมือทุกครั้ง — นี่แหละคือเหตุผลที่ automated smoke test และ regression test ในหัวข้อ 12 คุ้มค่าที่จะมีไว้ ก่อนที่คุณจะเจอบั๊กแบบนี้ด้วยซ้ำ: automated test ที่ดีก็เป็น oracle สำเร็จรูปสำหรับ git bisect run ครั้งต่อไปที่ต้องค้นประวัติศาสตร์ด้วย
git bisect สมมติว่าการมีอยู่ของบั๊กเปลี่ยนแบบ monotonic ตามประวัติศาสตร์ — เมื่อมันปรากฏขึ้นแล้ว มันจะยังอยู่ในทุก commit หลังจากนั้น และมันไม่มีอยู่ในทุก commit ก่อนหน้า ถ้าบั๊กถูกนำเข้ามา แล้วบังเอิญถูกแก้ แล้วถูกนำกลับเข้ามาอีก bisect ธรรมดาอาจรายงานผลที่งงหรือผิดได้ ให้ข้าม commit ที่ทดสอบไม่ได้ (เช่น อันที่ build ไม่ผ่าน) ด้วย git bisect skip แทนการเดาว่า good หรือ badLogging และ binary search จำกัดวงบั๊กให้เหลือพื้นที่โค้ดเล็ก ๆ Debugger คือวิธีที่คุณมองเข้าไปในพื้นที่นั้นโดยตรงในขณะที่โปรแกรมกำลังรันอยู่จริง แทนที่จะเดาจากข้อความที่พิมพ์ออกมา คุณเคยใช้มันมาแล้ว: gdb บนโปรแกรม C ธรรมดา ทุกอย่างที่คุณเรียนรู้ที่นั่น — breakpoint, stepping, การตรวจดูตัวแปร — คือไอเดียเดียวกันเป๊ะใน Visual Studio (หรือ Rider) ที่ attach เข้ากับ Unity แค่ปุ่มที่ใช้เปลี่ยนไป
Breakpoint คือบรรทัดที่คุณทำเครื่องหมายไว้ล่วงหน้า เมื่อการทำงานมาถึงมัน โปรแกรมจะหยุด ก่อน รันบรรทัดนั้น โดยตัวแปรทุกตัวยังคงค่าจริงปัจจุบันอยู่ ให้คุณมองดูรอบ ๆ ได้ ใน Visual Studio คลิกที่ขอบสีเทาบาง ๆ ทางซ้ายของเลขบรรทัดเพื่อตั้ง breakpoint (จะมีจุดแดงปรากฏขึ้น) ใน Unity Editor นั้น การหยุดโค้ดจะเกิดขึ้นก็ต่อเมื่อ debugger ของ Visual Studio (หรือ Rider) attach เข้ากับ Unity process ก่อนแล้วเท่านั้น — ปกติจะเป็นเมนูอย่าง Debug > Attach Unity Debugger แล้วกด Play ใน Unity ตามปกติ
void TakeFallDamage()
{
float fallDistance = fallStartY - transform.position.y; // <- breakpoint here
if (fallDistance > safeFallDistance)
{
int damage = (int)((fallDistance - safeFallDistance) * damagePerMeter);
currentHealth -= damage;
}
}
เมื่อการทำงานหยุดอยู่ที่บรรทัดนั้น UI ของ debugger จะแสดงทุกอย่างพร้อมกัน: hover ตัวแปรไหนก็ได้เพื่อดูค่าของมันใน tooltip หรือเปิดหน้าต่าง Locals เพื่อดูตัวแปร local และ field ทั้งหมดในมุมมองเดียว ตรงนี้แหละที่ตัวควบคุม stepping สำคัญ — มันตัดสินว่าโปรแกรมจะรันไปไกลแค่ไหนก่อนจะหยุดอีกครั้ง:
ตัวควบคุมพวกนี้เทียบตรงกับคำสั่ง gdb ที่คุณรู้จักรูปร่างมาแล้ว บวกกับปุ่มลัดของ Visual Studio ที่แสดงไว้ตรงนี้เพื่อใช้อ้างอิง:
ถ้า TakeFallDamage เรียกฟังก์ชันช่วยอย่าง ApplyArmorReduction(damage) และคุณไม่สนใจว่าการลด armor ทำงานยังไงตอนนี้ ให้ใช้ Step Over — คุณจะได้ return value กลับมาโดยไม่ต้อง step ผ่านทุกบรรทัดข้างใน ถ้า helper นั้นคือสิ่งที่คุณสงสัยจริง ๆ ให้ใช้ Step Into แทน การเลือกให้ถูกตรงนี้คือสิ่งที่ทำให้การ debug ด้วย breakpoint เร็วแทนที่จะน่าเบื่อ: step into เฉพาะฟังก์ชันที่คุณสงสัยจริง ๆ step over ที่เหลือทั้งหมด
Breakpoint ธรรมดาจะหยุด ทุกครั้ง ที่การทำงานมาถึงบรรทัดนั้น ถ้า TakeFallDamage รันตลอดเวลา (ครั้งละหนึ่งเฟรมสมมติ) แต่คุณสนใจแค่การเรียกครั้งเดียวที่เกิดขึ้นหลัง checkpoint load breakpoint ธรรมดาจะทำให้คุณต้องกด Continue หลายสิบครั้งก่อนที่การเรียกที่น่าสนใจจะโผล่มา Conditional breakpoint จะหยุดก็ต่อเมื่อ expression ที่คุณเขียนเป็นจริงเท่านั้น
ใน Visual Studio คลิกขวาที่จุดแดงของ breakpoint ที่มีอยู่แล้วเลือก Condition จากนั้นใส่ boolean expression โดยใช้ตัวแปรตัวเดียวกับที่มองเห็นได้ ณ บรรทัดนั้น:
fallDistance > 50 && justLoadedCheckpoint
ตอนนี้ debugger จะวิ่งผ่านทุกการเรียกปกติไปเงียบ ๆ และจะหยุดจริง ๆ แค่ตอนที่ตรงตามเงื่อนไข — ตรงกับการเรียกที่คุณกำลังตามหาพอดี ไอเดียเดียวกันมีอยู่ใน gdb เขียนบน command line แบบนี้:
break TakeFallDamage if fallDistance > 50 && justLoadedCheckpoint
Watch (หรือ watchpoint) เป็นเครื่องมือที่เกี่ยวข้องกันแต่ต่างออกไป: แทนที่จะหยุดที่ บรรทัด มันจะหยุดในจังหวะที่ ค่าที่กำหนด เปลี่ยนแปลง ไม่ว่าบรรทัดไหนเป็นคนเปลี่ยน สิ่งนี้สำคัญเมื่อคุณรู้ว่า อะไร ผิด (ตัวแปรลงเอยด้วยค่าที่ผิด) แต่ไม่รู้ว่า ตรงไหน ที่มันผิด เพราะมีหลายจุดในโค้ดที่แตะตัวแปรนั้น ใน Visual Studio คลิกขวาที่ตัวแปรขณะที่หยุดอยู่แล้วเลือก Add Watch หรือพิมพ์ expression ลงในหน้าต่าง Watch โดยตรง gdb ก็มีไอเดียเดียวกัน:
watch fallStartY
เมื่อตั้งค่านี้ไว้ gdb จะหยุดทันทีที่ fallStartY ถูกเขียนค่าที่ไหนก็ตามในโปรแกรม และบอกค่าเก่ากับค่าใหม่ — ซึ่งสำหรับบั๊กแบบ "ค่านี้ค้าง (stale)" มักเป็นทางที่เร็วที่สุดที่จะพาไปถึงการ assign ที่แท้จริง (หรือการ assign ที่ หายไป) ที่เป็นตัวการ
เมื่อหยุดแล้ว มีคำถามอีกข้อที่สำคัญพอ ๆ กับ "ตัวแปรนี้มีค่าอะไรตอนนี้": ใครเป็นคนเรียกฟังก์ชันนี้ แล้วใครเรียกพวกเขาอีกที? Call stack (แนะนำแบบแนวคิดไว้แล้วในบท C ตอนที่คุณเรียนเรื่อง stack เอง) คือรายการฟังก์ชันทั้งหมดที่กำลังรอสิ่งที่มันเรียกให้ return กลับมา นับจากตัวนอกสุดลงมาถึงบรรทัดที่คุณหยุดอยู่พอดี
นี่คือวิธียืนยันหรือปฏิเสธความเชื่อจากหัวข้อ 1 ได้เป๊ะ ๆ: ถ้าคุณคาดว่า TakeFallDamage จะถูกเรียกก็ต่อเมื่อมีการเช็คแล้วว่าผู้เล่นตกจริง ๆ เท่านั้น แต่ call stack กลับแสดงว่ามันถูกเรียกจาก LateUpdate โดยตรง ทุกเฟรมโดยไม่มีเงื่อนไขใด ๆ — นั่นคือข้อมูลใหม่ที่ความเชื่อเดิมของคุณไม่ได้คำนึงถึง และมันชี้ตรงไปที่รูปร่างจริงของบั๊ก: fall damage ถูกเช็คทุกเฟรมโดยใช้ค่า fallStartY ที่มีอยู่ตอนนั้น รวมถึงเฟรมทันทีหลัง checkpoint load ที่ยังไม่มีใครมา reset มันเลย การดับเบิลคลิก frame ไหนก็ได้ในหน้าต่าง Call Stack จะพาวิว debugger ไปที่ตัวแปร local ของฟังก์ชันนั้น เหมือนกับว่าคุณหยุดอยู่ตรงนั้นแทน — มีประโยชน์เวลาต้องเช็คว่า ผู้เรียก เชื่อว่าอะไรเป็นจริงตอนที่มันเรียกฟังก์ชันนี้ ของ gdb ที่เทียบเท่าคือ bt (backtrace, พิมพ์ทั้ง stack) และ frame N เพื่อกระโดดไปที่ frame หมายเลข N
Debugger ดีมากเมื่อคุณหยุดการทำงานและมองรอบ ๆ ด้วยมือได้ แต่มันไม่เหมาะกับอะไรก็ตามที่การหยุดจะเปลี่ยนผลลัพธ์ — physics ที่ยัง simulate ต่อ, การเชื่อมต่อ network ที่ timeout ระหว่างที่คุณ step ผ่านมันอยู่ หรือบั๊กที่โผล่มาหลังเล่นปกติสิบนาทีและหลายพันเฟรมที่คุณไม่มีทาง step ทีละอันได้ สำหรับกรณีแบบนั้น คุณต้องการ log: บันทึกที่รันไปเรื่อย ๆ มี timestamp บอกว่าเกิดอะไรขึ้น เขียนขณะที่เกมยังรันเต็มความเร็วอยู่ แล้วคุณค่อยกลับมาอ่านทีหลัง
ปัญหาคือ logging ของมือใหม่ส่วนใหญ่หน้าตาแบบนี้:
Debug.Log("here1");
// ... fifty lines later ...
Debug.Log("here2");
// ... in a different file entirely ...
Debug.Log("got here");
หกชั่วโมงต่อมา จ้องมอง console ที่เต็มไปด้วย here1, here2 และ got here ซ้ำเป็นร้อยครั้ง ไม่มีอันไหนบอกอะไรคุณได้เลย: ไม่บอกว่า object ไหน log มันออกมา ไม่บอกค่าตัวแปรตอนนั้น ไม่บอกว่าเฟรมไหน ไม่บอกว่าทำไมคุณควรสนใจ log line จะมีประโยชน์ก็ต่อเมื่อมันตอบคำถามที่คุณจะถามจริง ๆ ในภายหลัง ทางแก้คือ log context เสมอ: object อะไร, state อะไร, เฟรมไหน และค่าที่เกี่ยวข้องกับบั๊กที่คุณกำลังไล่ตาม
Debug.Log($"[FallDamage] player={playerId} frame={Time.frameCount} " +
$"fallStartY={fallStartY:F2} currentY={transform.position.y:F2} " +
$"justLoadedCheckpoint={justLoadedCheckpoint}");
Console output จากสองเฟรมติดกัน ตรงช่วง checkpoint load:
[FallDamage] player=1 frame=812 fallStartY=48.30 currentY=6.10 justLoadedCheckpoint=false
[FallDamage] player=1 frame=813 fallStartY=48.30 currentY=1.00 justLoadedCheckpoint=true
ตอนนี้ log เล่าเรื่องทั้งหมดได้ด้วยตัวเอง ไม่ต้องพึ่ง debugger เลย: ในเฟรม 813 ทันทีหลัง checkpoint load fallStartY ยังคงเป็น 48.30 — ความสูงจาก ก่อน การตกที่กำลังเกิดขึ้นอยู่แล้ว — แทนที่จะถูก reset เป็นความสูงของ checkpoint ใหม่ prefix [FallDamage] ยังทำให้บรรทัดค้นหาได้ง่ายด้วย: filter หน้าต่าง Console ด้วย tag นั้น หรือ grep ไฟล์ log หา มัน แล้ว noise ของระบบอื่นทั้งหมดจะหายไป
ไม่ใช่ทุก log line จะสมควรได้รับน้ำหนักเท่ากัน Log level คือ label บนข้อความ log ที่บอกว่ามันร้ายแรงหรือรกแค่ไหน เพื่อให้คุณ (หรือเครื่องมือ filter) เปิด-ปิดแต่ละหมวดได้ แทนที่จะจมอยู่กับทุกอย่างพร้อมกัน:
Logging ของ Unity map เข้ากับสามระดับกลางตรง ๆ: Debug.Log สำหรับข้อความระดับ Info/Debug, Debug.LogWarning สำหรับ Warning, Debug.LogError สำหรับ Error (ซึ่งทำให้ข้อความขึ้นเป็นสีแดงด้วย และใน Editor ตั้งค่าให้หยุดเมื่อเกิด error ได้) ไม่มี Trace level ในตัวมาให้ ทีมที่อยากมีมักเขียน wrapper บาง ๆ ที่จะ compile การเรียก Trace-level เข้า build ก็ต่อเมื่อมีการกำหนด symbol เฉพาะไว้เท่านั้น:
using System.Diagnostics;
public static class Log
{
[Conditional("VERBOSE_LOGGING")] // the whole call site disappears
public static void Trace(string message) // when this symbol is not defined
{
UnityEngine.Debug.Log("[TRACE] " + message);
}
}
[Conditional("VERBOSE_LOGGING")] คือคำสั่งของ compiler: ถ้าไม่มีการตั้ง scripting define symbol ชื่อ VERBOSE_LOGGING ไว้สำหรับ build นั้น ทุกจุดที่เรียก Log.Trace(...) จะถูกลบออกทั้งหมดตอน compile — ไม่ใช่แค่เงียบเสียง แต่ไม่มีอยู่จริง ๆ ในโค้ดที่ compile แล้ว จึงไม่เสียค่าใช้จ่ายอะไรตอน runtime ใน build ปกติ นี่คือวิธีที่ทีมสามารถใส่ trace logging ละเอียด ๆ ลงในโค้ดที่กระทบ performance ระหว่างพัฒนาได้ โดยไม่ต้องจ่ายค่าใช้จ่ายนั้นเมื่อเกมออกจริง
Debug.Log ไม่ได้ฟรี — การ format string แล้วเขียนออกมามีต้นทุนจริง และการทำแบบนั้นทุกเฟรมสำหรับศัตรูทุกตัวใน scene ที่วุ่นวายอาจเป็นสาเหตุของปัญหา frame rate ที่คุณกำลังพยายาม debug อยู่เองด้วยซ้ำก่อนแตะโค้ดแก้บั๊กแม้แต่บรรทัดเดียว คุณต้องมีสิ่งหนึ่ง: วิธีทำให้บั๊กเกิดขึ้น ตามสั่ง ได้กี่ครั้งก็ได้ตามที่ต้องการ สิ่งนี้เรียกว่า reproduction (หรือ repro) — ลำดับขั้นตอนที่แน่นอนซึ่งกระตุ้นบั๊กได้แน่นอนทุกครั้ง การข้ามขั้นตอนนี้ไปคือวิธีที่พบบ่อยที่สุดที่ทำให้เวลา debug เสียไปเปล่า ๆ: คุณเปลี่ยนอะไรบางอย่าง บั๊กไม่โผล่มาห้านาทีถัดไป คุณคิดว่าแก้แล้ว ปล่อยออกไป แล้วมันก็กลับมาอีกวันถัดมา — เพราะมันไม่เคยถูกแก้เลย มันแค่บังเอิญไม่เกิดในห้านาทีนั้น
repro ที่ดีจะเขียนเป็นขั้นตอนที่เป็นรูปธรรม มีเลขกำกับ และบอกผลลัพธ์ที่คาดหวังกับความถี่ที่เกิดขึ้น:
Bug: fall damage triggers immediately after loading a checkpoint
Steps to reproduce:
1. Start a new game, let health regenerate to full.
2. Walk off the edge of the bridge in "Docks" (any long fall works).
3. While still falling, open the pause menu and choose
"Load Last Checkpoint".
4. Watch the health bar the instant the level finishes loading.
Expected: health stays full, the checkpoint is on flat ground.
Actual: health drops by 35-50 (varies) the instant loading finishes.
Reliability: 10/10 when followed exactly as written above.
Does NOT reproduce if the player dies to an enemy and respawns from
the death screen instead of loading from the pause menu.
บรรทัดสุดท้ายนั้น — กรณีที่ ไม่ reproduce — มักมีค่ามากกว่ากรณีที่ reproduce ได้เสียอีก มันบอกคุณว่าบั๊กไม่ใช่ "fall damage พังทั้งระบบ" แต่เจาะจงไปที่ code path เดียว ที่ reset state ต่างจากอีกเส้นทางหนึ่ง ข้อสังเกตเดียวนี้จำกัดสมมติฐานได้แล้ว: โค้ดที่รันตอนตาย-แล้ว-respawn reset fallStartY ถูกต้อง แต่โค้ดที่รันตอน checkpoint-load ไม่ทำ hypothesis loop จากหัวข้อ 1, debugger จากหัวข้อ 4 และ logging จากหัวข้อ 6 ทั้งหมดจะเร็วขึ้นมากทันทีที่คุณมี repro ที่แม่นยำขนาดนี้ เพราะคุณสามารถกระตุ้นให้เกิดจังหวะที่ต้องตรวจสอบซ้ำได้เรื่อย ๆ แทนที่จะหวังว่ามันจะเกิดขึ้นอีก
ถ้าคุณทำให้บั๊กเกิดขึ้นแน่นอนจริง ๆ ไม่ได้ ให้มองว่านั่นคือเบาะแสอย่างหนึ่ง ไม่ใช่แค่โชคร้าย — ซึ่งพาไปสู่หัวข้อถัดไปโดยตรง
บั๊กที่เกิดทุกครั้งในรูปแบบเดียวกันมักจะตรงไปตรงมา: logic ผิดตรง ๆ และการอ่านโค้ดอย่างระมัดระวัง (หรือ step ผ่านมันครั้งเดียว) ก็เจอ บั๊กที่เกิด บางครั้ง — สามในสิบครั้ง แค่บนเครื่องหนึ่งเครื่อง หรือแค่หลังเล่นไปสักพัก — น่ากลัวกว่า แต่มันไม่ใช่เรื่องสุ่ม พฤติกรรมของโปรแกรมกำลังขึ้นอยู่กับเงื่อนไขบางอย่างที่คุณยังไม่รู้จัก: บางอย่างที่ไม่เหมือนกันทุกครั้งระหว่างแต่ละรอบการรัน ในทางปฏิบัติ บั๊กที่เกิดไม่สม่ำเสมอในเกมเกือบทั้งหมดจะตกอยู่ในหนึ่งในสามหมวดหมู่
ในบท C ตัวแปร local ที่ uninitialized จะอ่านค่า garbage byte ที่บังเอิญอยู่บน stack ตอนนั้น — undefined behavior และเป็นบั๊กแบบที่ AddressSanitizer กับการตรวจสอบด้วย gdb อย่างระมัดระวังจับได้พอดี field ของ C# จะถูก initialize เป็นค่า default ที่ปลอดภัยเสมอ (0, false, null) โดย runtime ดังนั้นคุณจะไม่ได้ garbage memory จริง ๆ — แต่บั๊ก รูปร่างเดียวกัน ยังคงมีอยู่ แค่กลไกต่างออกไป: field ที่มีค่าจริง ใช้งานได้ แต่ stale (ค้าง) จากการใช้งานก่อนหน้าที่ไม่เกี่ยวข้อง เพราะไม่มีอะไร reset มันบน code path นี้ นั่นคือบั๊ก fall-damage ตลอดทั้งบทนี้เป๊ะ ๆ: fallStartY ไม่ใช่ garbage มันคือ float ที่ถูกต้องสมบูรณ์ — แค่มันเป็น float ที่ ผิด ค้างมาจากการตกที่กำลังเกิดขึ้นอยู่แล้ว เพราะ path checkpoint-load ไม่เคยเรียกอะไรก็ตามที่ reset มันเลย
public class PlayerHealth : MonoBehaviour
{
private float fallStartY;
private bool isFalling;
// Called when the player starts falling (not shown in full):
// simply records isFalling = true; fallStartY = transform.position.y;
// Called by the "die -> respawn from death screen" path.
// Correctly resets fall tracking.
public void RespawnAfterDeath(Vector3 spawnPoint)
{
transform.position = spawnPoint;
fallStartY = spawnPoint.y;
isFalling = false;
}
// Called by the "pause menu -> load checkpoint" path.
// BUG: never touches fallStartY at all.
public void LoadCheckpoint(Vector3 checkpointPoint)
{
transform.position = checkpointPoint;
// missing: fallStartY = checkpointPoint.y; isFalling = false;
}
}
นี่คือเหตุผลที่ repro ในหัวข้อ 7 สำคัญมาก: มันพิสูจน์ไปแล้วว่าบั๊กขึ้นอยู่กับ reset path ไหนที่ถูกรัน ซึ่งตรงกับบั๊กแบบ "บาง path initialize สิ่งนี้ บาง path ไม่ทำ" พอดี ก่อนที่คุณจะเปิดไฟล์ด้วยซ้ำ
Race condition คือบั๊กที่ขึ้นอยู่กับ ลำดับ ที่สองสิ่งที่เกิดขึ้นอย่างอิสระต่อกันเสร็จสิ้นจริง ๆ เมื่อลำดับนั้นไม่ได้ถูกรับประกันไว้ Coroutine สองตัว, network message สองอัน หรือ coroutine กับ physics callback สามารถเสร็จในลำดับสัมพัทธ์ที่ต่างกันได้ในแต่ละรอบเล่น แม้ input ของผู้เล่นจะเหมือนกันเป๊ะ เพราะ timing ที่แท้จริงขึ้นอยู่กับสิ่งอย่าง frame timing และ scheduling ที่ไม่สามารถทำซ้ำได้เป๊ะ ๆ
โค้ดไม่ได้เปลี่ยนเลยระหว่าง Run A กับ Run B — มีแค่ timing สัมพัทธ์ของสอง coroutine อิสระที่เปลี่ยนไป นั่นคือลายเซ็นของ race condition: โค้ดเหมือนกัน input เหมือนกัน ผลลัพธ์ต่างกัน เพราะบางอย่างเกี่ยวกับ scheduling ขยับไปไม่กี่ millisecond ทางแก้ทั่วไปคือทำให้ผลลัพธ์ไม่ขึ้นกับว่าอันไหนเสร็จทีหลัง — เช่น ให้ reload ครั้งที่สองยกเลิกครั้งแรกไปเลย แทนที่จะปล่อยให้ทั้งคู่รันจนจบแล้วเขียนทับกันเงียบ ๆ
โค้ดที่มีพฤติกรรมต่างกันที่ frame rate ต่างกันคือผู้ร้ายซ้ำซากอันดับสาม และมันมักซ่อนอยู่ในการเปรียบเทียบแบบ exact กับค่าที่คำนวณจากเวลา:
// BUG: floating-point time almost never lands on an exact value,
// and whether it EVER happens to land close enough is itself
// frame-rate dependent -- fewer, bigger time steps at low frame
// rate are more likely to accidentally straddle the target than
// many tiny steps at high frame rate.
if (Time.time == nextSpawnTime)
{
SpawnWave();
}
ที่ 30 FPS Time.deltaTime เป็นก้าวที่ค่อนข้างใหญ่ในแต่ละเฟรม (ประมาณ 0.033 วินาที) ดังนั้น Time.time จึงบางครั้งลงใกล้พอที่จะทำให้เงื่อนไขเป็นจริงโดยบังเอิญ ที่ 144 FPS ก้าวจะเล็กลงมาก (ประมาณ 0.007 วินาที) และ Time.time มีแนวโน้มจะก้าว ข้าม nextSpawnTime ไปโดยไม่เคยลงตรงเป๊ะเลย — ทำให้ wave ไม่ spawn เลยแบบเงียบ ๆ เฉพาะบนเครื่องที่เร็วกว่า ทางแก้เดียวกับข้อผิดพลาดเรื่อง float-equality จากบท Animator: อย่าเปรียบเทียบเวลาแบบ floating-point (หรือ float ใด ๆ) ด้วย exact equality ใช้การเปรียบเทียบแบบ threshold แทน:
if (Time.time >= nextSpawnTime)
{
SpawnWave();
nextSpawnTime = Time.time + spawnInterval;
}
บทเรียนที่กว้างกว่านั้นเบื้องหลังทั้งสามหมวดหมู่: เมื่อบั๊กไม่ยอม reproduce แน่นอน อย่ายักไหล่แล้วรันซ้ำหวังว่าจะโชคดี ให้ถามอย่างเจาะจงแทนว่า "อะไรต่างกันระหว่างรอบที่มันเกิดกับรอบที่มันไม่เกิด?" — ค่าที่ค้างมาจาก code path อื่น, ลำดับที่สอง operation อิสระเสร็จ หรือขนาดของ time step คำถามนั้นเปลี่ยน "สุ่ม" ให้กลายเป็น "มีเงื่อนไขที่เจาะจง" ซึ่งเป็นบั๊กที่คุณสามารถล่าได้จริงด้วยเครื่องมือจากหัวข้อ 1 ถึง 7
ทุกอย่างในบทนี้จนถึงตอนนี้เป็นการหาและแก้บั๊กทีละตัวที่เกิดขึ้นแล้ว Test ต่างออกไป: มันคือโค้ดที่เช็คว่าโค้ดอื่นถูกต้อง โดยอัตโนมัติ ทุกครั้งที่คุณรันมัน — ดังนั้นบั๊กที่แก้แล้วครั้งหนึ่งจะกลับมาเงียบ ๆ ไม่ได้โดยไม่มีอะไรบอกคุณทันที Unit test เช็ค "หน่วย" พฤติกรรมที่เล็กที่สุดที่ทดสอบแยกได้ ปกติคือ method เดียว แยกออกจากส่วนที่เหลือของเกม
Unit test เหมาะที่สุดกับ pure logic: โค้ดที่ output ขึ้นอยู่กับ input เท่านั้น ไม่มีอะไรที่อ่านหน้าจอ, เฟรมปัจจุบัน, ไฟล์ หรือ network สูตร damage, คณิตศาสตร์ inventory และการ serialize save-data เป็นรูปร่างแบบนี้พอดี — ซึ่งเป็นเหตุผลว่าทำไมมันคุ้มค่าที่จะเขียน logic แบบนั้นเป็น C# class ธรรมดาที่ไม่อ้างอิง UnityEngine เลย แม้จะอยู่ในโปรเจกต์ Unity:
// No UnityEngine reference anywhere in this file -- plain data in,
// plain data out. That is exactly what makes it fast and easy to
// unit test: no scene, no GameObject, no engine startup required.
public static class DamageMath
{
public static int CalculateDamage(int baseDamage, int attackerPower,
int defenderArmor, bool isCritical)
{
int raw = baseDamage + attackerPower - defenderArmor;
if (isCritical)
{
raw *= 2;
}
return System.Math.Max(raw, 1); // a landed hit always deals at least 1
}
}
เพื่อทดสอบมัน หน้าต่าง Test Runner ของ Unity (Window > General > Test Runner) ต้องการที่ที่จะหาโค้ด test เจอ: โฟลเดอร์ที่มี assembly definition (ไฟล์ .asmdef ที่พูดถึงในบท project-structure) ทำเครื่องหมายเป็น Tests Assembly ซึ่งอ้างอิง NUnit-based test framework ที่ Unity มีมาให้ คลิกขวาที่โฟลเดอร์ (ตามธรรมเนียมคือ Assets/Tests/EditMode) แล้วเลือก Create > Testing > Tests Assembly Folder เพื่อตั้งค่านี้อัตโนมัติ จากนั้นไฟล์ test จะหน้าตาแบบนี้:
using NUnit.Framework;
public class DamageMathTests
{
[Test]
public void NormalHit_SubtractsArmorFromPower()
{
int damage = DamageMath.CalculateDamage(
baseDamage: 10, attackerPower: 5, defenderArmor: 3, isCritical: false);
Assert.AreEqual(12, damage); // 10 + 5 - 3
}
[Test]
public void CriticalHit_DoublesTheRawDamage()
{
int damage = DamageMath.CalculateDamage(
baseDamage: 10, attackerPower: 5, defenderArmor: 3, isCritical: true);
Assert.AreEqual(24, damage); // (10 + 5 - 3) * 2
}
[Test]
public void HeavyArmor_NeverReducesDamageBelowOne()
{
int damage = DamageMath.CalculateDamage(
baseDamage: 10, attackerPower: 0, defenderArmor: 999, isCritical: false);
Assert.AreEqual(1, damage); // clamped, never zero or negative
}
}
[Test] ทำเครื่องหมาย method ว่าเป็น test case ที่เป็นอิสระหนึ่งอัน การเปิดหน้าต่าง Test Runner แล้วคลิก Run All จะรันทุก method [Test] ที่มันเจอ แล้วรายงานผ่านหรือไม่ผ่านของแต่ละอัน แยกกัน:
Test Runner (EditMode)
DamageMathTests
NormalHit_SubtractsArmorFromPower PASS (2 ms)
CriticalHit_DoublesTheRawDamage PASS (1 ms)
HeavyArmor_NeverReducesDamageBelowOne PASS (1 ms)
3 passed, 0 failed, 0 skipped
ชื่อ test แต่ละอันอธิบายเป๊ะ ๆ ว่ามันเช็คอะไร ไม่ใช่แค่ "Test1" — หลักการ "context ไม่ใช่ here1" เดียวกับหัวข้อ 6 ใช้ได้กับชื่อ test ด้วย เพราะชื่อ test ที่ fail คือสิ่งแรก และบางทีสิ่งเดียว ที่คุณอ่านตอนมีอะไรพัง Inventory math ทดสอบแบบเดียวกัน:
public class Inventory
{
private const int MaxStack = 99;
private readonly Dictionary<string, int> stacks = new Dictionary<string, int>();
// Returns how many items did NOT fit, e.g. to spawn a new stack.
public int AddItem(string itemId, int count)
{
int current = stacks.TryGetValue(itemId, out int existing) ? existing : 0;
int newTotal = current + count;
stacks[itemId] = System.Math.Min(newTotal, MaxStack);
return System.Math.Max(0, newTotal - MaxStack);
}
public int GetCount(string itemId) =>
stacks.TryGetValue(itemId, out int c) ? c : 0;
}
[Test]
public void AddItem_BelowStackLimit_AddsFully()
{
var inv = new Inventory();
int overflow = inv.AddItem("potion", 40);
Assert.AreEqual(0, overflow);
Assert.AreEqual(40, inv.GetCount("potion"));
}
[Test]
public void AddItem_PastStackLimit_ReturnsTheOverflowAmount()
{
var inv = new Inventory();
inv.AddItem("potion", 90);
int overflow = inv.AddItem("potion", 20); // 90 + 20 = 110, cap is 99
Assert.AreEqual(11, overflow); // 110 - 99
Assert.AreEqual(99, inv.GetCount("potion"));
}
และการ round trip ของ save/load — ข้อมูลรอดจากการเขียนออกไปแล้วอ่านกลับมาเป๊ะ ๆ ไหม ไม่มีอะไรหายหรือเสียหาย — เป็นหนึ่งใน test ที่มีค่ามากที่สุดในทั้งเกม เพราะบั๊กเรื่อง save มักมองไม่เห็นจนกว่าความคืบหน้าจริงของผู้เล่นจะถูกทำลาย:
[System.Serializable]
public class PlayerSaveData
{
public string playerName;
public int level;
public float[] position;
public List<string> inventoryItemIds;
}
[Test]
public void SaveData_SurvivesAJsonRoundTrip()
{
var original = new PlayerSaveData
{
playerName = "Aria",
level = 7,
position = new float[] { 12.5f, 0f, -3.2f },
inventoryItemIds = new List<string> { "sword_01", "potion" }
};
string json = JsonUtility.ToJson(original);
PlayerSaveData loaded = JsonUtility.FromJson<PlayerSaveData>(json);
Assert.AreEqual(original.playerName, loaded.playerName);
Assert.AreEqual(original.level, loaded.level);
Assert.AreEqual(original.position[0], loaded.position[0]);
Assert.AreEqual(original.inventoryItemIds.Count, loaded.inventoryItemIds.Count);
Assert.AreEqual(original.inventoryItemIds[0], loaded.inventoryItemIds[0]);
}
สังเกตว่า test สุดท้ายนี้ใช้ UnityEngine (JsonUtility) จริง แต่ก็ยังรันได้ดีในฐานะ EditMode test เพราะมันไม่ต้องใช้ scene ที่กำลังรัน, frame loop หรือ MonoBehaviour — มันกำลังทดสอบ serialization ไม่ใช่ gameplay เส้นแบ่งระหว่าง "ต้องการ engine ที่รันอยู่" กับ "แค่ใช้ library ของ engine" สำคัญกว่า "มันแตะ UnityEngine หรือเปล่า"
พฤติกรรมบางอย่างทดสอบไม่ได้จริง ๆ ถ้าไม่มี scene ที่กำลังรัน: อะไรก็ตามที่ขึ้นอยู่กับ method วงจรชีวิตของ MonoBehaviour (Awake, Start, Update), physics stepping, coroutine ที่ต้องการเฟรมจริง ๆ ผ่านไป หรือ animation Play Mode test ของ Unity จัดการเรื่องนี้พอดี: Test Runner จะเข้า Play Mode จริง ๆ รันเฟรมจริง และให้โค้ด test ของคุณรอระหว่างเฟรมด้วย yield — กลไก coroutine เดียวกับที่เรียนไปในบทก่อน ๆ เพียงแต่ขับเคลื่อน test แทน gameplay
using System.Collections;
using NUnit.Framework;
using UnityEngine;
using UnityEngine.TestTools;
public class PlayerJumpPlayModeTests
{
[UnityTest]
public IEnumerator Jump_WhenGrounded_GivesTheRigidbodyUpwardVelocity()
{
var go = new GameObject("Player", typeof(Rigidbody), typeof(PlayerController));
var rb = go.GetComponent<Rigidbody>();
var controller = go.GetComponent<PlayerController>();
yield return new WaitForFixedUpdate(); // let one physics step settle first
controller.Jump();
yield return new WaitForSeconds(0.1f); // let a couple of physics frames pass
Assert.Greater(rb.velocity.y, 0f);
Object.Destroy(go);
}
}
[UnityTest] (แทนที่จะเป็น [Test] ธรรมดา) รวมกับ return type แบบ IEnumerator คือสิ่งที่ทำให้ yield return ใช้ในงาน test ได้ — Test Runner จะปั๊มเฟรมจริงระหว่างแต่ละ yield เหมือน coroutine ที่รันตอน gameplay ปกติเป๊ะ ดังนั้นผลของ Jump() ต่อ Rigidbody จึงมีเวลาส่งผลจริงก่อนที่ assertion จะรัน Console output เมื่อ test นี้ผ่าน:
Test Runner (PlayMode)
PlayerJumpPlayModeTests
Jump_WhenGrounded_GivesTheRigidbodyUpwardVelocity PASS (114 ms)
1 passed, 0 failed, 0 skipped
Play Mode test ช้ากว่า EditMode test — การเข้า Play Mode, รอเฟรมจริง และเก็บกวาดทุกอย่างทีหลังล้วนใช้เวลาจริงบนนาฬิกา มักวัดเป็นวินาทีแทนที่จะเป็นมิลลิวินาทีแบบ EditMode test นี่คือเหตุผลที่ pure-logic test จากหัวข้อ 9 สำคัญมาก: การผลัก logic ให้มากที่สุดเท่าที่ทำได้เข้าไปใน class ธรรมดาที่ไม่พึ่ง engine อย่าง DamageMath ทำให้ test ส่วนใหญ่ของคุณยังคงเป็น EditMode test ที่เร็ว และมีแค่พฤติกรรมที่พึ่ง engine จริง ๆ เท่านั้นที่ต้องใช้เส้นทาง Play Mode ที่ช้ากว่า
เป็นไปได้ที่จะใช้ testing มากเกินไป และเกมมีสัดส่วนโค้ดที่มากกว่าปกติที่ test ไม่ช่วยอะไรจริง ๆ กฎง่าย ๆ: ถ้าคำตอบที่ถูกต้องคือตัวเลขหรือข้อเท็จจริงที่คุณคำนวณด้วยมือได้ มันคุ้มค่าที่จะทดสอบ ถ้าคำตอบที่ถูกต้องคือ "มันรู้สึกใช่ไหม" มันไม่คุ้ม — แบบหลังต้องการ playtester ที่เป็นมนุษย์ ไม่ใช่ assertion
คุ้มค่าจะเขียน unit test เพราะมีคำตอบที่ถูกต้องเพียงหนึ่งเดียวสำหรับ input ที่กำหนด:
ไม่คุ้มค่าจะเขียน unit test เพราะไม่มีตัวเลขที่ถูกต้องหนึ่งเดียวให้ assert เทียบด้วย — มีแค่การตัดสินใจเชิงอัตวิสัยที่มนุษย์ต้องเป็นคนตัดสิน:
Physics.Raycast โดน collider แค่ต้องพิสูจน์ว่าโค้ด ของคุณเอง ตอบสนองถูกต้องเมื่อมันโดนแล้วมีกรณีกลาง ๆ ที่ควรพูดถึงเป็นพิเศษ: pathfinding, procedural generation และ AI decision logic คุ้มค่าที่จะทดสอบ แต่ไม่ใช่ด้วยการ assert หา output "ที่ถูกต้อง" หนึ่งเดียว เพราะบ่อยครั้งมี path หรือ layout ที่ถูกต้องมากกว่าหนึ่งแบบ ให้ assert หา คุณสมบัติ (properties) ที่ผลลัพธ์ที่ถูกต้องต้องมีแทน — "path ที่คืนมาไม่เคยตัดผ่าน wall tile", "던전ที่ generate มามีอย่างน้อยหนึ่งเส้นทางจากทางเข้าไปทางออก", "AI ไม่เคยเลือก ability ที่กำลัง cooldown อยู่" — คุณสมบัติที่เป็นจริงในทุกคำตอบที่ถูกต้อง ไม่ใช่แค่คำตอบเดียว
Regression คือบั๊กที่กลับมาอีกหลังจากถูกแก้ไปแล้วครั้งหนึ่ง — ปกติเพราะการเปลี่ยนแปลงในภายหลังแตะโค้ดเดียวกัน แล้วไม่มีใครสังเกตว่าปัญหาเก่ากลับมาแล้ว Regression test คือ test ที่เขียนขึ้นมาเจาะจงเพราะบั๊กจริง ๆ เคยเกิดขึ้น: มันเข้ารหัส scenario ที่เคยพังไว้เป็นการตรวจสอบอัตโนมัติถาวร ดังนั้นถ้าบั๊กถูกนำกลับเข้ามาอีก test จะ fail ทันที แทนที่จะให้ผู้เล่นมาเจอเองอีกหลายเดือนถัดมา
หัวข้อ 8 พาไล่ดูบั๊กที่ test นี้ล็อกไว้เป๊ะ ๆ: fall damage เกิดขึ้นทันทีหลัง load checkpoint เพราะ LoadCheckpoint ไม่เคย reset fallStartY เมื่อแก้แล้ว regression test สำหรับมันจะหน้าตาแบบนี้:
using System.Collections;
using NUnit.Framework;
using UnityEngine;
using UnityEngine.TestTools;
public class PlayerHealthRegressionTests
{
[UnityTest]
public IEnumerator LoadCheckpoint_ResetsFallTracking_SoNoFalseFallDamage()
{
// Regression test for TICKET-482: fall damage was firing
// immediately after a pause-menu checkpoint load.
var go = new GameObject("Player", typeof(PlayerHealth));
var health = go.GetComponent<PlayerHealth>();
health.BeginFalling(fromHeight: 50f); // a long fall in progress
health.LoadCheckpoint(new Vector3(0f, 1f, 0f)); // checkpoint on flat ground
yield return null; // let LateUpdate's fall-damage check run for one frame
Assert.AreEqual(health.MaxHealth, health.CurrentHealth,
"Loading a checkpoint mid-fall must not deal fall damage. " +
"See TICKET-482 -- do not let this regress.");
Object.Destroy(go);
}
}
รายละเอียดสองอย่างที่ทำให้นี่เป็น regression test ที่ดี ไม่ใช่แค่ test ทั่วไป: comment ระบุ ticket ที่ track บั๊กนี้ไว้เป๊ะ ๆ (เพื่อให้ใครก็ตามที่มาอ่านตอน test fail ในภายหลังมีเส้นทางย้อนกลับไปหารายงานต้นฉบับได้) และข้อความตอน fail บอกกฎเป็นภาษาธรรมดา ไม่ใช่แค่ตัวเลขดิบ ๆ เพื่อให้คนที่ทำให้มันพังในครั้งต่อไปเข้าใจทันทีว่า ทำไม มันถึงสำคัญ ไม่ใช่แค่ว่าตัวเลขสองตัวไม่ตรงกัน
Smoke test เป็นการตรวจสอบอัตโนมัติแบบกว้างกว่า ต่างออกไป: แทนที่จะเช็ค logic ชิ้นใดชิ้นหนึ่งอย่างละเอียด มันเช็คว่าทั้งเกมยังเปิดขึ้นมาได้และไปถึง state ที่เล่นได้พื้นฐานหรือไม่ — "มันยังบูตได้ไหม" ไม่ใช่ "ทุก feature ถูกต้องไหม" ทีมมักรัน smoke test อัตโนมัติทุกครั้งที่มีการเปลี่ยนแปลง ผ่าน continuous integration (CI พูดถึงแบบแนวคิดไว้ในบทก่อนหน้า): server จะ build โปรเจกต์แล้วรัน test แบบ headless ทันทีที่มีการ push โค้ด โดยไม่ต้องให้มนุษย์เปิดเกมด้วยมือก่อน
using System.Collections;
using NUnit.Framework;
using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEngine.TestTools;
public class BootSmokeTests
{
[UnityTest]
public IEnumerator Game_BootsAndReachesTheMainMenu()
{
SceneManager.LoadScene("Boot");
yield return null; // let the scene load start
float elapsed = 0f;
const float timeout = 10f;
while (GameObject.Find("MainMenuCanvas") == null && elapsed < timeout)
{
elapsed += Time.deltaTime;
yield return null;
}
Assert.IsNotNull(GameObject.Find("MainMenuCanvas"),
"Main menu did not appear within 10 seconds -- the game likely failed to boot.");
}
}
รันจาก command line ใน batch mode (ไม่มีหน้าต่าง ไม่มีมนุษย์อยู่หน้าคีย์บอร์ด เหมาะกับ CI server) Unity รัน test แบบนี้ได้แบบ headless:
Unity -batchmode -nographics -projectPath . -runTests -testPlatform PlayMode -testResults results.xml
Smoke test ที่ fail บน CI คือหนึ่งใน signal ที่มีค่าที่สุดที่ทีมจะได้รับ: มันหมายความว่าการเปลี่ยนแปลงที่เพิ่ง push ไปทำให้เกมพังหนักถึงขั้นไปไม่ถึง main menu ด้วยซ้ำ จับได้ภายในไม่กี่นาทีแทนที่จะให้คนถัดไปที่เปิดโปรเจกต์มาเจอเอง
Automated test ทั้งสามแบบจากบทนี้ประกอบกันเป็นรูปทรงที่ควรจำไว้ มักวาดเป็นปิรามิด: unit test เร็ว ๆ จำนวนมากอยู่ฐาน, Play Mode test ที่น้อยกว่าและช้ากว่าอยู่ชั้นบนนั้น และการตรวจสอบทั้งเกมที่กว้าง ช้า และมีจำนวนน้อย (smoke test บวก manual QA จากหัวข้อ 13) อยู่บนสุด:
สังเกตว่าปิรามิดนี้เป็นแนวทางเรื่อง สัดส่วน ไม่ใช่ checklist ที่เรียกร้องให้ลงแรงเท่ากันทุกชั้น — ประเด็นคือ automated coverage ส่วนใหญ่ของคุณควรมาจากชั้นล่างสุดที่ถูกและเร็ว โดยเก็บชั้นบนสุดที่ช้าและแพงไว้สำหรับสิ่งที่ไม่มีอะไรอื่นเช็คได้
Automated test จับสิ่งที่คุณคิดจะเขียน test ไว้เท่านั้น QA (quality assurance — วิชาชีพ และมักเป็นทีมเฉพาะทาง ที่คอยหาปัญหาก่อนที่ผู้เล่นจะเจอ) จับสิ่งที่ไม่มีใครคิดจะทดสอบ: ability ใหม่นี้ combo กับ ability เก่านั้นแล้วทำให้ economy พังไหม, UI นี้เข้าใจง่ายจริงไหมสำหรับคนที่ไม่เคยเห็นมันมาก่อน, เกมยังเล่นได้ดีบนมือถือรุ่นห้าปีที่แล้วไหม หัวข้อนี้ครอบคลุมสามส่วนของ QA ที่โปรแกรมเมอร์ทำงานด้วยโดยตรง
Test plan คือรายการที่เขียนไว้ว่าต้องเช็คอะไรบ้างสำหรับ feature หนึ่ง ๆ ก่อนที่จะถือว่าเสร็จ เพื่อไม่ให้การทดสอบขึ้นอยู่กับว่า QA tester คนหนึ่งบังเอิญจำได้ว่าต้องลองอะไร:
Feature: Checkpoint loading (pause menu)
1. Load a checkpoint while standing on flat ground.
Expect: no damage, player position matches the checkpoint exactly.
2. Load a checkpoint while mid-fall.
Expect: no fall damage (see TICKET-482).
3. Load a checkpoint while a status effect (poison) is active.
Expect: status effect either persists correctly or is clearly
removed -- not left in a half-applied state.
4. Load a checkpoint immediately after taking damage, before the
hit-react animation finishes.
Expect: hit-react does not persist into the reloaded state.
5. Rapidly open the pause menu and load a checkpoint twice in a row.
Expect: no double-load, no duplicated player object.
สังเกตว่าขั้นตอนที่ 2 มีอยู่เพราะบั๊กที่บทนี้ตามมาตลอดโดยเฉพาะ — test plan ที่ดีจะดูดซับบั๊กจริงทุกตัวที่เจอเข้าไว้ ดังนั้นข้อผิดพลาดหมวดเดียวกันนั้นจะถูกเช็คในทุก feature ในอนาคตที่แตะระบบเดียวกัน ไม่ใช่แค่ตัวที่บังเอิญพังก่อน
งานทั้งหมดของ bug report คือให้โปรแกรมเมอร์ reproduce บั๊กได้โดยไม่ต้องคุยกับคนที่ file มันไว้ เทียบรายงานสองแบบของบั๊กเดียวกัน:
BAD:
"Fall damage is bugged, please fix."
GOOD:
Title: Fall damage triggers immediately after loading a checkpoint
mid-fall (TICKET-482)
Build: 1.7.2-dev (commit a1b2c3d)
Platform: Windows, also seen on Switch
Steps to reproduce:
1. Start a new game, let health regenerate to full.
2. Walk off the edge of the bridge in "Docks."
3. While falling, open the pause menu, choose "Load Last Checkpoint."
4. Watch the health bar the instant the level finishes loading.
Expected: health stays full (checkpoint is on flat ground).
Actual: health drops by 35-50 instantly.
Frequency: 10/10, always reproduces with these exact steps.
Does NOT reproduce when respawning from the death screen instead
of the pause menu (only the checkpoint-load path is affected).
Attached: log file (console_log.txt), 15-second video clip.
Severity: High (breaks a core traversal loop). Priority: next patch.
เวอร์ชันที่ดีนั้นแทบจะเป็นคำต่อคำเดียวกับ repro จากหัวข้อ 7 บวก field เพิ่มไม่กี่อัน: เจอบั๊กบน build ไหนแน่ ๆ (commit hash หรือเลข version เพื่อไม่ให้ใครเสียเวลา debug version ที่เปลี่ยนไปแล้ว), platform ไหน, เกิดบ่อยแค่ไหน และอะไรที่ ไม่ ทำให้มันเกิด field สุดท้ายนั้นเพียงอย่างเดียวก็ประหยัดเวลาโปรแกรมเมอร์ไปได้หลายชั่วโมง — มันคือสมมติฐานที่ตั้งไว้ครึ่งหนึ่งให้แล้วฟรี ๆ ก่อนที่พวกเขาจะเปิด debugger ด้วยซ้ำ
เมื่อเกม ship ออกไปแล้ว crash ส่วนใหญ่ไม่มีใครในทีมเห็นโดยตรงเลย — มันเกิดบนเครื่องผู้เล่น ในสภาพแวดล้อมที่ไม่มีใครเคยทดสอบ และผู้เล่นก็ไม่น่าจะ file bug report แบบละเอียด Crash reporting service จะจับ crash โดยอัตโนมัติ, บันทึก stack trace (ไอเดีย call-stack เดียวกับหัวข้อ 5 แต่บันทึกอัตโนมัติในจังหวะที่ crash เกิด แทนที่จะเป็นโปรแกรมเมอร์ step ผ่านแบบสด ๆ) แล้วอัปโหลดมัน เพื่อให้ทีมรู้เรื่อง crash ที่ไม่งั้นจะไม่มีทางได้ยินเลย
ปัญหาคือ: build ที่ shipped มักจะถูก strip และบางทีก็ compile ผ่านขั้นตอนกลาง (IL2CPP backend ของ Unity แปลง C# เป็น C++ แล้วเป็น native machine code สำหรับหลาย platform) ดังนั้น raw crash stack trace จากเครื่องผู้เล่นมักแสดง address ที่ไม่มีความหมายหรือชื่อที่ถูก mangle แทนที่จะเป็นชื่อฟังก์ชันกับเลขบรรทัดที่อ่านได้:
Symbolication คือขั้นตอนการแปลนี้เป๊ะ ๆ: จับคู่ raw memory address กลับไปเป็นชื่อฟังก์ชันและเลขบรรทัดที่มนุษย์อ่านได้ โดยใช้ symbol file ที่สร้างขึ้นตอน build (เทียบได้กับ debug info ที่ flag -g เพิ่มให้ gdb ในบท C) ที่ map address ไปยังชื่อสำหรับ build และ platform ที่ เจาะจงนั้น นี่คือเหตุผลที่ build pipeline เก็บ symbol file ของทุก build ที่ shipped ไว้ โดย key ด้วย version หรือ commit hash: ถ้าไม่มี symbol file ที่ตรงกับ build ที่ crash จริง ๆ raw stack trace จากผู้เล่นก็แทบไม่มีประโยชน์ เป็นแค่รายการตัวเลข
เมื่อ symbolicate แล้ว crash report มักถูกจัดกลุ่มอัตโนมัติตามว่าฟังก์ชันไหน crash และเพราะอะไร (null reference ที่บรรทัดเดียวกัน ในผู้เล่นหลายพันคน คือบั๊กเดียว ไม่ใช่หลายพันบั๊ก) จัดอันดับตามจำนวนผู้เล่นที่เจอแต่ละอัน — ซึ่งเปลี่ยน "ผู้เล่นบางคน crash เป็นครั้งคราวโดยไม่รู้สาเหตุ" ให้กลายเป็นรายการที่จัดลำดับความสำคัญและอ่านได้: แก้ crash ที่กระทบ 40,000 session ก่อนอันที่กระทบแค่สิบสอง
git bisect — binary search ในตัวของ Git สำหรับประวัติ commit ใช้หา commit ที่นำบั๊กเข้ามาเป๊ะ ๆ[UnityTest] — attribute (รวมกับ return type แบบ IEnumerator) ที่ทำให้ test yield ข้ามเฟรมจริงได้(a) ความเชื่อ: "ผมเชื่อว่า critical-hit multiplier ถูกใช้สองรอบในบางเฟรม เพราะมีสองระบบที่ต่างกันเรียกฟังก์ชัน damage สำหรับ hit เดียวกัน — เช่น script ตรวจจับ melee hit กับ script track combo แยกต่างหาก ที่ทั้งคู่ react กับ collision เดียวกัน"
(b) การทำนาย: "ถ้าเป็นจริง แล้วในเฟรมที่บั๊กโผล่มา CalculateDamage (หรืออะไรก็ตามที่ใช้ critical multiplier) ต้องถูกเรียก สองครั้ง สำหรับ hit เดียวกัน ไม่ใช่ครั้งเดียว ในเฟรมปกติ มันควรถูกเรียกแค่ครั้งเดียวเท่านั้น"
(c) Test ที่เล็กที่สุด: ตั้ง breakpoint (หรือเนื่องจากมันเกิดไม่สม่ำเสมอและจับด้วยมือยาก ใช้ log line ที่มี context ตามเทคนิคหัวข้อ 6 เรื่อง "context ไม่ใช่ here1") ข้างในฟังก์ชัน damage แล้ว log calling context กับ hit ID ทุกครั้งที่มันรัน: Debug.Log($"[Damage] hitId={hitId} frame={Time.frameCount} isCritical={isCritical}") เล่นไปจนกว่าบั๊กจะโผล่มา แล้วอ่าน log: ถ้า hitId เดียวกันปรากฏสองครั้งในเฟรมเดียวกันหรือเฟรมติดกัน ความเชื่อนั้นได้รับการยืนยัน — มีสองจุดเรียกที่ react กับ hit เดียวกัน ถ้าแต่ละ hit log แค่ครั้งเดียวเสมอ ความเชื่อนั้นผิด และสาเหตุจริงอยู่ที่อื่น (บางทีตัว multiplier เองอาจผิด ไม่ใช่จำนวนครั้งที่เรียก) — ถึงเวลาตั้งความเชื่อใหม่
v2.0 กับ HEAD ปัจจุบัน เขียนคำสั่ง git bisect ที่แน่นอนที่คุณจะรันเพื่อเริ่มค้นหาและกำหนดจุดปลายสองจุดที่รู้อยู่แล้ว จากนั้นอธิบายคร่าว ๆ ว่าคุณต้องทดสอบ commit ด้วยมือกี่ครั้งเพื่อหาตัวที่ bad ตัวแรกเป๊ะ ๆ และอธิบายว่าคุณจะทำให้การค้นหาทั้งหมดเป็นอัตโนมัติด้วย git bisect run แทนได้ยังไง โดยสมมติว่าคุณมี script check_speed.sh อยู่แล้วที่ exit 0 เมื่อความเร็วการเคลื่อนที่ไม่ขึ้นกับ frame rate และ 1 เมื่อขึ้นกับมันgit bisect start
git bisect bad HEAD
git bisect good v2.0
ด้วย 16 commit ระหว่างจุดปลายทั้งสอง แต่ละการทดสอบจะลดผู้ต้องสงสัยที่เหลือลงประมาณครึ่งหนึ่ง: 16 -> 8 -> 4 -> 2 -> 1 ดังนั้นประมาณ 4 ครั้งของการทดสอบด้วยมือ (log2(16) = 4) ก็เพียงพอที่จะเจอ commit bad ตัวแรกเป๊ะ ๆ แทนที่จะทดสอบถึง 16 commit ทีละอัน
เพื่อทำให้เป็นอัตโนมัติเต็มรูปแบบแทนการทดสอบตัวเลือกแต่ละอันด้วยมือ:
git bisect start
git bisect bad HEAD
git bisect good v2.0
git bisect run ./check_speed.sh
Git จะ checkout commit ที่เป็นตัวเลือกแต่ละอันอัตโนมัติ รัน check_speed.sh กับมัน อ่าน exit code ของ script (0 คือ good, non-zero คือ bad) แล้วค่อย ๆ จำกัดวงโดยไม่ต้อง rebuild หรือ play-test ด้วยมือเลย จบด้วยรายงาน "first bad commit" เดียวกันในตอนท้าย หลังจากนั้น git bisect reset จะพา working tree กลับไปที่ branch ที่คุณเริ่มต้นไว้
UnityEngine เลย:
public static class ArmorMath
{
public static int ApplyArmor(int incomingDamage, int armor)
{
int reduced = incomingDamage - armor;
return System.Math.Max(reduced, 1);
}
}
เขียน method [Test] อย่างน้อยสามอันใน Unity Test Framework EditMode test class ที่ครอบคลุม: (1) กรณีปกติที่ armor ลด damage ได้บางส่วน (2) กรณีที่ armor มากกว่า damage ที่เข้ามา และ (3) กรณีที่ armor เป็นศูนย์ บอกว่าแต่ละ test assert อะไรและทำไมusing NUnit.Framework;
public class ArmorMathTests
{
[Test]
public void PartialArmor_ReducesDamageByArmorAmount()
{
int result = ArmorMath.ApplyArmor(incomingDamage: 20, armor: 6);
Assert.AreEqual(14, result); // 20 - 6
}
[Test]
public void ArmorGreaterThanDamage_ClampsToMinimumOfOne()
{
int result = ArmorMath.ApplyArmor(incomingDamage: 5, armor: 50);
Assert.AreEqual(1, result); // 5 - 50 would be negative, clamped to 1
}
[Test]
public void ZeroArmor_PassesDamageThroughUnchanged()
{
int result = ArmorMath.ApplyArmor(incomingDamage: 20, armor: 0);
Assert.AreEqual(20, result); // nothing to subtract
}
}
Test แรกเช็ค path การลบธรรมดาด้วยผลลัพธ์ที่ทำนายได้ตรง ๆ Test ที่สองเช็ค clamp: ถ้าไม่มีมัน armor ที่หนักมากจะทำให้ "hit" ติดลบหรือเป็นศูนย์ที่กลายเป็นการรักษาหรือไม่ทำอะไรเลย ดังนั้น test นี้ล็อกกฎไว้ว่า hit ที่ลงแล้วต้องสร้าง damage อย่างน้อย 1 เสมอ Test ที่สามเช็ค boundary ที่ armor ไม่มีส่วนช่วยอะไรเลย ยืนยันว่าฟังก์ชันไม่ได้ clamp หรือเปลี่ยน damage โดยไม่ตั้งใจเมื่อไม่มี armor ให้ใช้ รวมกันแล้ว test ทั้งสามตัวครอบคลุม path ปกติและขอบทั้งสองด้านของพฤติกรรมฟังก์ชัน ซึ่งเป็นรูปแบบที่ฟังก์ชันเล็ก ๆ pure แบบนี้สมควรได้รับพอดี — เร็ว กำหนดผลได้แน่นอน และไม่ต้องใช้ scene หรือ GameObject ในการรัน
บทนี้ครอบคลุมทั้งสองครึ่งของการหาบั๊กและทำให้มันแก้แล้วอยู่แบบนั้นตลอดไป: hypothesis loop ที่เปลี่ยนการ debug จากการเดาสุ่มให้กลายเป็นการค้นหาที่มีทิศทาง, binary search ทั้งข้ามโค้ดและข้ามประวัติ commit, การใช้ breakpoint, conditional breakpoint, watch และ call stack ของ debugger — ไอเดียเดียวกับที่คุณรู้จักจาก gdb อยู่แล้ว แค่ปุ่มต่างกัน — logging ที่มี context จริงและ level ที่สมเหตุสมผล การได้ repro ที่แน่นอนก่อนแตะโค้ดแก้ และการรู้จัก uninitialized data, race และ frame-rate dependence ว่าเป็นสามผู้ต้องสงสัยหลักเบื้องหลังบั๊กที่เกิดไม่สม่ำเสมอ ในฝั่ง testing: unit test สำหรับ pure logic, Play Mode test สำหรับอะไรก็ตามที่ต้องการ engine ที่ยังมีชีวิตอยู่, การรู้ว่าอะไรไม่คุ้มค่าจะทดสอบจริง ๆ, regression test ที่รักษาบั๊กที่แก้แล้วให้แก้อยู่แบบนั้น, automated smoke test ที่จับ build ที่พังได้ภายในไม่กี่นาที และกระบวนการ QA — test plan, bug report ที่ลงมือทำได้จริง และ crash reporting — ที่จับทุกอย่างที่ไม่มี test เขียนไว้ ไม่มีเครื่องมือไหนในนี้แทนที่การคิดอย่างรอบคอบได้ มันแค่ทำให้แน่ใจว่าความคิดที่คุณทำไปแล้วไม่ต้องทำซ้ำใหม่ทุกครั้งที่บั๊กตัวเดิมพยายามจะกลับมา