การเปลี่ยนแปลงในระบบสามารถทำได้สองวิธีหลัก ผมชอบเรียกมันว่า Edit and Pray และ Cover and Modify น่าเสียดายที่ Edit and Pray เป็นมาตรฐานในวงการซอฟต์แวร์โดยทั่วไป เมื่อคุณใช้ Edit and Pray คุณจะวางแผนการเปลี่ยนแปลงที่ต้องทำอย่างรอบคอบ คุณแน่ใจว่าคุณเข้าใจโค้ดที่กำลังจะแก้ไข จากนั้นก็เริ่มลงมือเปลี่ยนแปลง เมื่อทำเสร็จ คุณก็รันระบบเพื่อดูว่าการเปลี่ยนแปลงทำงานได้หรือไม่ แล้วก็ลองตรวจสอบเพิ่มเติมเพื่อให้แน่ใจว่าคุณไม่ได้ทำอะไรพัง การตรวจสอบเพิ่มเติมนี่เป็นสิ่งสำคัญ ในขณะที่คุณกำลังเปลี่ยนแปลงโค้ด คุณหวังและภาวนาว่าจะทำมันถูกต้อง และคุณใช้เวลาเพิ่มเติมหลังจากทำเสร็จเพื่อให้แน่ใจว่าคุณทำถูกต้องแล้ว
เมื่อมองเผินๆ Edit and Pray ดูเหมือน "การทำงานอย่างระมัดระวัง" ซึ่งเป็นสิ่งที่มืออาชีพควรทำ "ความระมัดระวัง" ที่คุณใส่ใจนั้นปรากฏให้เห็นเด่นชัด และคุณจะใช้ความระมัดระวังเป็นพิเศษเมื่อการเปลี่ยนแปลงนั้นกระทบวงกว้าง เพราะมีสิ่งผิดพลาดเกิดขึ้นได้มากกว่า แต่ความปลอดภัยไม่ได้ขึ้นอยู่กับความระมัดระวังเพียงอย่างเดียว ผมไม่คิดว่าพวกเราคนไหนจะเลือกศัลยแพทย์ที่ใช้มีดเนยในการผ่าตัดเพียงเพราะเขาทำงานอย่างระมัดระวัง การเปลี่ยนแปลงซอฟต์แวร์ที่มีประสิทธิภาพก็เหมือนการผ่าตัดที่มีประสิทธิภาพ มันต้องอาศัยทักษะที่ลึกซึ้งยิ่งขึ้น การทำงานอย่างระมัดระวังไม่ได้ช่วยอะไรมากถ้าคุณไม่มีเครื่องมือและเทคนิคที่เหมาะสม
Cover and Modify เป็นวิธีที่แตกต่างในการเปลี่ยนแปลง แนวคิดเบื้องหลังคือเราสามารถทำงานร่วมกับ safety net (ตาข่ายความปลอดภัย) ได้เมื่อเราเปลี่ยนแปลงซอฟต์แวร์ Safety net ที่เราใช้ไม่ใช่สิ่งที่เราเอาไว้ใต้โต๊ะเพื่อคอยรับถ้าเราตกจากเก้าอี้ แต่มันเป็นเหมือนเสื้อคลุมที่เราครอบคลุมโค้ดที่กำลังทำงานเพื่อให้แน่ใจว่าการเปลี่ยนแปลงที่ไม่ดีจะไม่รั่วไหลไปติดเชื้อในซอฟต์แวร์ส่วนอื่น การครอบคลุมซอฟต์แวร์หมายถึงการครอบคลุมมันด้วย Tests เมื่อเรามีชุด Test ที่ดีอยู่รอบๆ โค้ดชิ้นหนึ่ง เราสามารถเปลี่ยนแปลงและค้นพบได้อย่างรวดเร็วว่าผลลัพธ์นั้นดีหรือไม่ดี เรายังคงใช้ความระมัดระวังแบบเดิม แต่ด้วย Feedback ที่เราได้รับ เราจึงสามารถเปลี่ยนแปลงได้อย่างระมัดระวังมากขึ้น
ถ้าคุณไม่คุ้นเคยกับการใช้ Tests ในลักษณะนี้ ทั้งหมดนี้คงฟังดูแปลกๆ ไปบ้าง ตามธรรมเนียมแล้ว Tests จะถูกเขียนและรันหลังจากที่พัฒนาเสร็จแล้ว กลุ่มโปรแกรมเมอร์เขียนโค้ด และทีม Testers ก็รัน Tests กับโค้ดที่เขียนเสร็จแล้วเพื่อตรวจสอบว่ามันตรงตาม Specification หรือไม่ ในร้านพัฒนาซอฟต์แวร์แบบดั้งเดิมบางแห่ง นี่คือวิธีการพัฒนาซอฟต์แวร์ ทีมสามารถรับ Feedback ได้ แต่ Feedback loop นั้นใหญ่มาก ทำงานกันเป็นสัปดาห์หรือเป็นเดือน แล้วให้คนในอีกกลุ่มหนึ่งมาบอกคุณว่าคุณทำถูกต้องหรือยัง
การ Testing ที่ทำแบบนี้จริงๆ แล้วคือ "การทดสอบเพื่อพยายามแสดงความถูกต้อง (testing to attempt to show correctness)" แม้ว่านั่นจะเป็นเป้าหมายที่ดี แต่ Tests ก็สามารถใช้ในวิธีที่แตกต่างออกไปได้ เราสามารถทำ "การทดสอบเพื่อตรวจจับการเปลี่ยนแปลง (testing to detect change)"
ในศัพท์เทคนิคแบบดั้งเดิม สิ่งนี้เรียกว่า Regression Testing เราจะรัน Tests เป็นระยะเพื่อตรวจสอบพฤติกรรมที่ดีที่เรารู้จัก เพื่อดูว่าซอฟต์แวร์ของเรายังทำงานเหมือนที่เคยทำในอดีตหรือไม่
เมื่อคุณมี Tests ครอบคลุมพื้นที่ที่คุณกำลังจะเปลี่ยนแปลง พวกมันจะทำหน้าที่เป็น Software Vise (ปากกาจับชิ้นงานซอฟต์แวร์) คุณสามารถรักษาพฤติกรรมส่วนใหญ่ให้คงที่ และมั่นใจได้ว่าคุณกำลังเปลี่ยนแปลงเฉพาะสิ่งที่คุณตั้งใจจะเปลี่ยนเท่านั้น
Software Vise (ปากกาจับชิ้นงานซอฟต์แวร์)
vise (n.). อุปกรณ์จับยึด ประกอบด้วยขากรรไกรสองข้างที่เปิดหรือปิดด้วยสกรูหรือคันโยก ใช้ในงานช่างไม้หรืองานโลหะเพื่อยึดชิ้นงานให้อยู่กับที่ The American Heritage Dictionary of the English Language, Fourth Edition
เมื่อเรามี Tests ที่ตรวจจับการเปลี่ยนแปลง มันก็เหมือนกับการมีปากกาจับรอบๆ โค้ดของเรา พฤติกรรมของโค้ดถูกยึดไว้กับที่ เมื่อเราเปลี่ยนแปลง เราสามารถรู้ได้ว่าเรากำลังเปลี่ยนแปลงพฤติกรรมทีละอย่างเท่านั้น พูดง่ายๆ คือเราควบคุมงานของเราได้
Regression Testing เป็นแนวคิดที่ยอดเยี่ยม ทำไมคนเราถึงไม่ทำมันบ่อยกว่านี้? มันมีปัญหาเล็กน้อยกับ Regression Testing คือบ่อยครั้งที่คนเราทำมันในระดับ Application Interface ไม่ว่าจะเป็น Web Application, Command-line Application, หรือ GUI-based Application Regression Testing ถูกมองว่าเป็นการทดสอบในระดับ Application มาตั้งแต่เดิม แต่นั่นเป็นเรื่องน่าเสียดาย เพราะ Feedback ที่เราได้จากมันมีประโยชน์มาก การทำในระดับที่ละเอียดกว่าจึงคุ้มค่า
ลองทำการทดลองทางความคิดกันสักหน่อย เรากำลังจะเข้าไปใน Function ขนาดใหญ่ที่มี Logic ที่ซับซ้อนมากมาย เราวิเคราะห์ เราคิด เราคุยกับคนที่รู้จักโค้ดชิ้นนั้นมากกว่าเรา จากนั้นก็ทำการเปลี่ยนแปลง เราต้องการให้แน่ใจว่าการเปลี่ยนแปลงไม่ได้ทำให้อะไรพัง แต่เราจะทำยังไง? โชคดีที่เรามีทีม Quality Assurance ที่มีชุด Regression Tests ที่สามารถรันข้ามคืนได้ เราโทรไปขอให้พวกเขาจัดตารางรัน และพวกเขาก็บอกว่าใช่ สามารถรัน Tests ข้ามคืนได้ แต่โชคดีที่เราโทรไปเร็ว เพราะทีมอื่นๆ มักจะพยายามจองคิว Regression Runs ในช่วงกลางสัปดาห์ และถ้าเรารอนานกว่านี้ อาจจะไม่มีช่วงเวลาและเครื่องให้เราใช้ เราถอนหายใจด้วยความโล่งอกแล้วกลับไปทำงาน เรายังมีการเปลี่ยนแปลงอีกประมาณห้าอย่างที่ต้องทำเหมือนกับอันที่แล้ว ทั้งหมดอยู่ในพื้นที่ที่ซับซ้อนพอๆ กัน และเราไม่ได้ทำงานคนเดียว เรารู้ว่ามีคนอื่นอีกหลายคนกำลังทำการเปลี่ยนแปลงเช่นกัน
เช้าวันรุ่งขึ้น เราก็ได้รับโทรศัพท์ Daiva จากทีม Testing บอกเราว่า Tests AE1021 และ AE1029 ล้มเหลวข้ามคืน เธอไม่แน่ใจว่าเป็นเพราะการเปลี่ยนแปลงของเราหรือเปล่า แต่เธอโทรหาเราเพราะเธอรู้ว่าเราจะจัดการให้ เราจะ Debug และดูว่าความล้มเหลวนั้นเกิดจากการเปลี่ยนแปลงของเราหรือของคนอื่น
ฟังดูเหมือนจริงไหม? น่าเสียดายที่มันเป็นเรื่องจริงมาก
มาดูอีกสถานการณ์หนึ่งกัน
เราจำเป็นต้องเปลี่ยนแปลง Function ที่ค่อนข้างยาวและซับซ้อน โชคดีที่เราพบชุด Unit Tests ที่มีอยู่แล้วสำหรับมัน คนสุดท้ายที่แตะโค้ดนี้เขียน Unit Tests ประมาณ 20 ตัวที่ทดสอบมันอย่างละเอียด เรารันพวกมันและพบว่าผ่านทั้งหมด จากนั้นเราก็ดู Tests เพื่อทำความเข้าใจพฤติกรรมที่แท้จริงของโค้ด
เราเตรียมที่จะทำการเปลี่ยนแปลง แต่เราก็รู้ว่ามันค่อนข้างยากที่จะหาวิธีเปลี่ยน โค้ดไม่ชัดเจน และเราอยากทำความเข้าใจมันให้ดีขึ้นก่อนที่จะเปลี่ยนแปลง Tests อาจไม่ครอบคลุมทุกอย่าง เราจึงอยากทำให้โค้ดชัดเจนมากๆ เพื่อเพิ่มความมั่นใจในการเปลี่ยนแปลง นอกจากนั้น เราไม่อยากให้ตัวเองหรือคนอื่นต้องมาทำงานแบบเดียวกับที่เรากำลังทำเพื่อพยายามทำความเข้าใจมัน เสียเวลาเปล่าๆ!
เราเริ่ม Refactor โค้ดนิดหน่อย เราแยก Methods ออกมาบ้างและย้าย Logic เงื่อนไขบางส่วน หลังจากทุกการเปลี่ยนแปลงเล็กๆ ที่เราทำ เราก็รันชุด Unit Tests เล็กๆ นั้น พวกมันผ่านเกือบทุกครั้งที่เรารัน ไม่กี่นาทีที่แล้ว เราทำพลาดและกลับ Logic ในเงื่อนไข แต่ Test ตัวหนึ่งล้มเหลว และเราก็แก้ไขได้ภายในประมาณหนึ่งนาที เมื่อ Refactor เสร็จ โค้ดก็ชัดเจนขึ้นมาก เราเปลี่ยนแปลงสิ่งที่ตั้งใจจะเปลี่ยน และมั่นใจว่ามันถูกต้อง เราเพิ่ม Tests บางตัวเพื่อตรวจสอบพฤติกรรมใหม่ โปรแกรมเมอร์คนต่อไปที่ทำงานกับโค้ดชิ้นนี้จะมีเวลาที่ง่ายขึ้น และจะมี Tests ที่ครอบคลุมฟังก์ชันการทำงานของมัน
คุณอยากได้ Feedback ในหนึ่งนาทีหรือข้ามคืน? สถานการณ์ไหนมีประสิทธิภาพมากกว่ากัน?
Unit Testing เป็นหนึ่งในองค์ประกอบที่สำคัญที่สุดในงาน Legacy Code System-level Regression Tests นั้นยอดเยี่ยม แต่ Tests ขนาดเล็กที่เฉพาะเจาะจงนั้นมีค่าอย่างยิ่ง พวกมันสามารถให้ Feedback แก่คุณขณะที่คุณพัฒนา และช่วยให้คุณ Refactor ได้อย่างปลอดภัยมากขึ้น
What Is Unit Testing? (Unit Testing คืออะไร)
คำว่า unit test มีประวัติศาสตร์อันยาวนานในวงการพัฒนาซอฟต์แวร์ สิ่งที่เหมือนกันในแนวคิดส่วนใหญ่ของ Unit Tests คือแนวคิดที่ว่าพวกมันคือ Tests ที่แยกทดสอบ Component แต่ละตัวของซอฟต์แวร์อย่างอิสระ Component คืออะไร? คำจำกัดความแตกต่างกันไป แต่ใน Unit Testing เรามักจะสนใจหน่วยพฤติกรรมที่เล็กที่สุดของระบบ ในโค้ดแบบ Procedural หน่วยเหล่านั้นมักจะเป็น Functions ในโค้ดแบบ Object-oriented หน่วยเหล่านั้นคือ Classes
Test Harnesses (เครื่องมือทดสอบ)
ในหนังสือเล่มนี้ ผมใช้คำว่า test harness เป็นคำทั่วไปสำหรับโค้ดทดสอบที่เราเขียนเพื่อทดสอบซอฟต์แวร์บางส่วนและโค้ดที่จำเป็นในการรันมัน เราสามารถใช้ Test Harnesses หลายประเภทเพื่อทำงานกับโค้ดของเรา ใน Chapter 5 , Tools , ผมพูดถึง xUnit Testing Framework และ FIT Framework ทั้งสองอย่างสามารถใช้ทำการทดสอบที่ผมบรรยายในหนังสือเล่มนี้ได้
เราเคยทดสอบแค่ Function เดียวหรือ Class เดียวได้ไหม? ในระบบ Procedural มันมักจะยากที่จะทดสอบ Functions แบบแยกเดี่ยว Functions ระดับบนเรียก Functions อื่น ซึ่งก็เรียก Functions อื่นต่อไปเรื่อยๆ ลงไปจนถึงระดับ Machine ในระบบ Object-oriented มันค่อนข้างง่ายกว่าที่จะทดสอบ Classes แบบแยกเดี่ยว แต่ความจริงก็คือ Classes โดยทั่วไปไม่ได้อยู่โดดเดี่ยว ลองคิดถึง Classes ทั้งหมดที่คุณเคยเขียนที่ไม่ใช้ Classes อื่น มันค่อนข้างหายากใช่ไหม? โดยปกติแล้วพวกมันเป็น Data Classes เล็กๆ หรือ Data Structure Classes เช่น Stack และ Queue (และแม้แต่พวกนี้ก็อาจใช้ Classes อื่น)
การทดสอบแบบแยกเดี่ยวเป็นส่วนสำคัญของคำจำกัดความของ Unit Test แต่มันสำคัญเพราะอะไร? ท้ายที่สุดแล้ว ข้อผิดพลาดมากมายอาจเกิดขึ้นเมื่อชิ้นส่วนซอฟต์แวร์ถูกรวมเข้าด้วยกัน การทดสอบขนาดใหญ่ที่ครอบคลุมพื้นที่ฟังก์ชันการทำงานกว้างๆ ของโค้ดไม่ควรสำคัญมากกว่าหรือ? ก็มันสำคัญนะ ผมไม่ปฏิเสธ แต่มันมีปัญหาบางอย่างกับการทดสอบขนาดใหญ่:
• Error Localization —เมื่อ Tests อยู่ห่างจากสิ่งที่พวกมันทดสอบมากขึ้น มันก็ยากขึ้นที่จะระบุว่าความล้มเหลวของ Test หมายถึงอะไร บ่อยครั้งที่ต้องใช้ความพยายามอย่างมากในการระบุต้นตอของความล้มเหลว คุณต้องดู Test Inputs ดูความล้มเหลว และตัดสินว่าความล้มเหลวนั้นเกิดขึ้นที่จุดไหนระหว่างทางจาก Inputs ไปยัง Outputs ใช่ เราก็ต้องทำแบบนั้นกับ Unit Tests เช่นกัน แต่งานนั้นมักจะเล็กน้อย
• Execution Time —Tests ที่ใหญ่กว่ามักใช้เวลานานกว่าในการทำงาน ซึ่งมักจะทำให้การรัน Tests ค่อนข้างน่าหงุดหงิด Tests ที่ใช้เวลานานเกินไปในการรันสุดท้ายแล้วก็จะไม่ได้ถูกรัน
• Coverage —เป็นการยากที่จะเห็นความเชื่อมโยงระหว่างโค้ดชิ้นหนึ่งกับค่าที่ทดสอบมัน โดยปกติเราสามารถหาได้ว่าโค้ดชิ้นหนึ่งถูก Test ครอบคลุมหรือไม่โดยใช้ Coverage Tools แต่เมื่อเราเพิ่มโค้ดใหม่ เราอาจต้องทำงานมากมายเพื่อสร้าง High-level Tests ที่ครอบคลุมโค้ดใหม่นั้น
หนึ่งในสิ่งที่ frustrating ที่สุดเกี่ยวกับ Tests ที่ใหญ่กว่าคือเราสามารถระบุตำแหน่งข้อผิดพลาดได้ถ้าเรารัน Tests บ่อยขึ้น แต่มันยากมากที่จะทำให้สำเร็จ ถ้าเรารัน Tests แล้วผ่าน จากนั้นเราเปลี่ยนแปลงเล็กน้อยแล้วมันล้มเหลว เราจะรู้ทันทีว่าปัญหาถูกกระตุ้นที่ไหน มันเป็นสิ่งที่เราทำในการเปลี่ยนแปลงเล็กๆ ล่าสุดนั้น เราสามารถย้อนกลับการเปลี่ยนแปลงและลองใหม่ แต่ถ้า Tests ของเราใหญ่ เวลาในการทำงานอาจยาวเกินไป แนวโน้มของเราคือหลีกเลี่ยงการรัน Tests บ่อยพอที่จะระบุตำแหน่งข้อผิดพลาดได้จริงๆ
Unit Tests เติมเต็มช่องว่างที่ Tests ขนาดใหญ่ทำไม่ได้ เราสามารถทดสอบชิ้นส่วนโค้ดอย่างอิสระ เราสามารถจัดกลุ่ม Tests เพื่อให้รันบางตัวภายใต้เงื่อนไขหนึ่งและบางตัวภายใต้เงื่อนไขอื่น ด้วยพวกมันเราสามารถระบุตำแหน่งข้อผิดพลาดได้อย่างรวดเร็ว ถ้าเราคิดว่ามีข้อผิดพลาดในโค้ดชิ้นใดชิ้นหนึ่งและเราสามารถใช้มันใน Test Harness ได้ เราก็สามารถเขียน Test ได้อย่างรวดเร็วเพื่อดูว่าข้อผิดพลาดนั้นอยู่ตรงนั้นจริงหรือไม่
นี่คือคุณสมบัติของ Unit Tests ที่ดี:
1. มันต้องทำงานเร็ว
2. มันช่วยให้เราระบุตำแหน่งปัญหาได้
ในวงการซอฟต์แวร์ คนมักจะถกเถียงกันว่า Tests บางอย่างเป็น Unit Tests หรือไม่ Test จริงๆ เป็น Unit Test หรือถ้ามันใช้ Production Class อื่น? ผมกลับไปที่คุณสมบัติสองอย่าง: Test ทำงานเร็วหรือไม่? มันช่วยให้เราระบุตำแหน่งข้อผิดพลาดได้เร็วหรือไม่? โดยธรรมชาติแล้ว มันมีระดับความแตกต่างกันไป Tests บางตัวก็ใหญ่กว่า และใช้หลาย Classes ร่วมกัน อันที่จริง พวกมันอาจดูเหมือน Integration Tests เล็กๆ เมื่อทดสอบทีละตัว พวกมันอาจดูเหมือนทำงานเร็ว แต่จะเกิดอะไรขึ้นเมื่อคุณรันพวกมันทั้งหมดพร้อมกัน? เมื่อคุณมี Test ที่ทดสอบ Class หนึ่งพร้อมกับ Collaborators หลายตัวของมัน มันก็มีแนวโน้มที่จะใหญ่ขึ้น ถ้าคุณไม่ได้สละเวลาเพื่อทำให้ Class สามารถสร้าง Instance แยกต่างหากใน Test Harness ได้ มันจะง่ายแค่ไหนเมื่อคุณเพิ่มโค้ดมากขึ้น? มันไม่เคยง่ายขึ้นเลย คนมักจะผัดวันประกันพรุ่ง เมื่อเวลาผ่านไป Test อาจใช้เวลานานถึง 1/10 วินาทีในการทำงาน
Unit Test ที่ใช้เวลา 1/10 วินาทีในการทำงานคือ Unit Test ที่ช้า
ใช่ ผมไม่ได้พูดเล่น ในตอนที่ผมเขียนนี้ 1/10 วินาทีเป็นเวลาที่ยาวนานมากสำหรับ Unit Test ลองคำนวณดู ถ้าคุณมีโปรเจกต์ที่มี 3,000 Classes และมีประมาณ 10 Tests ต่อ Class นั่นคือ 30,000 Tests จะใช้เวลานานแค่ไหนในการรัน Tests ทั้งหมดถ้าแต่ละตัวใช้เวลา 1/10 วินาที? เกือบหนึ่งชั่วโมง นั่นเป็นเวลาที่ยาวนานในการรอ Feedback คุณไม่มี 3,000 Classes? ลดลงครึ่งหนึ่ง ก็ยังเป็นครึ่งชั่วโมงอยู่ดี ในทางกลับกัน ถ้า Tests ใช้เวลา 1/100 วินาทีต่อตัวล่ะ? ตอนนี้เรากำลังพูดถึง 5 ถึง 10 นาที เมื่อมันใช้เวลานานขนาดนั้น ผมจะแน่ใจว่าผมใช้ชุดย่อยในการทำงาน แต่ผมก็ไม่รังเกียจที่จะรันทั้งหมดทุกสองสามชั่วโมง
ด้วยความช่วยเหลือของ Moore's Law ผมหวังว่าจะได้เห็น Test Feedback ที่เกือบจะทันทีแม้แต่ในระบบที่ใหญ่ที่สุดในช่วงชีวิตของผม ผมสงสัยว่าการทำงานในระบบเหล่านั้นจะเหมือนกับการทำงานกับโค้ดที่กัดกลับได้ มันจะสามารถบอกเราได้เมื่อมันกำลังถูกเปลี่ยนแปลงในทางที่ไม่ดี
Unit Tests ทำงานเร็ว ถ้าพวกมันไม่ทำงานเร็ว มันก็ไม่ใช่ Unit Tests
Tests ประเภทอื่นๆ มักจะแอบอ้างเป็น Unit Tests Test ไม่ใช่ Unit Test ถ้า:
1. มันคุยกับ Database
2. มันสื่อสารผ่าน Network
3. มันแตะ File System
4. คุณต้องทำสิ่งพิเศษกับ Environment ของคุณ (เช่น แก้ไข Configuration Files) เพื่อรันมัน
Tests ที่ทำสิ่งเหล่านี้ไม่ใช่สิ่งที่ไม่ดี บ่อยครั้งที่มันคุ้มค่าที่จะเขียน และโดยทั่วไปคุณจะเขียนมันใน Unit Test Harnesses อย่างไรก็ตาม สิ่งสำคัญคือการแยกพวกมันออกจาก Unit Tests ที่แท้จริง เพื่อให้คุณสามารถรักษาชุด Tests ที่คุณสามารถรันได้ อย่างรวดเร็ว เมื่อใดก็ตามที่คุณทำการเปลี่ยนแปลง
Higher-Level Testing (การทดสอบระดับสูง)
Unit Tests นั้นยอดเยี่ยม แต่ก็มีที่สำหรับ Higher-level Tests ซึ่งเป็น Tests ที่ครอบคลุมสถานการณ์และปฏิสัมพันธ์ในแอปพลิเคชัน Higher-level Tests สามารถใช้เพื่อยึดพฤติกรรมของชุด Classes ในครั้งเดียว เมื่อคุณสามารถทำแบบนั้นได้ บ่อยครั้งที่คุณสามารถเขียน Tests สำหรับแต่ละ Class ได้ง่ายขึ้น
Test Coverings (การครอบคลุมด้วย Test)
แล้วเราจะเริ่มทำการเปลี่ยนแปลงในโปรเจกต์ Legacy ได้อย่างไร? สิ่งแรกที่ควรสังเกตคือ ถ้ามีทางเลือก การมี Tests ครอบคลุมการเปลี่ยนแปลงที่เราทำนั้นปลอดภัยกว่าเสมอ เมื่อเราเปลี่ยนโค้ด เราสามารถทำให้เกิดข้อผิดพลาดได้ เพราะท้ายที่สุดเราก็เป็นมนุษย์ แต่เมื่อเราครอบคลุมโค้ดของเราด้วย Tests ก่อนที่จะเปลี่ยนแปลง เรามีแนวโน้มที่จะจับข้อผิดพลาดที่เราทำได้มากขึ้น
Figure 2.1 แสดงชุด Classes เล็กๆ ให้เราเห็น เราต้องการเปลี่ยนแปลง getResponseText Method ของ InvoiceUpdateResponder และ getValue Method ของ Invoice Methods เหล่านั้นคือ Change Points ของเรา เราสามารถครอบคลุมพวกมันด้วยการเขียน Tests สำหรับ Classes ที่พวกมันอยู่
Figure 2.1 Invoice update classes. (Classes การอัปเดต Invoice)
ในการเขียนและรัน Tests เราต้องสามารถสร้าง Instances ของ InvoiceUpdateResponder และ Invoice ใน Testing Harness ได้ เราทำได้ไหม? ก็ดูเหมือนว่าการสร้าง Invoice น่าจะง่ายพอ เพราะมันมี Constructor ที่ไม่รับ Arguments ใดๆ InvoiceUpdateResponder อาจจะยุ่งยากหน่อย เพราะมันรับ DBConnection ซึ่งเป็นการเชื่อมต่อจริงกับฐานข้อมูลจริง เราจะจัดการกับมันใน Test อย่างไร? เราต้องตั้งค่า Database พร้อมข้อมูลสำหรับ Tests ของเราหรือ? นั่นเป็นงานที่เยอะมาก การ Testing ผ่าน Database จะช้าหรือเปล่า? จริงๆ แล้วตอนนี้เราไม่ได้สนใจ Database เป็นพิเศษหรอก เราแค่อยากครอบคลุมการเปลี่ยนแปลงของเราใน InvoiceUpdateResponder และ Invoice นอกจากนี้เรายังมีปัญหาที่ใหญ่กว่า Constructor ของ InvoiceUpdateResponder ต้องการ InvoiceUpdateServlet เป็น Argument การสร้าง Servlet ตัวหนึ่งจะง่ายแค่ไหน? เราสามารถเปลี่ยนโค้ดเพื่อให้มันไม่ต้องรับ Servlet นั้นอีกต่อไป ถ้า InvoiceUpdateResponder ต้องการแค่ข้อมูลเล็กน้อยจาก InvoiceUpdateServlet เราก็สามารถส่งข้อมูลนั้นแทนการส่ง Servlet ทั้งตัวเข้าไปได้ แต่เราไม่ควรมี Tests เพื่อให้แน่ใจว่าเราเปลี่ยนแปลงได้ถูกต้องหรือ?
ปัญหาทั้งหมดนี้คือ Dependency Problems เมื่อ Classes พึ่งพาสิ่งที่ยากต่อการใช้ใน Test โดยตรง พวกมันก็จะยากต่อการแก้ไขและยากต่อการทำงานด้วย
Dependency เป็นหนึ่งในปัญหาที่สำคัญที่สุดในการพัฒนาซอฟต์แวร์ งาน Legacy Code ส่วนใหญ่เกี่ยวข้องกับการทำลาย Dependencies เพื่อให้การเปลี่ยนแปลงทำได้ง่ายขึ้น
แล้วเราจะทำยังไง? เราจะมี Tests โดยไม่เปลี่ยนโค้ดได้อย่างไร? ความจริงที่น่าเศร้าคือ ในหลายกรณี มันไม่ค่อยปฏิบัติได้จริงเท่าไหร่ ในบางกรณี มันอาจเป็นไปไม่ได้เลย ในตัวอย่างที่เราเพิ่งเห็น เราสามารถพยายามหลีกเลี่ยงปัญหา DBConnection โดยใช้ Database จริง แต่ปัญหา Servlet ล่ะ? เราต้องสร้าง Servlet เต็มรูปแบบและส่งมันไปยัง Constructor ของ InvoiceUpdateResponder หรือ? เราสามารถทำให้มันอยู่ในสถานะที่ถูกต้องได้ไหม? มันอาจเป็นไปได้ แล้วถ้าเราทำงานใน GUI Desktop Application ล่ะ? เราอาจไม่มี Programmatic Interface เลย Logic อาจถูกผูกติดอยู่กับ GUI Classes โดยตรง แล้วเราจะทำยังไง?
The Legacy Code Dilemma (ภาวะกลืนไม่เข้าคายไม่ออกของ Legacy Code)
เมื่อเราเปลี่ยนแปลงโค้ด เราควรมี Tests ไว้ก่อน แต่เพื่อให้มี Tests ได้ เรามักจะต้องเปลี่ยนแปลงโค้ด
ในตัวอย่าง Invoice เราสามารถลองทดสอบในระดับที่สูงขึ้น ถ้ามันยากที่จะเขียน Tests โดยไม่เปลี่ยน Class ใด Class หนึ่ง บางครั้งการทดสอบ Class ที่ใช้มันก็ง่ายกว่า อย่างไรก็ตาม เรามักจะต้องทำลาย Dependencies ระหว่าง Classes ที่ไหนสักแห่ง ในกรณีนี้ เราสามารถทำลาย Dependency ที่มีต่อ InvoiceUpdateServlet โดยการส่งสิ่งเดียวที่ InvoiceUpdateResponder ต้องการจริงๆ มันต้องการชุดรวมของ Invoice IDs ที่ InvoiceUpdateServlet เก็บไว้ นอกจากนี้เรายังสามารถทำลาย Dependency ที่ InvoiceUpdateResponder มีต่อ DBConnection โดยการ Introduce Interface ( IDBConnection ) และเปลี่ยน InvoiceUpdateResponder ให้ใช้ Interface แทน Figure 2.2 แสดงสถานะของ Classes เหล่านี้หลังการเปลี่ยนแปลง
Figure 2.2 Invoice update classes with dependencies broken. (Classes การอัปเดต Invoice ที่ถูกทำลาย Dependencies แล้ว)
การทำ Refactorings เหล่านี้โดยไม่มี Tests ปลอดภัยไหม? มันสามารถปลอดภัยได้ Refactorings เหล่านี้มีชื่อว่า Primitivize Parameter ( 385 ) และ Extract Interface ( 362 ) ตามลำดับ พวกมันถูกอธิบายไว้ใน Dependency Breaking Techniques Catalog ที่ท้ายเล่ม เมื่อเราทำลาย Dependencies เราสามารถเขียน Tests ที่ทำให้การเปลี่ยนแปลงที่รุนแรงขึ้นปลอดภัยยิ่งขึ้น เคล็ดลับคือการทำ Refactorings เริ่มต้นเหล่านี้อย่างระมัดระวังที่สุด
การระมัดระวังเป็นสิ่งที่ถูกต้องเมื่อเราอาจทำให้เกิดข้อผิดพลาด แต่บางครั้งเมื่อเราทำลาย Dependencies เพื่อครอบคลุมโค้ด มันอาจไม่ได้ออกมาดีเท่ากับตัวอย่างก่อนหน้านี้ เราอาจเพิ่ม Parameters ให้ Methods ที่ไม่จำเป็นต้องใช้ใน Production Code หรืออาจแยก Classes ในวิธีที่แปลก เพียงเพื่อให้มี Tests ได้ เมื่อเราทำแบบนั้น เราอาจทำให้โค้ดดูแย่ลงในพื้นที่นั้น ถ้าเราระมัดระวังน้อยลง เราก็แค่แก้ไขมันทันที เราสามารถทำแบบนั้นได้ แต่มันขึ้นอยู่กับว่ามีความเสี่ยงมากแค่ไหน เมื่อข้อผิดพลาดเป็นเรื่องใหญ่ ซึ่งมันก็เป็นอย่างนั้นบ่อยครั้ง การระมัดระวังก็คุ้มค่า
เมื่อคุณทำลาย Dependencies ใน Legacy Code คุณมักจะต้องพักความรู้สึกด้านความสวยงามไว้ก่อน บาง Dependencies ก็ถูกทำลายได้อย่างสะอาด บางอันก็ดูไม่ค่อยดีนักในมุมมองของการออกแบบ พวกมันเหมือนกับรอยผ่าในการผ่าตัด: อาจมีรอยแผลเป็นหลงเหลืออยู่ในโค้ดของคุณหลังจากการทำงาน แต่ทุกอย่างภายใต้นั้นสามารถดีขึ้นได้
ถ้าต่อมาคุณสามารถครอบคลุมโค้ดรอบๆ จุดที่คุณทำลาย Dependencies ได้ คุณก็สามารถรักษารอยแผลเป็นนั้นได้เช่นกัน
The Legacy Code Change Algorithm (อัลกอริทึมการเปลี่ยนแปลง Legacy Code)
เมื่อคุณต้องทำการเปลี่ยนแปลงใน Legacy Code Base นี่คือ Algorithm ที่คุณสามารถใช้ได้
1. ระบุ Change Points
2. หา Test Points
3. ทำลาย Dependencies
4. เขียน Tests
5. เปลี่ยนแปลงและ Refactor
เป้าหมายในวันต่อวันของ Legacy Code คือการทำการเปลี่ยนแปลง แต่ไม่ใช่การเปลี่ยนแปลงอะไรก็ได้ เราต้องการการเปลี่ยนแปลงที่ส่งมอบคุณค่าในขณะที่นำระบบส่วนมากขึ้นมาอยู่ภายใต้ Test ในตอนท้ายของแต่ละช่วงการเขียนโปรแกรม เราควรจะชี้ไปที่โค้ดที่ให้ Feature ใหม่ได้ ไม่เพียงเท่านั้น แต่ยังรวมถึง Tests ของมันด้วย เมื่อเวลาผ่านไป พื้นที่ที่ถูกทดสอบของ Code Base จะโผล่ขึ้นมาเหมือนเกาะที่ผุดขึ้นจากมหาสมุทร การทำงานบนเกาะเหล่านี้จะง่ายขึ้นมาก เมื่อเวลาผ่านไป เกาะต่างๆ จะกลายเป็นแผ่นดินขนาดใหญ่ ในที่สุด คุณจะสามารถทำงานในทวีปของโค้ดที่ถูกครอบคลุมด้วย Test ได้
มาดูแต่ละขั้นตอนเหล่านี้กันว่าหนังสือเล่มนี้จะช่วยคุณในแต่ละขั้นตอนได้อย่างไร
Identify Change Points (ระบุ Change Points)
สถานที่ที่คุณต้องทำการเปลี่ยนแปลงขึ้นอยู่กับ Architecture ของคุณเป็นอย่างมาก ถ้าคุณไม่รู้จัก Design ของคุณดีพอที่จะรู้สึกว่าคุณกำลังเปลี่ยนแปลงในที่ที่ถูกต้อง ให้ดูที่ Chapter 16 , I Don't Understand the Code Well Enough to Change It , และ Chapter 17 , My Application Has No Structure .
Find Test Points (หา Test Points)
ในบางกรณี การหาสถานที่ที่จะเขียน Tests นั้นง่าย แต่ใน Legacy Code มันมักจะยาก ลองดูที่ Chapter 11 , I Need to Make a Change. What Methods Should I Test? , และ Chapter 12 , I Need to Make Many Changes in One Area. Do I Have to Break Dependencies for All the Classes Involved? บทเหล่านี้มีเทคนิคที่คุณสามารถใช้เพื่อตัดสินใจว่าคุณต้องเขียน Tests ที่ไหนสำหรับการเปลี่ยนแปลงเฉพาะ
Break Dependencies (ทำลาย Dependencies)
Dependencies มักจะเป็นอุปสรรคที่ชัดเจนที่สุดในการ Testing สองวิธีที่ปัญหานี้แสดงออกมาคือความยากในการสร้าง Instances ของ Objects ใน Test Harnesses และความยากในการรัน Methods ใน Test Harnesses บ่อยครั้งใน Legacy Code คุณต้องทำลาย Dependencies เพื่อให้มี Tests ได้ ในอุดมคติแล้ว เราควรมี Tests ที่บอกเราว่าสิ่งที่เราทำเพื่อทำลาย Dependencies นั้นก่อให้เกิดปัญหาหรือไม่ แต่บ่อยครั้งที่เราไม่มี ลองดูที่ Chapter 23 , How Do I Know That I'm Not Breaking Anything? เพื่อดูแนวปฏิบัติบางอย่างที่สามารถใช้เพื่อทำให้การผ่าตัดครั้งแรกในระบบปลอดภัยขึ้นเมื่อคุณเริ่มนำระบบมาอยู่ภายใต้ Test เมื่อคุณทำเสร็จแล้ว ให้ดูที่ Chapter 9 , I Can't Get This Class into a Test Harness , และ Chapter 10 , I Can't Run This Method in a Test Harness สำหรับสถานการณ์ที่แสดงวิธีผ่านปัญหาทั่วไปเกี่ยวกับ Dependencies ส่วนเหล่านี้มีการอ้างอิงถึง Dependency Breaking Techniques Catalog ที่ด้านหลังของหนังสืออย่างมาก แต่มันไม่ได้ครอบคลุมทุกเทคนิค ใช้เวลาสักครู่เพื่อดู Catalog สำหรับแนวคิดเพิ่มเติมเกี่ยวกับวิธีทำลาย Dependencies
Dependencies ยังแสดงออกมาเมื่อเรามีแนวคิดสำหรับ Test แต่ไม่สามารถเขียนมันได้ง่ายๆ ถ้าคุณพบว่าคุณไม่สามารถเขียน Tests ได้เพราะ Dependencies ใน Methods ขนาดใหญ่ ให้ดูที่ Chapter 22 , I Need to Change a Monster Method and I Can't Write Tests for It ถ้าคุณพบว่าคุณสามารถทำลาย Dependencies ได้ แต่ใช้เวลานานเกินไปในการสร้าง Tests ให้ดูที่ Chapter 7 , It Takes Forever to Make a Change . บทนั้นอธิบายงานการทำลาย Dependencies เพิ่มเติมที่คุณสามารถทำได้เพื่อทำให้ Average Build Time เร็วขึ้น
Write Tests (เขียน Tests)
ผมพบว่า Tests ที่ผมเขียนใน Legacy Code ค่อนข้างแตกต่างจาก Tests ที่ผมเขียนสำหรับโค้ดใหม่ ลองดูที่ Chapter 13 , I Need to Make a Change but I Don't Know What Tests to Write เพื่อเรียนรู้เพิ่มเติมเกี่ยวกับบทบาทของ Tests ในงาน Legacy Code
Make Changes and Refactor (เปลี่ยนแปลงและ Refactor)
ผมสนับสนุนให้ใช้ Test-Driven Development (TDD) เพื่อเพิ่ม Features ใน Legacy Code มีคำอธิบายของ TDD และเทคนิคการเพิ่ม Features อื่นๆ ใน Chapter 8 , How Do I Add a Feature? หลังจากทำการเปลี่ยนแปลงใน Legacy Code เรามักจะมีความเข้าใจปัญหาของมันดีขึ้น และ Tests ที่เราเขียนเพื่อเพิ่ม Features มักจะให้ความคุ้มครองเราบ้างในการทำ Refactoring Chapter 20 , This Class Is Too Big and I Don't Want It to Get Any Bigger ; Chapter 22 , I Need to Change a Monster Method and I Can't Write Tests for It ; และ Chapter 21 , I'm Changing the Same Code All Over the Place ครอบคลุมเทคนิคมากมายที่คุณสามารถใช้เพื่อเริ่มย้าย Legacy Code ของคุณไปสู่โครงสร้างที่ดีขึ้น จำไว้ว่าสิ่งที่ผมบรรยายในบทเหล่านี้คือ "baby steps" (ก้าวเล็กๆ) พวกมันไม่ได้สอนวิธีทำให้ Design ของคุณสมบูรณ์แบบ สะอาด หรือเต็มไปด้วย Patterns มีหนังสือมากมายที่สอนวิธีทำสิ่งเหล่านั้น และเมื่อคุณมีโอกาสใช้เทคนิคเหล่านั้น ผมขอแนะนำให้คุณทำ บทเหล่านี้แสดงวิธีทำให้ Design ดีขึ้น โดยที่ "ดีกว่า" นั้นขึ้นอยู่กับบริบท และมักจะเป็นแค่การทำให้ Maintainable มากขึ้นกว่าที่เคยเป็นอยู่สองสามก้าว แต่อย่ามองข้ามงานนี้ บ่อยครั้งสิ่งที่ง่ายที่สุด เช่น การแบ่ง Class ขนาดใหญ่เพื่อให้ทำงานด้วยง่ายขึ้น สามารถสร้างความแตกต่างอย่างมีนัยสำคัญในแอปพลิเคชัน แม้ว่ามันจะเป็นเรื่องที่ค่อนข้างเป็นกลไกก็ตาม
The Rest of This Book (ส่วนที่เหลือของหนังสือเล่มนี้)
ส่วนที่เหลือของหนังสือเล่มนี้จะแสดงวิธีทำการเปลี่ยนแปลงใน Legacy Code สองบทถัดไปมีเนื้อหาพื้นหลังเกี่ยวกับแนวคิดสำคัญสามอย่างในงาน Legacy: Sensing, Separation, และ Seams