| Topic 41 | Test to Code (เขียน Test ก่อนเขียน Code) |
|---|
ฉบับพิมพ์ครั้งแรกของหนังสือเล่มนี้ถูกเขียนขึ้นในยุคที่ยังดั้งเดิมกว่านี้มาก ตอนนั้น developer ส่วนใหญ่ไม่เขียน test เลย—จะเขียนไปทำไม พวกเขาคิด โลกก็จะแตกในปี 2000 อยู่ดี
ในหนังสือเล่มนั้น เรามี section ที่พูดถึงวิธีเขียน code ให้ test ได้ง่าย มันเป็นวิธีแอบแฝงที่ใช้โน้มน้าวให้ developer ยอมเขียน test จริง ๆ สักที
ยุคนี้เป็นยุคที่รู้แจ้งกว่าเดิมมาก ถ้ายังมี developer ที่ไม่เขียน test อยู่บ้าง อย่างน้อยพวกเขาก็รู้แล้วว่าควรจะเขียน
แต่มันก็ยังมีปัญหาอยู่ดี เวลาเราถาม developer ว่าทำไม ถึงเขียน test พวกเขามองเราเหมือนเราเพิ่งถามว่ายังเขียน code ด้วยบัตรเจาะรู (punched cards) อยู่หรือเปล่า แล้วก็ตอบว่า “ก็เพื่อให้แน่ใจว่า code มันทำงานได้ไง” พร้อมกับนัย ๆ ว่า “ถามได้” อยู่ในน้ำเสียง และเราคิดว่ามันผิด
แล้ว เรา คิดว่าอะไรคือสิ่งสำคัญเกี่ยวกับการ testing กันแน่? แล้วเราคิดว่าคุณควรจะเข้าหามันยังไง?
เริ่มต้นด้วยประโยคที่ค่อนข้างจะกล้าหาญสักหน่อย
| Tip 66 | Testing ไม่ได้มีไว้เพื่อหาบั๊ก |
|---|
เราเชื่อว่าประโยชน์หลัก ๆ ของการ testing เกิดขึ้นตอนที่คุณคิดและเขียน test ต่างหาก ไม่ใช่ตอนที่คุณรัน test เหล่านั้น
Thinking About Tests (คิดเรื่อง Test)
เป็นเช้าวันจันทร์ และคุณกำลังจะเริ่มลงมือเขียน code ใหม่ คุณต้องเขียนอะไรสักอย่างที่ query database เพื่อดึงรายชื่อคนที่ดูวิดีโอมากกว่า 10 คลิปต่อสัปดาห์บนเว็บไซต์ “วิดีโอล้างจานที่ตลกที่สุดในโลก” ของคุณ
คุณเปิด editor ขึ้นมา แล้วเริ่มเขียน function ที่ทำ query นั้น
| | def return_avid_viewers do |
|---|---|
| | # ... hmmm ... |
| | end |
หยุดก่อน! คุณรู้ได้ยังไงว่าสิ่งที่คุณกำลังจะทำนั้นเป็นเรื่องดี?
คำตอบคือคุณไม่มีทางรู้ได้เลย ไม่มีใครรู้ได้ แต่การคิดเรื่อง test สามารถทำให้มันมีโอกาสเป็นเรื่องดีมากขึ้นได้ นี่คือวิธีที่มันทำงาน
เริ่มจากการจินตนาการว่าคุณเขียน function เสร็จแล้ว และตอนนี้ต้องมา test มัน คุณจะทำยังไง? แน่นอนว่าคุณคงอยากใช้ test data บางอย่าง ซึ่งก็หมายความว่าคุณอยากทำงานกับ database ที่คุณควบคุมได้เอง ตอนนี้บาง framework ก็จัดการเรื่องนี้ให้คุณได้ โดยรัน test กับ test database แต่ในกรณีของเรา นั่นหมายความว่าเราควรจะส่ง database instance เข้าไปใน function ของเรา แทนที่จะใช้ global instance เพราะมันจะทำให้เราเปลี่ยนมันได้ระหว่าง testing
| | def return_avid_users(db) do |
|---|
จากนั้นเราก็ต้องคิดต่อว่าจะเติม test data นั้นเข้าไปยังไง requirement บอกว่าต้องการ “รายชื่อคนที่ดูวิดีโอมากกว่า 10 คลิปต่อสัปดาห์” เราจึงไปดู database schema เพื่อหา field ที่น่าจะเกี่ยวข้อง เราเจอ field ที่น่าจะใช่อยู่สอง field ใน table ที่บันทึกว่าใครดูอะไรไปแล้วบ้าง opened_video กับ completed_video เพื่อจะเขียน test data ของเรา เราต้องรู้ว่าจะใช้ field ไหน แต่เราไม่รู้ว่า requirement หมายถึงอะไร และคนติดต่อฝั่ง business ก็ไม่อยู่ งั้นเราขอเล่นตุกติกนิดหน่อยละกัน ด้วยการส่งชื่อ field เข้าไปเป็นพารามิเตอร์ (ซึ่งจะทำให้เรา test สิ่งที่เรามีได้ และอาจจะเปลี่ยนมันทีหลังก็ได้)
| | def return_avid_users(db, qualifying_field_name) do |
|---|
เราเริ่มต้นด้วยการคิดถึง test ของเรา และโดยที่ยังไม่ได้เขียน code สักบรรทัดเดียว เราก็ค้นพบสิ่งใหม่ ๆ ไปแล้วสองอย่าง และใช้มันเปลี่ยน API ของ method เราไปเรียบร้อย
Tests Drive Coding (Test เป็นตัวนำทาง Coding)
ในตัวอย่างก่อนหน้านี้ การคิดเรื่อง testing ทำให้เราลด coupling ใน code ของเราลง (ด้วยการส่ง database connection เข้าไปแทนที่จะใช้ global instance) และเพิ่มความยืดหยุ่นขึ้น (ด้วยการทำให้ชื่อ field ที่เรา test เป็นพารามิเตอร์) การคิดถึงการเขียน test สำหรับ method ของเรา ทำให้เรามองมันจากมุมภายนอก เหมือนเราเป็น client ของ code นั้น ไม่ใช่คนเขียนมันเอง
| Tip 67 | Test คือ User คนแรกของ Code คุณ |
|---|
เราคิดว่านี่น่าจะเป็นประโยชน์ที่ยิ่งใหญ่ที่สุดที่ testing มอบให้เรา testing คือ feedback สำคัญที่คอยนำทาง coding ของคุณ
function หรือ method ที่ผูกติด (coupled) กับ code อื่นแน่นเกินไป จะ test ได้ยาก เพราะคุณต้องตั้งค่า environment ทั้งหมดนั้นก่อนที่จะรัน method ของคุณได้ด้วยซ้ำ ดังนั้นการทำให้สิ่งที่คุณเขียน test ได้ ก็ช่วยลด coupling ของมันไปในตัวด้วย
และก่อนที่คุณจะ test อะไรสักอย่างได้ คุณต้องเข้าใจมันก่อน ฟังดูเหมือนเรื่องเล็ก ๆ แต่ในความเป็นจริงเราทุกคนต่างก็เคยลงมือเขียน code โดยที่ยังเข้าใจสิ่งที่ต้องทำแบบเลือน ๆ มาแล้วทั้งนั้น เราปลอบใจตัวเองว่าเดี๋ยวก็คิดออกไปเรื่อย ๆ ระหว่างทาง เดี๋ยวค่อยเติม code สำหรับรองรับ boundary conditions ทีหลัง แล้วก็เรื่อง error handling ด้วย และสุดท้าย code ก็ยาวกว่าที่ควรจะเป็นห้าเท่า เพราะมันเต็มไปด้วย conditional logic และ special case เต็มไปหมด แต่พอส่องไฟของ test ลงไปบน code นั้น ทุกอย่างก็ชัดเจนขึ้นมาทันที ถ้าคุณคิดเรื่องการ test boundary conditions และวิธีที่มันจะทำงาน ก่อน ที่คุณจะเริ่มเขียน code คุณอาจจะเจอ pattern ใน logic ที่ช่วยทำให้ function เรียบง่ายขึ้นได้ ถ้าคุณคิดถึง error conditions ที่ต้อง test ไว้ก่อน คุณก็จะออกแบบโครงสร้างของ function ให้สอดคล้องกับมัน
Test-Driven Development
มีสำนักคิดหนึ่งในวงการ programming ที่บอกว่า ในเมื่อการคิดถึง test ล่วงหน้ามีประโยชน์ขนาดนี้ แล้วทำไมไม่เขียนมันล่วงหน้าไปเลยล่ะ? พวกเขาเรียกสิ่งนี้ว่า test-driven development หรือ TDD คุณอาจจะเคยเห็นมันถูกเรียกว่า test-first development ด้วยเช่นกัน [58]
วงจรพื้นฐานของ TDD คือ
-
ตัดสินใจเลือก functionality เล็ก ๆ ชิ้นหนึ่งที่คุณอยากเพิ่มเข้าไป
-
เขียน test ที่จะผ่านก็ต่อเมื่อ functionality นั้นถูก implement แล้วเท่านั้น
-
รัน test ทั้งหมด แล้วตรวจสอบว่ามีแค่ test ที่คุณเพิ่งเขียนเท่านั้นที่ fail
-
เขียน code จำนวนน้อยที่สุดเท่าที่จำเป็นเพื่อให้ test ผ่าน แล้วตรวจสอบว่า test ทั้งหมดรันผ่านเรียบร้อยดี
-
Refactor code ของคุณ ลองดูว่ามีทางไหนที่จะปรับปรุงสิ่งที่คุณเพิ่งเขียนได้บ้างไหม (ทั้ง test หรือ function) แล้วตรวจสอบให้แน่ใจว่า test ยังผ่านอยู่เมื่อคุณทำเสร็จ
ไอเดียคือวงจรนี้ควรจะสั้นมาก ๆ ในระดับไม่กี่นาที เพื่อให้คุณเขียน test อยู่ตลอดเวลา แล้วก็ทำให้มันทำงานได้
เรามองว่า TDD มีประโยชน์มากสำหรับคนที่เพิ่งเริ่มต้นทำ testing ถ้าคุณทำตาม workflow ของ TDD คุณจะมั่นใจได้ว่าคุณมี test สำหรับ code ของคุณอยู่เสมอ และนั่นหมายความว่าคุณจะคิดถึง test ของคุณอยู่ตลอดเวลาด้วย
แต่เราก็เคยเห็นคนกลายเป็นทาสของ TDD เหมือนกัน สิ่งนี้แสดงออกมาได้หลายรูปแบบ
-
พวกเขาใช้เวลามหาศาลไปกับการทำให้แน่ใจว่ามี test coverage ครบ 100% เสมอ
-
พวกเขามี test ที่ซ้ำซ้อนเยอะมาก ยกตัวอย่างเช่น ก่อนที่จะเขียน class ขึ้นมาครั้งแรก ผู้ที่ยึดถือ TDD หลายคนจะเขียน test ที่ fail ที่อ้างอิงถึงชื่อ class นั้นก่อน มันก็ fail แน่นอน จากนั้นพวกเขาก็เขียน class เปล่า ๆ ขึ้นมา แล้วมันก็ผ่าน แต่ตอนนี้คุณก็มี test ที่ไม่ได้ทำอะไรเลยสักอย่าง test ตัวถัดไปที่คุณเขียนก็จะอ้างอิงถึง class นี้เหมือนกัน ทำให้ test ตัวแรกไม่จำเป็นไปโดยปริยาย และมีสิ่งที่ต้องแก้เพิ่มขึ้นอีก ถ้าชื่อ class เปลี่ยนไปในภายหลัง และนี่ก็แค่ตัวอย่างเล็ก ๆ เท่านั้น
-
การออกแบบของพวกเขามักจะเริ่มจากล่างขึ้นบนเสมอ (ดู Bottom-Up vs. Top-Down vs. The Way You Should Do It )
Bottom-Up vs. Top-Down vs. The Way You Should Do It (จากล่างขึ้นบน vs. จากบนลงล่าง vs. วิธีที่คุณควรทำจริง ๆ)
ย้อนกลับไปในยุคที่วงการ computing ยังเด็กและไร้กังวล มีสำนักคิดด้านการออกแบบอยู่สองแนวทาง คือ top-down กับ bottom-up ฝ่าย top-down บอกว่าคุณควรเริ่มจากปัญหาภาพรวมที่คุณกำลังจะแก้ แล้วแตกมันออกเป็นชิ้นเล็ก ๆ จำนวนหนึ่ง จากนั้นก็แตกแต่ละชิ้นให้เล็กลงไปอีก ทำแบบนี้ไปเรื่อย ๆ จนกว่าจะได้ชิ้นส่วนที่เล็กพอจะเขียนเป็น code ได้
ส่วนฝ่าย bottom-up จะสร้าง code เหมือนที่คุณสร้างบ้านหลังหนึ่ง พวกเขาเริ่มจากด้านล่าง สร้าง layer ของ code ที่ให้ abstraction ที่ใกล้เคียงกับปัญหาที่พวกเขากำลังจะแก้มากขึ้น จากนั้นก็เพิ่ม layer ใหม่เข้าไปอีก ด้วย abstraction ที่สูงขึ้นไปอีกระดับ ทำแบบนี้ไปเรื่อย ๆ จนกระทั่ง layer สุดท้ายกลายเป็น abstraction ที่แก้ปัญหานั้นได้ “Make it so….”
ทั้งสองสำนักคิดนี้ใช้ไม่ได้ผลจริง เพราะทั้งคู่มองข้ามหนึ่งในแง่มุมที่สำคัญที่สุดของ software development ไป นั่นคือ เราไม่รู้หรอกว่าเรากำลังทำอะไรอยู่ตอนที่เราเริ่มต้น ฝ่าย top-down สมมติว่าพวกเขาสามารถระบุ requirement ทั้งหมดได้ตั้งแต่แรก—ซึ่งพวกเขาทำไม่ได้ ฝ่าย bottom-up สมมติว่าพวกเขาสามารถสร้างรายการ abstraction ที่จะพาพวกเขาไปสู่ solution ระดับบนสุดเพียงหนึ่งเดียวได้ในที่สุด แต่พวกเขาจะตัดสินใจเรื่อง functionality ของแต่ละ layer ได้ยังไง ในเมื่อยังไม่รู้เลยว่าตัวเองกำลังมุ่งหน้าไปทางไหน?
| Tip 68 | สร้างแบบ End-to-End ไม่ใช่ Top-Down หรือ Bottom-Up |
|---|
เราเชื่ออย่างยิ่งว่าวิธีเดียวที่จะสร้างซอฟต์แวร์ได้ คือการสร้างแบบค่อยเป็นค่อยไป (incrementally) สร้าง functionality แบบ end-to-end เป็นชิ้นเล็ก ๆ พร้อมกับเรียนรู้เกี่ยวกับปัญหาไปพร้อมกันระหว่างทาง นำสิ่งที่เรียนรู้มาปรับใช้ต่อไป ในขณะที่คุณค่อย ๆ เติม code ให้สมบูรณ์ขึ้น ให้ลูกค้ามีส่วนร่วมในทุกขั้นตอน และให้พวกเขาช่วยชี้นำกระบวนการนี้ด้วย
ทำ TDD ไปเลยตามสบาย แต่ถ้าคุณทำ อย่าลืมหยุดพักเป็นระยะ ๆ แล้วมองภาพใหญ่ดูบ้าง มันง่ายมากที่จะถูกล่อลวงด้วยข้อความสีเขียว "tests passed" จนเขียน code เยอะแยะ โดยที่มันไม่ได้พาคุณเข้าใกล้ solution ขึ้นมาเลยสักนิด
TDD: You Need to Know Where You're Going (คุณต้องรู้ว่ากำลังมุ่งหน้าไปทางไหน)
มุกตลกเก่า ๆ ถามว่า “คุณกินช้างยังไง?” คำตอบคือ “กินทีละคำ” และไอเดียนี้มักจะถูกยกมาเป็นข้อดีของ TDD เวลาที่คุณไม่สามารถทำความเข้าใจปัญหาทั้งหมดได้ ก็ให้ก้าวไปทีละก้าวเล็ก ๆ ทำ test ทีละอัน อย่างไรก็ตาม วิธีนี้ก็อาจจะพาคุณหลงทางได้เหมือนกัน มันชักจูงให้คุณจดจ่อและขัดเกลาปัญหาง่าย ๆ ไปเรื่อย ๆ โดยลืมเหตุผลจริง ๆ ที่คุณกำลังเขียน code อยู่ไป ตัวอย่างที่น่าสนใจของเรื่องนี้เกิดขึ้นในปี 2006 เมื่อ Ron Jeffries หนึ่งในผู้นำของขบวนการ agility ได้เริ่มเขียน blog post ชุดหนึ่งที่บันทึกการเขียน code แบบ test-driven ของเขาสำหรับตัวแก้ Sudoku [59] หลังจากโพสต์ไปห้าครั้ง เขาก็ปรับปรุงวิธีการแทนค่า (representation) ของกระดานที่อยู่เบื้องหลัง refactor มันไปหลายรอบ จนกระทั่งเขาพอใจกับ object model แล้ว แต่แล้วเขาก็ยกเลิกโปรเจกต์นั้นไปเฉย ๆ มันน่าสนใจที่จะอ่าน blog post เหล่านั้นเรียงตามลำดับ แล้วดูว่าคนที่ฉลาดคนหนึ่งสามารถหลงทางไปกับรายละเอียดปลีกย่อยได้ยังไง โดยมีแสงเรืองรองของ test ที่ผ่านคอยตอกย้ำอยู่ตลอด
ในทางตรงกันข้าม Peter Norvig อธิบายแนวทางอีกแบบหนึ่ง [60] ที่ให้ความรู้สึกแตกต่างออกไปมาก แทนที่จะถูกขับเคลื่อนด้วย test เขาเริ่มจากความเข้าใจพื้นฐานว่าปัญหาแบบนี้มักจะถูกแก้ยังไงตามธรรมเนียม (โดยใช้ constraint propagation) แล้วจากนั้นก็มุ่งเน้นไปที่การขัดเกลา algorithm ของเขา เขาจัดการกับการแทนค่ากระดานด้วย code แค่สิบกว่าบรรทัด ซึ่งไหลมาจากการอธิบายเรื่อง notation ของเขาโดยตรง
Test สามารถช่วยขับเคลื่อน development ได้จริง ๆ แต่เหมือนการขับรถทุกครั้ง ถ้าคุณไม่มีจุดหมายปลายทางอยู่ในใจ คุณก็อาจจะจบลงด้วยการวนอยู่ที่เดิมไปเรื่อย ๆ
Back to the Code (กลับมาที่ Code)
Component-based development เป็นเป้าหมายอันสูงส่งของ software development มาอย่างยาวนาน [61] ไอเดียคือ software component ทั่วไปควรจะหาได้และเอามาประกอบกันได้ง่ายพอ ๆ กับที่เราเอา integrated circuit (IC) ทั่วไปมาประกอบกัน แต่มันจะได้ผลก็ต่อเมื่อ component ที่คุณใช้นั้นเป็นที่รู้กันว่าเชื่อถือได้ และคุณมี voltage มาตรฐานเดียวกัน, มาตรฐานการเชื่อมต่อ, timing และอื่น ๆ ที่ตรงกัน
Chip ถูกออกแบบมาให้ test ได้—ไม่ใช่แค่ที่โรงงาน ไม่ใช่แค่ตอนติดตั้ง แต่รวมถึงตอนที่มันถูกใช้งานจริงในสนามด้วย chip และ system ที่ซับซ้อนกว่านั้นอาจจะมีฟีเจอร์ Built-In Self Test (BIST) แบบเต็มรูปแบบ ที่รัน diagnostics ระดับพื้นฐานอยู่ภายในตัวมันเอง หรือมี Test Access Mechanism (TAM) ที่เป็น test harness ให้สภาพแวดล้อมภายนอกส่ง stimuli เข้าไปและเก็บ response จาก chip ได้
เราก็ทำแบบเดียวกันนี้กับซอฟต์แวร์ได้ เหมือนเพื่อนร่วมงานฝั่ง hardware ของเรา เราต้องสร้างความสามารถในการ test เข้าไปในซอฟต์แวร์ตั้งแต่เริ่มต้น และ test แต่ละชิ้นอย่างละเอียดก่อนที่จะเอามันมาต่อเข้าด้วยกัน
Unit Testing
การ test ระดับ chip สำหรับ hardware ก็เทียบเคียงได้กับ unit testing ในซอฟต์แวร์—การ test ที่ทำกับแต่ละ module แบบแยกเดี่ยว เพื่อยืนยัน behavior ของมัน เราจะรู้สึกได้ชัดเจนขึ้นว่า module หนึ่งจะตอบสนองยังไงในโลกกว้าง เมื่อเราได้ test มันอย่างละเอียดภายใต้เงื่อนไขที่ควบคุมได้ (แม้ว่าจะเป็นเงื่อนไขที่สร้างขึ้นมาเองก็ตาม)
Software unit test คือ code ที่ใช้ทดสอบการทำงานของ module หนึ่ง ๆ โดยทั่วไปแล้ว unit test จะสร้างสภาพแวดล้อมจำลองขึ้นมาชนิดหนึ่ง แล้วเรียก routine ภายใน module ที่กำลังถูก test จากนั้นมันจะตรวจสอบผลลัพธ์ที่ได้กลับมา ไม่ว่าจะเทียบกับค่าที่รู้อยู่แล้ว หรือเทียบกับผลลัพธ์จากการรัน test เดียวกันในครั้งก่อน ๆ (regression testing)
ต่อมา เมื่อเราประกอบ “software IC” ของเราเข้าเป็น system ที่สมบูรณ์ เราจะมั่นใจได้ว่าแต่ละส่วนทำงานตามที่คาดไว้ และเราก็สามารถใช้ unit test facility ชุดเดียวกันนี้มา test ทั้ง system ในภาพรวมได้ด้วย เราพูดถึงการตรวจสอบขนาดใหญ่แบบนี้ของ system ไว้ใน Ruthless and Continuous Testing
แต่ก่อนที่เราจะไปถึงจุดนั้น เราต้องตัดสินใจก่อนว่าจะ test อะไรบ้างในระดับ unit ในอดีต programmer มักจะโยนข้อมูลสุ่ม ๆ สักไม่กี่ตัวเข้าไปใส่ code แล้วดูผลลัพธ์จาก print statement แล้วก็ถือว่า test แล้ว เราทำได้ดีกว่านั้นมาก
Testing Against Contract (Test เทียบกับ Contract)
เราชอบมองว่า unit testing คือการ test เทียบกับ contract (ดู Topic 23, Design by Contract ) เราอยากเขียน test case ที่ยืนยันได้ว่า unit หนึ่ง ๆ ทำตาม contract ของมันจริง ๆ สิ่งนี้จะบอกเราสองอย่าง คือ code ทำตาม contract หรือเปล่า และ contract นั้นมีความหมายตรงกับที่เราคิดไว้หรือเปล่า เราอยากจะ test ว่า module ส่งมอบ functionality ที่มันสัญญาไว้จริง ๆ ครอบคลุมทั้ง test case และ boundary condition ที่หลากหลาย
สิ่งนี้หมายความว่ายังไงในทางปฏิบัติ? มาเริ่มด้วยตัวอย่างเชิงตัวเลขง่าย ๆ กัน นั่นคือ routine หาค่ารากที่สอง contract ของมันที่ถูกบันทึกไว้นั้นเรียบง่ายมาก
| | pre-conditions: |
|---|---|
| | argument >= 0; |
| | |
| | post-conditions: |
| | ((result result) - argument).abs <= epsilonargument; |
นี่บอกเราว่าต้อง test อะไรบ้าง
-
ส่ง argument ที่เป็นค่าติดลบเข้าไป และตรวจสอบว่ามันถูกปฏิเสธ
-
ส่ง argument เป็นศูนย์เข้าไป เพื่อตรวจสอบว่ามันถูกยอมรับ (นี่คือ boundary value)
-
ส่งค่าระหว่างศูนย์กับ argument สูงสุดที่แสดงผลได้เข้าไป แล้วตรวจสอบว่าผลต่างระหว่างค่ากำลังสองของผลลัพธ์กับ argument เดิมนั้นน้อยกว่าเศษส่วนเล็ก ๆ ของ argument (epsilon)
เมื่อมี contract นี้ในมือแล้ว และสมมติว่า routine ของเราตรวจสอบ pre- และ postcondition ด้วยตัวมันเองอยู่แล้ว เราก็สามารถเขียน test script พื้นฐานเพื่อทดสอบ function หาค่ารากที่สองนี้ได้
จากนั้นเราก็เรียก routine นี้เพื่อ test function หาค่ารากที่สองของเรา
| | assertWithinEpsilon(my_sqrt(0), 0) |
|---|---|
| | assertWithinEpsilon(my_sqrt(2.0), 1.4142135624) |
| | assertWithinEpsilon(my_sqrt(64.0), 8.0) |
| | assertWithinEpsilon(my_sqrt(1.0e7), 3162.2776602) |
| | assertRaisesException fn => my_sqrt(-4.0) end |
นี่เป็น test ที่เรียบง่ายมาก ในโลกความเป็นจริง module ที่ไม่ธรรมดาส่วนใหญ่มักจะพึ่งพา module อื่น ๆ อีกหลายตัว แล้วเราจะ test การรวมกันของมันยังไง?
สมมติว่าเรามี module A ที่ใช้ DataFeed และ LinearRegression เราจะ test ตามลำดับดังนี้
- contract ของ DataFeed ทั้งหมด
- contract ของ LinearRegression ทั้งหมด
- contract ของ A ซึ่งพึ่งพา contract อื่น ๆ แต่ไม่ได้เปิดเผยมันออกมาโดยตรง
การ test แบบนี้กำหนดให้คุณต้อง test subcomponent ของ module ก่อนเสมอ เมื่อ subcomponent ถูกยืนยันแล้ว ตัว module เองก็ค่อย test ได้
ถ้า test ของ DataFeed และ LinearRegression ผ่าน แต่ test ของ A fail เราก็มั่นใจได้ค่อนข้างมากว่าปัญหาอยู่ที่ A หรืออยู่ที่วิธีที่ A ใช้ subcomponent ตัวใดตัวหนึ่งเหล่านั้น เทคนิคนี้เป็นวิธีที่ดีมากในการลดความพยายามในการ debug เราสามารถโฟกัสไปที่แหล่งที่น่าจะเป็นสาเหตุของปัญหาได้อย่างรวดเร็วภายใน module A โดยไม่ต้องเสียเวลาไปตรวจสอบ subcomponent ของมันซ้ำอีก
ทำไมเราต้องลำบากขนาดนี้? เหนือสิ่งอื่นใด เราอยากหลีกเลี่ยงการสร้าง “ระเบิดเวลา”—สิ่งที่แอบซ่อนอยู่โดยไม่มีใครสังเกตเห็น แล้วระเบิดขึ้นมาในจังหวะที่แย่ที่สุดในภายหลังของโปรเจกต์ ด้วยการเน้นการ test เทียบกับ contract เราสามารถพยายามหลีกเลี่ยงหายนะที่จะตามมาทีหลังแบบนี้ได้มากที่สุดเท่าที่จะทำได้
| Tip 69 | ออกแบบเพื่อให้ Test ได้ |
|---|
Ad Hoc Testing (Test แบบเฉพาะกิจ)
อย่าสับสนกับคำว่า “แฮกส่งเดช” ad-hoc testing คือตอนที่เราไปจิ้ม ๆ ตรวจสอบ code ของเราด้วยมือเอง มันอาจจะง่าย ๆ แค่ใส่ console.log() เข้าไป หรือพิมพ์ code เข้าไปแบบ interactive ใน debugger, IDE environment หรือ REPL
เมื่อจบ debugging session แล้ว คุณต้องทำให้ ad hoc test นี้เป็นทางการ ถ้า code เคยพังมาครั้งหนึ่ง มันก็มีแนวโน้มจะพังอีกครั้ง อย่าทิ้ง test ที่คุณสร้างขึ้นมาไปเฉย ๆ ให้เก็บมันเข้าคลัง unit test ที่มีอยู่แล้วไปเลย
Build a Test Window (สร้างหน้าต่างสำหรับ Test)
แม้แต่ชุด test ที่ดีที่สุดก็ไม่น่าจะหาบั๊กได้ทั้งหมด มีบางอย่างเกี่ยวกับสภาพชื้นและอบอุ่นของ production environment ที่ดูเหมือนจะดึงบั๊กพวกนั้นออกมาจากซอกหลืบเสมอ
นั่นหมายความว่าคุณมักจะต้อง test ซอฟต์แวร์หลังจากที่มันถูก deploy ไปแล้วบ่อย ๆ—โดยมีข้อมูลจากโลกจริงไหลผ่านเส้นเลือดของมัน ต่างจาก circuit board หรือ chip เราไม่มี test pin ในซอฟต์แวร์ แต่เรา สามารถ สร้างมุมมองต่าง ๆ เพื่อดู internal state ของ module ได้ โดยไม่ต้องใช้ debugger (ซึ่งอาจจะไม่สะดวกหรือเป็นไปไม่ได้เลยใน production application)
Log file ที่มี trace message เป็นกลไกแบบหนึ่งที่ใช้ทำแบบนี้ได้ ข้อความ log ควรจะอยู่ในรูปแบบที่สม่ำเสมอและคงเส้นคงวา คุณอาจจะอยากเขียนโปรแกรมมา parse มันอัตโนมัติ เพื่อหา processing time หรือ logic path ที่โปรแกรมเดินผ่าน diagnostic ที่ format ไม่ดีหรือไม่สม่ำเสมอก็แค่ “ขยะ”—มันอ่านยากและ parse แทบไม่ได้เลย
อีกกลไกหนึ่งสำหรับการมองเข้าไปใน code ที่กำลังรันอยู่คือลำดับปุ่มลัด (”hot-key”) หรือ magic URL เมื่อกดปุ่มชุดเฉพาะนี้ หรือเข้า URL นี้ หน้าต่าง diagnostic control ก็จะเด้งขึ้นมาพร้อม status message และอื่น ๆ นี่ไม่ใช่สิ่งที่คุณจะเปิดเผยให้ end user เห็นตามปกติ แต่มันสะดวกมากสำหรับทีม help desk
ในภาพที่กว้างขึ้น คุณสามารถใช้ feature switch เพื่อเปิด diagnostic เพิ่มเติมสำหรับ user คนใดคนหนึ่ง หรือ user กลุ่มใดกลุ่มหนึ่งโดยเฉพาะได้
A Confession (คำสารภาพ)
ผม (Dave) เป็นที่รู้กันว่าเคยบอกคนอื่นว่าผมไม่เขียน test แล้ว ส่วนหนึ่งผมทำแบบนั้นเพื่อสั่นคลอนความเชื่อของคนที่ทำให้ testing กลายเป็นศาสนาไปแล้ว และอีกส่วนหนึ่งผมพูดแบบนั้นเพราะมัน (ค่อนข้าง) เป็นเรื่องจริง
ผมเขียน code มา 45 ปีแล้ว และเขียน automated test มามากกว่า 30 ปีในจำนวนนั้น การคิดถึงเรื่อง testing ฝังอยู่ในวิธีที่ผมเข้าหา coding ไปแล้ว มันรู้สึกคุ้นเคยสบายตัว และนิสัยของผมก็ยืนกรานว่าเมื่อไหร่ที่อะไรเริ่มรู้สึกสบายตัวเกินไป ผมควรจะย้ายไปทำอย่างอื่นแทน
คราวนี้ผมตัดสินใจหยุดเขียน test ไปสักสองสามเดือน แล้วดูว่ามันส่งผลอะไรกับ code ของผมบ้าง ซึ่งน่าประหลาดใจที่คำตอบคือ “ไม่มากเท่าไหร่” ผมเลยใช้เวลาหาคำตอบว่าทำไม
ผม เชื่อ ว่าคำตอบคือ (สำหรับผมนะ) ประโยชน์ส่วนใหญ่ของการ testing มาจากการคิดถึง test และผลกระทบของมันต่อ code ต่างหาก และหลังจากทำแบบนี้มานานขนาดนี้ ผมก็คิดแบบนั้นได้โดยไม่ต้องเขียน test จริง ๆ ด้วยซ้ำ code ของผมยังคง test ได้อยู่ มันแค่ไม่ได้ถูก test เท่านั้นเอง
แต่นั่นมันมองข้ามข้อเท็จจริงที่ว่า test ก็เป็นวิธีหนึ่งในการสื่อสารกับ developer คนอื่น ๆ ด้วย ดังนั้นตอนนี้ผมก็ ยัง เขียน test สำหรับ code ที่แชร์กับคนอื่น หรือ code ที่พึ่งพาความแปลกประหลาดเฉพาะตัวของ external dependency อยู่
Andy บอกว่าผมไม่ควรใส่ sidebar นี้เข้าไป เขากังวลว่ามันจะล่อลวงให้ developer ที่ยังไม่มีประสบการณ์เลิกเขียน test นี่คือข้อตกลงประนีประนอมของผม
คุณควรเขียน test ไหม? ควรสิ แต่หลังจากที่คุณทำมันมา 30 ปีแล้ว ก็ลองทดลองดูบ้างว่าประโยชน์ของมันอยู่ตรงไหนสำหรับตัวคุณเอง
A Culture of Testing (วัฒนธรรมแห่งการ Testing)
ซอฟต์แวร์ทุกชิ้นที่คุณเขียน จะ ถูก test เสมอ—ถ้าไม่ใช่โดยคุณและทีมของคุณ ก็จะเป็นโดย user ที่มาใช้งานจริงในที่สุด—ดังนั้นคุณก็ควรจะวางแผน test มันอย่างละเอียดตั้งแต่แรกเลยดีกว่า การคิดล่วงหน้าเพียงเล็กน้อยสามารถช่วยลดต้นทุนการดูแลรักษาและสายโทรเข้า help desk ได้อย่างมาก
คุณมีตัวเลือกอยู่ไม่กี่แบบเท่านั้นจริง ๆ
- Test First (Test ก่อน)
- Test During (Test ระหว่างทาง)
- Test Never (ไม่ Test เลย)
Test First รวมถึง Test-Driven Design น่าจะเป็นตัวเลือกที่ดีที่สุดของคุณในสถานการณ์ส่วนใหญ่ เพราะมันการันตีว่าการ testing จะเกิดขึ้นจริง แต่บางครั้งมันก็ไม่ได้สะดวกหรือใช้ประโยชน์ได้เต็มที่นัก ดังนั้น Test During ระหว่างเขียน code ก็เป็นทางเลือกสำรองที่ดีได้เช่นกัน คุณเขียน code บางส่วน ลองปรับแต่งมันดู เขียน test ให้มัน แล้วค่อยไปต่อส่วนถัดไป ตัวเลือกที่แย่ที่สุดมักถูกเรียกว่า “Test Later” แต่คุณจะหลอกใครกัน? “Test Later” จริง ๆ แล้วก็คือ “Test Never” นั่นเอง
วัฒนธรรมแห่งการ testing หมายความว่า test ทั้งหมดผ่านตลอดเวลา การปล่อยให้มี test ที่ “fail อยู่เป็นประจำ” ยิ่งทำให้คนมองข้าม test ทั้งหมด ได้ง่ายขึ้น แล้ววงจรอุบาทว์ก็เริ่มต้นขึ้น (ดู Topic 3, Software Entropy )
ดูแล test code ด้วยความใส่ใจเดียวกันกับ production code เก็บให้มัน decoupled สะอาด และแข็งแรง อย่าพึ่งพาสิ่งที่ไม่น่าเชื่อถือ (ดู Topic 38, Programming by Coincidence ) อย่างเช่นตำแหน่งที่แน่นอนของ widget ใน GUI system หรือ timestamp ที่เป๊ะเป๊ะใน server log หรือถ้อยคำที่แน่นอนของ error message การ test สิ่งเหล่านี้จะส่งผลให้ได้ test ที่เปราะบาง
| Tip 70 | Test ซอฟต์แวร์ของคุณเอง ไม่งั้น User ของคุณจะเป็นคน Test แทน |
|---|
อย่าเข้าใจผิด testing คือส่วนหนึ่งของ programming มันไม่ใช่สิ่งที่ปล่อยให้เป็นหน้าที่ของแผนกอื่นหรือ staff คนอื่น
Testing, design, coding—ทั้งหมดนี้คือ programming
Related Sections Include (Section ที่เกี่ยวข้อง)
- Topic 27, Don't Outrun Your Headlights
- Topic 51, Pragmatic Starter Kit