การทดสอบหน่วย วิธีการเขียนโค้ดที่ทดสอบได้ และเหตุใดจึงสำคัญ
เผยแพร่แล้ว: 2022-03-11การทดสอบหน่วยเป็นเครื่องมือสำคัญในกล่องเครื่องมือของนักพัฒนาซอฟต์แวร์ที่จริงจัง อย่างไรก็ตาม บางครั้งอาจเป็นเรื่องยากที่จะเขียนการทดสอบหน่วยที่ดีสำหรับโค้ดบางชิ้น มีปัญหาในการทดสอบโค้ดของตนเองหรือของผู้อื่น นักพัฒนามักคิดว่าอุปสรรคของพวกเขาเกิดจากการขาดความรู้พื้นฐานในการทดสอบหรือเทคนิคการทดสอบหน่วยลับ
ในบทช่วยสอนการทดสอบหน่วยนี้ ฉันตั้งใจที่จะแสดงให้เห็นว่าการทดสอบหน่วยนั้นค่อนข้างง่าย ปัญหาจริงที่ทำให้การทดสอบหน่วยซับซ้อน และทำให้เกิดความซับซ้อนที่มีราคาแพง เป็นผลมาจาก โค้ด ที่ออกแบบไม่ดีและทดสอบไม่ได้ เราจะหารือกันถึงสิ่งที่ทำให้โค้ดยากต่อการทดสอบ รูปแบบการต่อต้านและแนวทางปฏิบัติที่ไม่ถูกต้องที่เราควรหลีกเลี่ยงเพื่อปรับปรุงความสามารถในการทดสอบ และประโยชน์อื่นๆ ที่เราสามารถทำได้โดยการเขียนโค้ดที่ทดสอบได้ เราจะเห็นว่าการเขียน unit test และสร้าง code ที่ทดสอบได้นั้นไม่ใช่แค่การทำให้การทดสอบยุ่งยากน้อยลงเท่านั้น แต่ยังรวมถึงการทำให้ code นั้นแข็งแกร่งขึ้นและง่ายต่อการบำรุงรักษาอีกด้วย
การทดสอบหน่วยคืออะไร?
โดยพื้นฐานแล้ว การทดสอบหน่วยเป็นวิธีที่สร้างตัวอย่างส่วนเล็กๆ ของแอปพลิเคชันของเราและยืนยันการทำงาน โดยไม่ขึ้นกับส่วนอื่นๆ การทดสอบหน่วยทั่วไปประกอบด้วย 3 ขั้นตอน: ขั้นแรก จะเริ่มต้นส่วนเล็กๆ ของแอปพลิเคชันที่ต้องการทดสอบ (เรียกอีกอย่างว่าระบบที่กำลังทดสอบ หรือ SUT) จากนั้นจะใช้สิ่งเร้าบางอย่างกับระบบที่กำลังทดสอบ (โดยปกติโดยการเรียก วิธีการ) และสุดท้ายก็สังเกตพฤติกรรมที่เกิดขึ้น หากพฤติกรรมที่สังเกตได้สอดคล้องกับความคาดหวัง การทดสอบหน่วยจะผ่าน มิฉะนั้น จะไม่สำเร็จ แสดงว่ามีปัญหาบางอย่างในระบบที่อยู่ระหว่างการทดสอบ ขั้นตอนการทดสอบหน่วยทั้งสามนี้เรียกอีกอย่างว่า Arrange, Act and Assert หรือเพียงแค่ AAA
การทดสอบหน่วยสามารถตรวจสอบลักษณะพฤติกรรมที่แตกต่างกันของระบบภายใต้การทดสอบ แต่มีแนวโน้มมากที่สุดว่าจะจัดอยู่ในประเภทใดประเภทหนึ่งต่อไปนี้: อิงสถานะ หรือ อิงจาก การโต้ตอบ การตรวจสอบว่าระบบที่ทดสอบให้ผลลัพธ์ที่ถูกต้อง หรือสถานะผลลัพธ์นั้นถูกต้อง เรียกว่าการทดสอบหน่วย ตามสถานะ ในขณะที่การตรวจสอบว่าเรียกใช้วิธีการบางอย่างได้อย่างเหมาะสมจะเรียกว่าการทดสอบหน่วย ตามการโต้ตอบ
เพื่อเป็นการอุปมาสำหรับการทดสอบหน่วยซอฟต์แวร์ที่เหมาะสม ลองนึกภาพนักวิทยาศาสตร์ที่คลั่งไคล้ที่ต้องการสร้างความฝันเหนือธรรมชาติด้วยขากบ หนวดปลาหมึก ปีกนก และหัวของสุนัข (คำอุปมานี้ค่อนข้างใกล้เคียงกับสิ่งที่โปรแกรมเมอร์ทำในที่ทำงานจริงๆ) นักวิทยาศาสตร์คนนั้นจะแน่ใจได้อย่างไรว่าทุกส่วน (หรือหน่วย) ที่เขาเลือกใช้งานได้จริง สมมุติว่าเขาใช้ขากบตัวเดียว ใช้เครื่องกระตุ้นไฟฟ้ากับมัน และตรวจดูว่ากล้ามเนื้อหดตัวหรือไม่ สิ่งที่เขาทำอยู่นั้นก็เหมือนกับขั้นตอน Arrange-Act-Assert ของการทดสอบหน่วย ข้อแตกต่างเพียงอย่างเดียวคือ ในกรณีนี้ unit หมายถึงวัตถุทางกายภาพ ไม่ใช่วัตถุนามธรรมที่เราสร้างโปรแกรมขึ้นมา
ฉันจะใช้ C# สำหรับตัวอย่างทั้งหมดในบทความนี้ แต่แนวคิดที่อธิบายไว้จะนำไปใช้กับภาษาการเขียนโปรแกรมเชิงวัตถุทั้งหมด
ตัวอย่างของการทดสอบหน่วยอย่างง่ายอาจมีลักษณะดังนี้:
[TestMethod] public void IsPalindrome_ForPalindromeString_ReturnsTrue() { // In the Arrange phase, we create and set up a system under test. // A system under test could be a method, a single object, or a graph of connected objects. // It is OK to have an empty Arrange phase, for example if we are testing a static method - // in this case SUT already exists in a static form and we don't have to initialize anything explicitly. PalindromeDetector detector = new PalindromeDetector(); // The Act phase is where we poke the system under test, usually by invoking a method. // If this method returns something back to us, we want to collect the result to ensure it was correct. // Or, if method doesn't return anything, we want to check whether it produced the expected side effects. bool isPalindrome = detector.IsPalindrome("kayak"); // The Assert phase makes our unit test pass or fail. // Here we check that the method's behavior is consistent with expectations. Assert.IsTrue(isPalindrome); }
การทดสอบหน่วยเทียบกับการทดสอบการรวม
สิ่งสำคัญอีกประการที่ควรพิจารณาคือความแตกต่างระหว่างการทดสอบหน่วยและการทดสอบการรวม
จุดประสงค์ของการทดสอบหน่วยในวิศวกรรมซอฟต์แวร์คือเพื่อตรวจสอบการทำงานของซอฟต์แวร์ชิ้นเล็กๆ โดยไม่ขึ้นกับส่วนอื่นๆ การทดสอบหน่วยมีขอบเขตที่แคบ และทำให้เราครอบคลุมทุกกรณี เพื่อให้มั่นใจว่าทุกส่วนทำงานอย่างถูกต้อง
ในทางกลับกัน การทดสอบการรวมแสดงให้เห็นว่าส่วนต่างๆ ของระบบ ทำงานร่วมกันในสภาพแวดล้อมในชีวิตจริง พวกเขาตรวจสอบสถานการณ์ที่ซับซ้อน (เราสามารถนึกถึงการทดสอบการรวมเป็นผู้ใช้ที่ดำเนินการระดับสูงบางอย่างภายในระบบของเรา) และมักจะต้องมีทรัพยากรภายนอก เช่น ฐานข้อมูลหรือเว็บเซิร์ฟเวอร์
กลับไปที่อุปมานักวิทยาศาสตร์ที่บ้าคลั่งของเรากัน และสมมติว่าเขารวมทุกส่วนของความฝันได้สำเร็จ เขาต้องการทดสอบการรวมตัวของสิ่งมีชีวิตที่เป็นผล ทำให้แน่ใจว่ามันสามารถเดินบนภูมิประเทศประเภทต่างๆ ได้ ก่อนอื่น นักวิทยาศาสตร์ต้องจำลองสภาพแวดล้อมเพื่อให้สิ่งมีชีวิตเดินต่อไปได้ จากนั้นเขาก็โยนสิ่งมีชีวิตนั้นลงในสภาพแวดล้อมนั้นและแหย่มันด้วยไม้ สังเกตว่ามันเดินและเคลื่อนไหวตามที่ออกแบบไว้หรือไม่ หลังจากเสร็จสิ้นการทดสอบ นักวิทยาศาสตร์ผู้คลั่งไคล้จะทำความสะอาดสิ่งสกปรก ทราย และหินที่กระจัดกระจายอยู่ในห้องทดลองอันสวยงามของเขา
สังเกตความแตกต่างที่สำคัญระหว่างการทดสอบหน่วยและการทดสอบการรวม: การทดสอบหน่วยตรวจสอบการทำงานของส่วนเล็ก ๆ ของแอปพลิเคชัน แยกออกจากสภาพแวดล้อมและส่วนอื่น ๆ และค่อนข้างง่ายต่อการใช้งาน ในขณะที่การทดสอบการรวมครอบคลุมการโต้ตอบระหว่างส่วนประกอบต่าง ๆ ใน สภาพแวดล้อมที่ใกล้เคียงกับชีวิตจริง และต้องใช้ความพยายามมากขึ้น รวมถึงขั้นตอนการตั้งค่าเพิ่มเติมและการแยกส่วน
การผสมผสานที่สมเหตุสมผลของการทดสอบยูนิตและการรวมเข้าด้วยกันช่วยให้มั่นใจว่าทุกยูนิตทำงานอย่างถูกต้อง เป็นอิสระจากยูนิตอื่น และยูนิตทั้งหมดเหล่านี้เล่นได้อย่างดีเมื่อรวมเข้าด้วยกัน ทำให้เรามีความมั่นใจในระดับสูงว่าทั้งระบบทำงานตามที่คาดไว้
อย่างไรก็ตาม เราต้องจำไว้ว่าต้องระบุเสมอว่าเรากำลังใช้การทดสอบประเภทใด: หน่วยหรือการทดสอบการรวม ความแตกต่างบางครั้งสามารถหลอกลวงได้ หากเราคิดว่าเรากำลังเขียน unit test เพื่อตรวจสอบ edge case ที่ละเอียดอ่อนในคลาสตรรกะทางธุรกิจ และตระหนักว่าต้องใช้ทรัพยากรภายนอก เช่น บริการเว็บหรือฐานข้อมูลที่มีอยู่ มีบางอย่างไม่ถูกต้อง โดยพื้นฐานแล้ว เรากำลังใช้ค้อนขนาดใหญ่เพื่อ แตกถั่ว และนั่นหมายถึงการออกแบบที่ไม่ดี
อะไรทำให้การทดสอบหน่วยที่ดี
ก่อนดำดิ่งสู่ส่วนหลักของบทช่วยสอนนี้และเขียนการทดสอบหน่วย เรามาพูดถึงคุณสมบัติของการทดสอบหน่วยที่ดีกันก่อน หลักการทดสอบหน่วยต้องการให้การทดสอบที่ดีคือ:
เขียนง่าย. โดยทั่วไปแล้ว นักพัฒนาซอฟต์แวร์จะเขียน Unit Test จำนวนมากเพื่อให้ครอบคลุมกรณีและลักษณะการทำงานที่แตกต่างกันของแอปพลิเคชัน ดังนั้นจึงควรเขียนโค้ดสำหรับรูทีนการทดสอบทั้งหมดได้อย่างง่ายดายโดยไม่ต้องใช้ความพยายามอย่างมาก
อ่านได้ จุดประสงค์ของการทดสอบหน่วยควรมีความชัดเจน การทดสอบหน่วยที่ดีจะบอกเล่าเรื่องราวเกี่ยวกับลักษณะพฤติกรรมบางอย่างของแอปพลิเคชันของเรา ดังนั้นจึงควรเข้าใจได้ง่ายว่าสถานการณ์ใดกำลังถูกทดสอบ และหากการทดสอบล้มเหลว จะตรวจพบวิธีแก้ปัญหาได้ง่าย ด้วยการทดสอบหน่วยที่ดี เราสามารถแก้ไขข้อผิดพลาดได้โดยไม่ต้องดีบั๊กโค้ดจริงๆ!
เชื่อถือได้. การทดสอบหน่วยจะล้มเหลวก็ต่อเมื่อมีจุดบกพร่องในระบบที่อยู่ระหว่างการทดสอบ ดูเหมือนจะค่อนข้างชัดเจน แต่โปรแกรมเมอร์มักประสบปัญหาเมื่อการทดสอบล้มเหลวแม้ว่าจะไม่มีจุดบกพร่องก็ตาม ตัวอย่างเช่น การทดสอบอาจผ่านเมื่อรันทีละรายการ แต่จะล้มเหลวเมื่อรันชุดการทดสอบทั้งหมด หรือส่งผ่านบนเครื่องพัฒนาของเราและล้มเหลวบนเซิร์ฟเวอร์การรวมแบบต่อเนื่อง สถานการณ์เหล่านี้บ่งบอกถึงข้อบกพร่องในการออกแบบ การทดสอบหน่วยที่ดีควรทำซ้ำได้และเป็นอิสระจากปัจจัยภายนอก เช่น สภาพแวดล้อมหรือลำดับการทำงาน
เร็ว. นักพัฒนาเขียน unit test เพื่อให้สามารถเรียกใช้ซ้ำได้และตรวจสอบว่าไม่มีการแนะนำจุดบกพร่อง หากการทดสอบหน่วยช้า นักพัฒนามักจะข้ามการเรียกใช้บนเครื่องของตนเอง การทดสอบช้าหนึ่งครั้งจะไม่สร้างความแตกต่างอย่างมีนัยสำคัญ เพิ่มอีกหนึ่งพันและเราก็ต้องรอสักครู่อย่างแน่นอน การทดสอบหน่วยที่ช้ายังอาจบ่งชี้ว่าระบบที่ทดสอบหรือตัวการทดสอบเองนั้นโต้ตอบกับระบบภายนอก ทำให้ขึ้นอยู่กับสภาพแวดล้อม
หน่วยอย่างแท้จริงไม่ใช่บูรณาการ ดังที่เราได้กล่าวไปแล้ว การทดสอบหน่วยและการทดสอบการรวมมีวัตถุประสงค์ที่แตกต่างกัน ทั้งการทดสอบหน่วยและระบบที่ทดสอบไม่ควรเข้าถึงทรัพยากรเครือข่าย ฐานข้อมูล ระบบไฟล์ ฯลฯ เพื่อขจัดอิทธิพลของปัจจัยภายนอก
แค่นั้นแหละ — ไม่มีความลับในการเขียนแบบ ทดสอบหน่วยการ เรียนรู้ อย่างไรก็ตาม มีเทคนิคบางอย่างที่ช่วยให้เราสามารถเขียน โค้ดที่ทดสอบได้
รหัสที่ทดสอบได้และทดสอบไม่ได้
โค้ดบางตัวเขียนในลักษณะที่ยากหรือแทบจะเป็นไปไม่ได้เลยที่จะเขียนแบบทดสอบหน่วยที่ดี ดังนั้นอะไรทำให้โค้ดทดสอบยาก มาทบทวนการต่อต้านรูปแบบ กลิ่นโค้ด และแนวทางปฏิบัติที่ไม่ดีที่เราควรหลีกเลี่ยงเมื่อเขียนโค้ดที่ทดสอบได้
การทำให้ Codebase เป็นพิษด้วยปัจจัยที่ไม่สามารถกำหนดได้
มาเริ่มกันด้วยตัวอย่างง่ายๆ ลองนึกภาพว่าเรากำลังเขียนโปรแกรมสำหรับไมโครคอนโทรลเลอร์สำหรับบ้านอัจฉริยะ และข้อกำหนดประการหนึ่งคือการเปิดไฟในสวนหลังบ้านโดยอัตโนมัติ หากตรวจพบการเคลื่อนไหวที่นั่นในตอนเย็นหรือตอนกลางคืน เราได้เริ่มต้นจากล่างขึ้นบนโดยใช้วิธีการที่ส่งกลับการแสดงสตริงของเวลาโดยประมาณของวัน ("กลางคืน", "เช้า", "บ่าย" หรือ "เย็น"):
public static string GetTimeOfDay() { DateTime time = DateTime.Now; if (time.Hour >= 0 && time.Hour < 6) { return "Night"; } if (time.Hour >= 6 && time.Hour < 12) { return "Morning"; } if (time.Hour >= 12 && time.Hour < 18) { return "Afternoon"; } return "Evening"; }
โดยพื้นฐานแล้ว วิธีนี้จะอ่านเวลาของระบบปัจจุบันและส่งกลับผลลัพธ์ตามค่านั้น ดังนั้นสิ่งที่ผิดปกติกับรหัสนี้?
หากเราพิจารณาจากมุมมองของการทดสอบหน่วย เราจะเห็นว่าเป็นไปไม่ได้ที่จะเขียนการทดสอบหน่วยตามสถานะที่เหมาะสมสำหรับวิธีนี้ โดยพื้นฐานแล้ว DateTime.Now
เป็นอินพุตที่ซ่อนอยู่ซึ่งอาจเปลี่ยนแปลงระหว่างการทำงานของโปรแกรมหรือระหว่างการทดสอบรัน ดังนั้นการเรียกในภายหลังจะให้ผลลัพธ์ที่แตกต่างกัน
พฤติกรรม ที่ไม่ได้กำหนดไว้ ดังกล่าวทำให้ไม่สามารถทดสอบตรรกะภายในของ GetTimeOfDay()
ได้โดยไม่ต้องเปลี่ยนวันที่และเวลาของระบบจริงๆ มาดูกันว่าการทดสอบดังกล่าวจะต้องดำเนินการอย่างไร:
[TestMethod] public void GetTimeOfDay_At6AM_ReturnsMorning() { try { // Setup: change system time to 6 AM ... // Arrange phase is empty: testing static method, nothing to initialize // Act string timeOfDay = GetTimeOfDay(); // Assert Assert.AreEqual("Morning", timeOfDay); } finally { // Teardown: roll system time back ... } }
การทดสอบเช่นนี้จะละเมิดกฎมากมายที่กล่าวถึงก่อนหน้านี้ การเขียนจะมีราคาแพง (เนื่องจากการตั้งค่าที่ไม่สำคัญและตรรกะการฉีกขาด) ไม่น่าเชื่อถือ (อาจล้มเหลวแม้ว่าจะไม่มีข้อบกพร่องในระบบที่อยู่ระหว่างการทดสอบ เช่น ปัญหาการอนุญาตของระบบ) และไม่รับประกัน วิ่งเร็ว. และสุดท้าย การทดสอบนี้จะไม่ใช่การทดสอบหน่วย แต่เป็นบางอย่างระหว่างการทดสอบหน่วยและการทดสอบการรวม เนื่องจากเป็นการแสร้งทำเป็นทดสอบ edge case ธรรมดาๆ แต่ต้องมีการตั้งค่าสภาพแวดล้อมในลักษณะเฉพาะ ผลที่ได้ไม่คุ้มกับความพยายามใช่ไหม
ปรากฎว่าปัญหาความสามารถในการทดสอบเหล่านี้เกิดจาก API GetTimeOfDay()
ที่มีคุณภาพต่ำ ในรูปแบบปัจจุบัน วิธีนี้ประสบปัญหาหลายประการ:
แนบแน่นกับแหล่งข้อมูลที่เป็นรูปธรรม ไม่สามารถใช้วิธีนี้ซ้ำสำหรับการประมวลผลวันที่และเวลาที่ดึงมาจากแหล่งอื่นหรือส่งผ่านเป็นอาร์กิวเมนต์ วิธีการนี้ใช้งานได้เฉพาะกับวันที่และเวลาของเครื่องที่รันโค้ดเท่านั้น การมีเพศสัมพันธ์อย่างแน่นหนาเป็นสาเหตุหลักของปัญหาในการทดสอบส่วนใหญ่
มันละเมิดหลักการความรับผิดชอบเดียว (SRP) วิธีการนี้มีความรับผิดชอบหลายประการ มันใช้ข้อมูลและประมวลผลด้วย ตัวบ่งชี้การละเมิด SRP อีกประการหนึ่งคือเมื่อคลาสหรือเมธอดเดียวมี เหตุผลมากกว่าหนึ่งข้อในการเปลี่ยนแปลง จากมุมมองนี้
GetTimeOfDay()
สามารถเปลี่ยนแปลงได้เนื่องจากการปรับลอจิกภายใน หรือเนื่องจากควรเปลี่ยนแหล่งที่มาของวันที่และเวลามันโกหกเกี่ยวกับข้อมูลที่จำเป็นในการทำงานให้สำเร็จ นักพัฒนาซอฟต์แวร์ต้องอ่านซอร์สโค้ดจริงทุกบรรทัดเพื่อทำความเข้าใจว่ามีการใช้อินพุตใดที่ซ่อนอยู่และมาจากไหน ลายเซ็นเมธอดเพียงอย่างเดียวไม่เพียงพอที่จะเข้าใจพฤติกรรมของเมธอด
เป็นการยากที่จะคาดเดาและรักษาไว้ พฤติกรรมของวิธีการที่ขึ้นอยู่กับสถานะโกลบอลที่ไม่แน่นอนนั้นไม่สามารถคาดเดาได้เพียงแค่อ่านซอร์สโค้ดเท่านั้น จำเป็นต้องคำนึงถึงมูลค่าปัจจุบันพร้อมกับลำดับเหตุการณ์ทั้งหมดที่อาจเปลี่ยนแปลงไปก่อนหน้านี้ ในแอปพลิเคชันในโลกแห่งความเป็นจริง การพยายามคลี่คลายทุกสิ่งจะกลายเป็นเรื่องน่าปวดหัวอย่างแท้จริง
หลังจากตรวจสอบ API แล้ว มาแก้ไขกันในที่สุด! โชคดีที่สิ่งนี้ง่ายกว่าการพูดคุยถึงข้อบกพร่องทั้งหมด — เราแค่ต้องขจัดข้อกังวลที่เชื่อมโยงกันอย่างแน่นหนา
การแก้ไข API: การแนะนำวิธีอาร์กิวเมนต์
วิธีที่ชัดเจนและง่ายที่สุดในการแก้ไข API คือการแนะนำวิธีอาร์กิวเมนต์:
public static string GetTimeOfDay(DateTime dateTime) { if (dateTime.Hour >= 0 && dateTime.Hour < 6) { return "Night"; } if (dateTime.Hour >= 6 && dateTime.Hour < 12) { return "Morning"; } if (dateTime.Hour >= 12 && dateTime.Hour < 18) { return "Noon"; } return "Evening"; }
ตอนนี้เมธอดต้องการให้ผู้โทรระบุอาร์กิวเมนต์ DateTime
แทนที่จะค้นหาข้อมูลนี้ด้วยตัวเองอย่างลับๆ จากมุมมองของการทดสอบหน่วย นี่เป็นสิ่งที่ยอดเยี่ยม ตอนนี้เมธอดถูกกำหนดไว้แล้ว (เช่น ค่าที่ส่งคืนนั้นขึ้นอยู่กับอินพุตทั้งหมด) ดังนั้นการทดสอบตามสถานะจึงง่ายพอๆ กับการส่งผ่านค่า DateTime
และตรวจสอบผลลัพธ์:
[TestMethod] public void GetTimeOfDay_For6AM_ReturnsMorning() { // Arrange phase is empty: testing static method, nothing to initialize // Act string timeOfDay = GetTimeOfDay(new DateTime(2015, 12, 31, 06, 00, 00)); // Assert Assert.AreEqual("Morning", timeOfDay); }
โปรดสังเกตว่า refactor แบบง่ายนี้ยังช่วยแก้ปัญหา API ทั้งหมดที่กล่าวถึงก่อนหน้านี้ด้วย (การมีเพศสัมพันธ์ที่แน่นหนา การละเมิด SRP API ที่ไม่ชัดเจนและเข้าใจยาก) โดยแนะนำรอยต่อที่ชัดเจนระหว่างข้อมูล ที่ ควรได้รับการประมวลผลและ วิธี ที่ควรทำ
ยอดเยี่ยม — วิธีการนี้สามารถทดสอบได้ แต่ ลูกค้า ของวิธีนี้ล่ะ ? ตอนนี้เป็นความรับผิดชอบ ของผู้โทรใน การระบุวันที่และเวลาให้กับ GetTimeOfDay(DateTime dateTime)
ซึ่งหมายความ ว่า อาจไม่สามารถทดสอบได้หากเราไม่ใส่ใจเพียงพอ มาดูกันว่าเราจะจัดการกับมันได้อย่างไร
การแก้ไข Client API: การพึ่งพาการฉีด
สมมติว่าเรายังคงทำงานบนระบบบ้านอัจฉริยะต่อไป และใช้ไคลเอ็นต์ต่อไปนี้ของวิธี GetTimeOfDay(DateTime dateTime)
ซึ่งเป็นรหัสไมโครคอนโทรลเลอร์สำหรับบ้านอัจฉริยะที่กล่าวถึงข้างต้น ซึ่งมีหน้าที่ในการเปิดหรือปิดไฟ โดยพิจารณาจากช่วงเวลาของวันและการตรวจจับการเคลื่อนไหว :
public class SmartHomeController { public DateTime LastMotionTime { get; private set; } public void ActuateLights(bool motionDetected) { DateTime time = DateTime.Now; // Ouch! // Update the time of last motion. if (motionDetected) { LastMotionTime = time; } // If motion was detected in the evening or at night, turn the light on. string timeOfDay = GetTimeOfDay(time); if (motionDetected && (timeOfDay == "Evening" || timeOfDay == "Night")) { BackyardLightSwitcher.Instance.TurnOn(); } // If no motion is detected for one minute, or if it is morning or day, turn the light off. else if (time.Subtract(LastMotionTime) > TimeSpan.FromMinutes(1) || (timeOfDay == "Morning" || timeOfDay == "Noon")) { BackyardLightSwitcher.Instance.TurnOff(); } } }
อุ๊ย! เรามี DateTime.Now
แบบเดียวกันที่ซ่อนอยู่ ปัญหาการป้อนข้อมูลในตอนนี้ — ข้อแตกต่างเพียงอย่างเดียวคือมันตั้งอยู่บนระดับนามธรรมที่สูงขึ้นเล็กน้อย เพื่อแก้ปัญหานี้ เราสามารถแนะนำอาร์กิวเมนต์อื่น โดยมอบหมายความรับผิดชอบในการจัดหาค่า DateTime
ให้กับผู้เรียกใช้เมธอดใหม่อีกครั้งด้วยลายเซ็น ActuateLights(bool motionDetected, DateTime dateTime)
แต่แทนที่จะย้ายปัญหาให้สูงขึ้นใน call stack อีกครั้ง ลองใช้เทคนิคอื่นที่จะช่วยให้เราสามารถเก็บทั้ง ActuateLights(bool motionDetected)
และไคลเอนต์ที่สามารถทดสอบได้: Inversion of Control หรือ IoC
การผกผันของการควบคุมเป็นเทคนิคง่ายๆ แต่มีประโยชน์มากสำหรับการถอดรหัสโค้ด และสำหรับการทดสอบหน่วยโดยเฉพาะ (ท้ายที่สุด การรักษาสิ่งที่เป็นคู่กันอย่างหลวมๆ เป็นสิ่งจำเป็นสำหรับการวิเคราะห์อย่างอิสระจากกันและกัน) ประเด็นสำคัญของ IoC คือการแยกรหัสการตัดสินใจ ( เมื่อ จะทำบางสิ่ง) ออกจากรหัสการกระทำ ( จะทำ อย่างไร เมื่อมีบางสิ่งเกิดขึ้น ). เทคนิคนี้เพิ่มความยืดหยุ่น ทำให้โค้ดของเราเป็นแบบโมดูลาร์มากขึ้น และลดการเชื่อมต่อระหว่างส่วนประกอบต่างๆ
การผกผันของการควบคุมสามารถทำได้หลายวิธี มาดูตัวอย่างกันโดยเฉพาะ — Dependency Injection โดยใช้ Constructor — และวิธีที่มันสามารถช่วยในการสร้าง SmartHomeController
API ที่ทดสอบได้
ขั้นแรก ให้สร้างอินเทอร์เฟซ IDateTimeProvider
ซึ่งมีลายเซ็นเมธอดสำหรับรับวันที่และเวลา:
public interface IDateTimeProvider { DateTime GetDateTime(); }
จากนั้นให้ SmartHomeController
อ้างอิงการใช้งาน IDateTimeProvider
และมอบหมายความรับผิดชอบในการรับวันที่และเวลา:
public class SmartHomeController { private readonly IDateTimeProvider _dateTimeProvider; // Dependency public SmartHomeController(IDateTimeProvider dateTimeProvider) { // Inject required dependency in the constructor. _dateTimeProvider = dateTimeProvider; } public void ActuateLights(bool motionDetected) { DateTime time = _dateTimeProvider.GetDateTime(); // Delegating the responsibility // Remaining light control logic goes here... } }
ตอนนี้เราเห็นแล้วว่าเหตุใดจึงเรียก Inversion of Control: การ ควบคุม กลไกที่จะใช้สำหรับการอ่านวันที่และเวลาถูก กลับด้าน และตอนนี้เป็นของ ลูกค้า ของ SmartHomeController
ไม่ใช่ SmartHomeController
ด้วยเหตุนี้ การดำเนินการของวิธี ActuateLights(bool motionDetected)
ขึ้นอยู่กับสองสิ่งที่สามารถจัดการได้อย่างง่ายดายจากภายนอก: อาร์กิวเมนต์ motionDetected
และการใช้งาน IDateTimeProvider
อย่างเป็นรูปธรรม ส่งต่อไปยังคอนสตรัคเตอร์ SmartHomeController

เหตุใดจึงมีความสำคัญสำหรับการทดสอบหน่วย หมายความว่าการใช้งาน IDateTimeProvider
ที่แตกต่างกันสามารถใช้ได้ในรหัสการผลิตและรหัสทดสอบหน่วย ในสภาพแวดล้อมการผลิต การใช้งานจริงบางอย่างจะถูกแทรก (เช่น หนึ่งที่อ่านเวลาของระบบจริง) อย่างไรก็ตาม ในการทดสอบหน่วย เราสามารถฉีดการใช้งาน "ปลอม" ที่ส่งกลับค่า DateTime
คงที่หรือที่กำหนดไว้ล่วงหน้าซึ่งเหมาะสำหรับการทดสอบสถานการณ์เฉพาะ
การใช้งาน IDateTimeProvider
ปลอมอาจมีลักษณะดังนี้:
public class FakeDateTimeProvider : IDateTimeProvider { public DateTime ReturnValue { get; set; } public DateTime GetDateTime() { return ReturnValue; } public FakeDateTimeProvider(DateTime returnValue) { ReturnValue = returnValue; } }
ด้วยความช่วยเหลือของคลาสนี้ เป็นไปได้ที่จะแยก SmartHomeController
ออกจากปัจจัยที่ไม่ได้กำหนดไว้และทำการทดสอบหน่วยตามสถานะ มาตรวจสอบว่า หากตรวจพบการเคลื่อนไหว เวลาของการเคลื่อนไหวนั้นจะถูกบันทึกในคุณสมบัติ LastMotionTime
:
[TestMethod] void ActuateLights_MotionDetected_SavesTimeOfMotion() { // Arrange var controller = new SmartHomeController(new FakeDateTimeProvider(new DateTime(2015, 12, 31, 23, 59, 59))); // Act controller.ActuateLights(true); // Assert Assert.AreEqual(new DateTime(2015, 12, 31, 23, 59, 59), controller.LastMotionTime); }
ยอดเยี่ยม! การทดสอบแบบนี้ไม่สามารถทำได้ก่อนที่จะทำการ refactoring ตอนนี้เราได้กำจัดปัจจัยที่ไม่ได้กำหนดไว้และตรวจสอบสถานการณ์ตามสถานะแล้ว คุณคิดว่า SmartHomeController
สามารถทดสอบได้ทั้งหมดหรือไม่
พิษของ Codebase ด้วยผลข้างเคียง
แม้ว่าเราจะแก้ปัญหาที่เกิดจากอินพุตที่ซ่อนอยู่ซึ่งไม่ได้กำหนดไว้ และเราสามารถทดสอบการทำงานบางอย่างได้ แต่โค้ด (หรืออย่างน้อย บางส่วน) ก็ยังไม่สามารถทดสอบได้!
มาทบทวนส่วนต่อไปนี้ของวิธี ActuateLights(bool motionDetected)
ที่รับผิดชอบในการเปิดหรือปิดไฟ:
// If motion was detected in the evening or at night, turn the light on. if (motionDetected && (timeOfDay == "Evening" || timeOfDay == "Night")) { BackyardLightSwitcher.Instance.TurnOn(); } // If no motion was detected for one minute, or if it is morning or day, turn the light off. else if (time.Subtract(LastMotionTime) > TimeSpan.FromMinutes(1) || (timeOfDay == "Morning" || timeOfDay == "Noon")) { BackyardLightSwitcher.Instance.TurnOff(); }
ดังที่เราเห็น SmartHomeController
มอบหมายความรับผิดชอบในการเปิดหรือปิดไฟให้กับวัตถุ BackyardLightSwitcher
ซึ่งใช้รูปแบบ Singleton มีอะไรผิดปกติกับการออกแบบนี้?
ในการทดสอบหน่วยแบบเต็มของวิธี ActuateLights(bool motionDetected)
เราควรดำเนินการทดสอบตามการโต้ตอบเพิ่มเติมจากการทดสอบตามสถานะ นั่นคือเราควรตรวจสอบให้แน่ใจว่ามีการเรียกวิธีการเปิดหรือปิดไฟหากตรงตามเงื่อนไขที่เหมาะสมเท่านั้น น่าเสียดายที่การออกแบบปัจจุบันไม่อนุญาตให้เราทำอย่างนั้น: วิธี TurnOn()
และ TurnOff( TurnOff()
ของ BackyardLightSwitcher
ทำให้เกิดการเปลี่ยนแปลงสถานะบางอย่างในระบบ หรือกล่าวอีกนัยหนึ่ง ทำให้เกิด ผลข้างเคียง วิธีเดียวที่จะตรวจสอบว่าวิธีการเหล่านี้ถูกเรียกคือการตรวจสอบว่าผลข้างเคียงที่เกิดขึ้นจริงหรือไม่ซึ่งอาจเจ็บปวดได้
ที่จริงแล้ว สมมติว่าเซ็นเซอร์ตรวจจับความเคลื่อนไหว โคมไฟสนามหลังบ้าน และไมโครคอนโทรลเลอร์อัจฉริยะในบ้านเชื่อมต่อกับเครือข่าย Internet of Things และสื่อสารโดยใช้โปรโตคอลไร้สายบางตัว ในกรณีนี้ การทดสอบหน่วยสามารถพยายามรับและวิเคราะห์การรับส่งข้อมูลเครือข่ายนั้น หรือหากส่วนประกอบฮาร์ดแวร์เชื่อมต่อด้วยสายไฟ การทดสอบหน่วยสามารถตรวจสอบว่าแรงดันไฟฟ้าถูกนำไปใช้กับวงจรไฟฟ้าที่เหมาะสมหรือไม่ หรือตรวจสอบได้ว่าไฟเปิดหรือปิดจริงโดยใช้เซ็นเซอร์วัดแสงเพิ่มเติม
ดังที่เราเห็น วิธีการสร้างผลข้างเคียงของการทดสอบหน่วยอาจยากพอๆ กับการทดสอบหน่วยแบบไม่กำหนดแบบตายตัว และอาจเป็นไปไม่ได้ด้วยซ้ำ ความพยายามใด ๆ จะนำไปสู่ปัญหาที่คล้ายกับที่เราได้เห็นแล้ว ผลการทดสอบจะยากต่อการนำไปใช้ ไม่น่าเชื่อถือ อาจช้า และไม่ใช่หน่วยจริงๆ และหลังจากนั้น แสงแวบ ๆ ทุกครั้งที่เราเรียกใช้ชุดทดสอบจะทำให้เราแทบบ้า!
อีกครั้ง ปัญหาความสามารถในการทดสอบเหล่านี้เกิดจาก API ที่ไม่ดี ไม่ใช่ความสามารถของนักพัฒนาในการเขียนการทดสอบหน่วย ไม่ว่าจะใช้การควบคุมด้วยแสงอย่างไร SmartHomeController
API ก็ประสบปัญหาที่คุ้นเคยอยู่แล้วเหล่านี้:
ควบคู่ไปกับการดำเนินการอย่างเป็นรูปธรรม API อาศัยอินสแตนซ์ที่เป็นรูปธรรมของ
BackyardLightSwitcher
ไม่สามารถใช้วิธีActuateLights(bool motionDetected)
เพื่อสลับแสงอื่นนอกเหนือจากแสงในสนามหลังบ้านมันละเมิดหลักการความรับผิดชอบเดียว API มีเหตุผลสองประการที่ต้องเปลี่ยนแปลง: ประการแรก การเปลี่ยนแปลงตรรกะภายใน (เช่น การเลือกให้เปิดไฟเฉพาะในเวลากลางคืน แต่ไม่ใช่ในตอนเย็น) และประการที่สอง หากกลไกการสลับแสงถูกแทนที่ด้วยกลไกอื่น
มันโกหกเกี่ยวกับการพึ่งพา ไม่มีทางที่นักพัฒนาจะทราบได้ว่า
SmartHomeController
นั้นอาศัยส่วนประกอบBackyardLightSwitcher
ที่ฮาร์ดโค้ดไว้ นอกเหนือไปจากการขุดลงในซอร์สโค้ดยากที่จะเข้าใจและรักษาไว้ เกิดอะไรขึ้นถ้าไฟปฏิเสธที่จะเปิดเมื่อเงื่อนไขเหมาะสม? เราอาจใช้เวลามากมายในการแก้ไข
SmartHomeController
อย่างไร้ประโยชน์ เพียงเพื่อจะตระหนักว่าปัญหาเกิดจากข้อบกพร่องในBackyardLightSwitcher
(หรือที่ตลกกว่านั้นก็คือ หลอดไฟที่ไฟดับ!)
วิธีแก้ปัญหาของทั้งความสามารถในการทดสอบและปัญหา API ที่มีคุณภาพต่ำนั้น ไม่น่าแปลกใจเลยที่จะแยกส่วนประกอบที่ประกอบเข้าด้วยกันอย่างแน่นหนาออกจากกัน เช่นเดียวกับตัวอย่างก่อนหน้านี้ การใช้ Dependency Injection สามารถแก้ปัญหาเหล่านี้ได้ เพียงเพิ่มการพึ่งพา ILightSwitcher
ให้กับ SmartHomeController
มอบหมายให้รับผิดชอบในการพลิกสวิตช์ไฟ และส่งการใช้งาน ILightSwitcher
ปลอมสำหรับทดสอบเท่านั้น ซึ่งจะบันทึกว่ามีการเรียกวิธีการที่เหมาะสมภายใต้เงื่อนไขที่ถูกต้องหรือไม่ อย่างไรก็ตาม แทนที่จะใช้ Dependency Injection อีกครั้ง เรามาทบทวนแนวทางทางเลือกที่น่าสนใจสำหรับการแยกความรับผิดชอบ
การแก้ไข API: Higher-Order Functions
วิธีการนี้เป็นตัวเลือกในภาษาเชิงวัตถุที่สนับสนุน ฟังก์ชันระดับเฟิร์สคลาส มาใช้ประโยชน์จากคุณลักษณะการทำงานของ C# และทำให้ ActuateLights(bool motionDetected)
ยอมรับอาร์กิวเมนต์อีกสองข้อ: ผู้รับมอบสิทธิ์ Action
หนึ่งคู่ ซึ่งชี้ไปที่เมธอดที่ควรเรียกเพื่อเปิดและปิดไฟ โซลูชันนี้จะแปลงวิธีการเป็น ฟังก์ชันลำดับที่สูงกว่า :
public void ActuateLights(bool motionDetected, Action turnOn, Action turnOff) { DateTime time = _dateTimeProvider.GetDateTime(); // Update the time of last motion. if (motionDetected) { LastMotionTime = time; } // If motion was detected in the evening or at night, turn the light on. string timeOfDay = GetTimeOfDay(time); if (motionDetected && (timeOfDay == "Evening" || timeOfDay == "Night")) { turnOn(); // Invoking a delegate: no tight coupling anymore } // If no motion is detected for one minute, or if it is morning or day, turn the light off. else if (time.Subtract(LastMotionTime) > TimeSpan.FromMinutes(1) || (timeOfDay == "Morning" || timeOfDay == "Noon")) { turnOff(); // Invoking a delegate: no tight coupling anymore } }
นี่เป็นโซลูชันที่มีฟังก์ชันการทำงานมากกว่าวิธีการ Dependency Injection แบบออบเจ็กต์แบบคลาสสิกที่เราเคยเห็นมาก่อน อย่างไรก็ตาม มันช่วยให้เราได้ผลลัพธ์แบบเดียวกันโดยใช้โค้ดน้อยกว่าและมีความหมายมากกว่า Dependency Injection ไม่จำเป็นต้องใช้คลาสที่สอดคล้องกับอินเทอร์เฟซอีกต่อไป เพื่อให้ SmartHomeController
มีฟังก์ชันการทำงานที่จำเป็น แทน เราสามารถผ่านการกำหนดฟังก์ชัน ฟังก์ชันที่มีลำดับสูงกว่าถือเป็นอีกวิธีหนึ่งในการนำ Inversion of Control ไปใช้
ในตอนนี้ เพื่อทำการทดสอบหน่วยตามการโต้ตอบของวิธีการที่ได้ผลลัพธ์ เราสามารถผ่านการกระทำปลอมที่ตรวจสอบยืนยันได้อย่างง่ายดายเข้าไป:
[TestMethod] public void ActuateLights_MotionDetectedAtNight_TurnsOnTheLight() { // Arrange: create a pair of actions that change boolean variable instead of really turning the light on or off. bool turnedOn = false; Action turnOn = () => turnedOn = true; Action turnOff = () => turnedOn = false; var controller = new SmartHomeController(new FakeDateTimeProvider(new DateTime(2015, 12, 31, 23, 59, 59))); // Act controller.ActuateLights(true, turnOn, turnOff); // Assert Assert.IsTrue(turnedOn); }
สุดท้าย เราได้ทำให้ SmartHomeController
API สามารถทดสอบได้อย่างสมบูรณ์ และเราสามารถทำการทดสอบทั้งหน่วยตามสถานะและแบบโต้ตอบได้ ขอย้ำอีกครั้งว่านอกจากความสามารถในการทดสอบที่ได้รับการปรับปรุงแล้ว การแนะนำรอยต่อระหว่างรหัสการตัดสินใจและการดำเนินการช่วยแก้ปัญหาการมีเพศสัมพันธ์ที่แน่นแฟ้น และนำไปสู่ API ที่สะอาดขึ้นและใช้ซ้ำได้
ตอนนี้ เพื่อให้ครอบคลุมการทดสอบหน่วยแบบเต็ม เราสามารถใช้การทดสอบที่มีลักษณะคล้ายคลึงกันเพื่อตรวจสอบกรณีที่เป็นไปได้ทั้งหมด ไม่ใช่เรื่องใหญ่เพราะตอนนี้การทดสอบหน่วยทำได้ง่ายมาก
สิ่งเจือปนและการทดสอบได้
การไม่กำหนดระดับและผลข้างเคียงที่ไม่สามารถควบคุมได้นั้นคล้ายคลึงกันในผลการทำลายล้างบน codebase เมื่อใช้อย่างไม่ระมัดระวัง จะนำไปสู่การหลอกลวง เข้าใจและบำรุงรักษายาก โค้ดที่เชื่อมโยงกันแน่นหนา ใช้ซ้ำไม่ได้ และทดสอบไม่ได้
ในทางกลับกัน วิธีการที่ถูกกำหนด และ ไม่มีผลข้างเคียงนั้นง่ายต่อการทดสอบ ให้เหตุผล และนำมาใช้ซ้ำเพื่อสร้างโปรแกรมขนาดใหญ่ขึ้น ในแง่ของการเขียนโปรแกรมเชิงฟังก์ชัน วิธีการดังกล่าวเรียกว่า ฟังก์ชันบริสุทธิ์ เราจะไม่ค่อยมีปัญหาหน่วยทดสอบฟังก์ชันบริสุทธิ์ สิ่งที่เราต้องทำคือส่งข้อโต้แย้งและตรวจสอบผลลัพธ์เพื่อความถูกต้อง สิ่งที่ทำให้โค้ดไม่สามารถทดสอบได้จริงๆ คือฮาร์ดโค้ด ปัจจัยที่ไม่บริสุทธิ์ซึ่งไม่สามารถแทนที่ แทนที่ หรือแยกออกเป็นอย่างอื่นได้
สิ่งเจือปนเป็นพิษ: หากวิธี Foo()
ขึ้นอยู่กับวิธีการที่ไม่กำหนดหรือให้ผลข้างเคียง Bar()
ดังนั้น Foo()
จะกลายเป็นไม่กำหนดหรือมีผลข้างเคียงเช่นกัน ในที่สุด เราอาจลงเอยด้วยการวางยาพิษให้กับ codebase ทั้งหมด ทวีคูณปัญหาเหล่านี้ด้วยขนาดของแอปพลิเคชันในชีวิตจริงที่ซับซ้อน แล้วเราจะพบว่าตัวเองต้องคอยดูแล codebase ที่มีกลิ่นเหม็น รูปแบบการต่อต้าน การพึ่งพาที่เป็นความลับ และสิ่งน่าเกลียดและไม่น่าพอใจทุกประเภท
อย่างไรก็ตาม สิ่งเจือปนเป็นสิ่งที่หลีกเลี่ยงไม่ได้ ในบางจุด แอปพลิเคชันในชีวิตจริงต้องอ่านและจัดการสถานะโดยการโต้ตอบกับสภาพแวดล้อม ฐานข้อมูล ไฟล์การกำหนดค่า บริการเว็บ หรือระบบภายนอกอื่นๆ ดังนั้น แทนที่จะมุ่งหมายที่จะขจัดสิ่งเจือปนโดยสิ้นเชิง จึงเป็นความคิดที่ดีที่จะจำกัดปัจจัยเหล่านี้ หลีกเลี่ยงการปล่อยให้มันเป็นพิษกับ codebase ของคุณ และทำลายการขึ้นต่อกันแบบตายตัวให้ได้มากที่สุด เพื่อให้สามารถวิเคราะห์และทดสอบหน่วยต่างๆ ได้อย่างอิสระ
สัญญาณเตือนทั่วไปของรหัสที่ยากต่อการทดสอบ
สุดท้าย เรามาทบทวนสัญญาณเตือนทั่วไปที่ระบุว่าโค้ดของเราอาจทดสอบได้ยาก
คุณสมบัติและฟิลด์คงที่
คุณสมบัติและฟิลด์สแตติกหรือพูดง่ายๆ ก็คือ สถานะส่วนกลาง สามารถทำให้ความเข้าใจโค้ดและการทดสอบซับซ้อนขึ้นได้ โดยการซ่อนข้อมูลที่จำเป็นสำหรับวิธีการทำงานให้สำเร็จ โดยการแนะนำการไม่กำหนดขึ้น หรือโดยการส่งเสริมการใช้ผลข้างเคียงอย่างกว้างขวาง ฟังก์ชันที่อ่านหรือแก้ไขสถานะโกลบอลที่ไม่แน่นอนนั้นไม่บริสุทธิ์โดยเนื้อแท้
ตัวอย่างเช่น เป็นการยากที่จะให้เหตุผลเกี่ยวกับโค้ดต่อไปนี้ ซึ่งขึ้นอยู่กับคุณสมบัติที่เข้าถึงได้ทั่วโลก:
if (!SmartHomeSettings.CostSavingEnabled) { _swimmingPoolController.HeatWater(); }
จะเกิดอะไรขึ้นถ้า HeatWater()
ไม่ถูกเรียกเมื่อเราแน่ใจว่าควรเป็นเช่นนั้น เนื่องจากส่วนใดส่วนหนึ่งของแอปพลิเคชันอาจเปลี่ยนค่า CostSavingEnabled
เราจึงต้องค้นหาและวิเคราะห์สถานที่ทั้งหมดที่ปรับเปลี่ยนค่านั้นเพื่อค้นหาว่ามีอะไรผิดปกติ ตามที่เราได้เห็นแล้ว เป็นไปไม่ได้ที่จะตั้งค่าคุณสมบัติสแตติกบางอย่างเพื่อการทดสอบ (เช่น DateTime.Now
หรือ Environment.MachineName
เป็นแบบอ่านอย่างเดียว แต่ยังไม่สามารถกำหนดได้)
ในทางกลับกัน สถานะโลกที่ไม่เปลี่ยนรูป และ กำหนดได้นั้นถือว่าใช้ได้ทั้งหมด อันที่จริง มีชื่อที่คุ้นเคยมากกว่าสำหรับสิ่งนี้ — ค่าคงที่ ค่าคงที่ เช่น Math.PI
ไม่ได้ทำให้เกิดการไม่กำหนดขึ้น และเนื่องจากค่าเหล่านี้ไม่สามารถเปลี่ยนแปลงได้ จึงไม่อนุญาตให้มีผลข้างเคียง:
double Circumference(double radius) { return 2 * Math.PI * radius; } // Still a pure function!
Singletons
โดยพื้นฐานแล้ว รูปแบบซิงเกิลตันเป็นเพียงอีกรูปแบบหนึ่งของรัฐทั่วโลก Singletons ส่งเสริม API ที่คลุมเครือซึ่งเกี่ยวข้องกับการพึ่งพาจริงและแนะนำการมีเพศสัมพันธ์ที่แน่นหนาโดยไม่จำเป็นระหว่างส่วนประกอบต่างๆ พวกเขายังละเมิดหลักการความรับผิดชอบเดียวเพราะนอกเหนือจากหน้าที่หลักแล้ว พวกเขาควบคุมการเริ่มต้นและวงจรชีวิตของตนเอง
Singletons สามารถสร้างหน่วยการทดสอบตามคำสั่งได้อย่างง่ายดาย เนื่องจากมีสถานะตลอดอายุการใช้งานของแอปพลิเคชันทั้งหมดหรือชุดทดสอบหน่วย ดูตัวอย่างต่อไปนี้:
User GetUser(int userId) { User user; if (UserCache.Instance.ContainsKey(userId)) { user = UserCache.Instance[userId]; } else { user = _userService.LoadUser(userId); UserCache.Instance[userId] = user; } return user; }
In the example above, if a test for the cache-hit scenario runs first, it will add a new user to the cache, so a subsequent test of the cache-miss scenario may fail because it assumes that the cache is empty. To overcome this, we'll have to write additional teardown code to clean the UserCache
after each unit test run.
Using Singletons is a bad practice that can (and should) be avoided in most cases; however, it is important to distinguish between Singleton as a design pattern, and a single instance of an object. In the latter case, the responsibility of creating and maintaining a single instance lies with the application itself. Typically, this is handed with a factory or Dependency Injection container, which creates a single instance somewhere near the “top” of the application (ie, closer to an application entry point) and then passes it to every object that needs it. This approach is absolutely correct, from both testability and API quality perspectives.
The new
Operator
Newing up an instance of an object in order to get some job done introduces the same problem as the Singleton anti-pattern: unclear APIs with hidden dependencies, tight coupling, and poor testability.
For example, in order to test whether the following loop stops when a 404 status code is returned, the developer should set up a test web server:
using (var client = new HttpClient()) { HttpResponseMessage response; do { response = await client.GetAsync(uri); // Process the response and update the uri... } while (response.StatusCode != HttpStatusCode.NotFound); }
However, sometimes new
is absolutely harmless: for example, it is OK to create simple entity objects:
var person = new Person("John", "Doe", new DateTime(1970, 12, 31));
It is also OK to create a small, temporary object that does not produce any side effects, except to modify their own state, and then return the result based on that state. In the following example, we don't care whether Stack
methods were called or not — we just check if the end result is correct:
string ReverseString(string input) { // No need to do interaction-based testing and check that Stack methods were called or not; // The unit test just needs to ensure that the return value is correct (state-based testing). var stack = new Stack<char>(); foreach(var s in input) { stack.Push(s); } string result = string.Empty; while(stack.Count != 0) { result += stack.Pop(); } return result; }
Static Methods
Static methods are another potential source of non-deterministic or side-effecting behavior. They can easily introduce tight coupling and make our code untestable.
For example, to verify the behavior of the following method, unit tests must manipulate environment variables and read the console output stream to ensure that the appropriate data was printed:
void CheckPathEnvironmentVariable() { if (Environment.GetEnvironmentVariable("PATH") != null) { Console.WriteLine("PATH environment variable exists."); } else { Console.WriteLine("PATH environment variable is not defined."); } }
However, pure static functions are OK: any combination of them will still be a pure function. ตัวอย่างเช่น:
double Hypotenuse(double side1, double side2) { return Math.Sqrt(Math.Pow(side1, 2) + Math.Pow(side2, 2)); }
Benefits of Unit Testing
Obviously, writing testable code requires some discipline, concentration, and extra effort. But software development is a complex mental activity anyway, and we should always be careful, and avoid recklessly throwing together new code from the top of our heads.
As a reward for this act of proper software quality assurance, we'll end up with clean, easy-to-maintain, loosely coupled, and reusable APIs, that won't damage developers' brains when they try to understand it. After all, the ultimate advantage of testable code is not only the testability itself, but the ability to easily understand, maintain and extend that code as well.