การทดสอบทำได้ง่ายขึ้นผ่าน Framework Minimalism และสถาปัตยกรรมซอฟต์แวร์

เผยแพร่แล้ว: 2022-03-10
สรุปอย่างรวดเร็ว ↬ เช่นเดียวกับหัวข้ออื่นๆ ในการพัฒนาซอฟต์แวร์ การทดสอบและการพัฒนาที่ขับเคลื่อนด้วยการทดสอบมักจะซับซ้อนโดยไม่จำเป็นในทฤษฎีและการใช้งาน โดยเน้นมากเกินไปในการเรียนรู้กรอบการทดสอบที่หลากหลาย ในบทความนี้ เราจะทบทวนความหมายของการทดสอบโดยการเปรียบเทียบง่ายๆ สำรวจแนวคิดในสถาปัตยกรรมซอฟต์แวร์ซึ่งจะส่งผลโดยตรงต่อความต้องการเฟรมเวิร์กการทดสอบที่ลดลง และข้อโต้แย้งบางประการเกี่ยวกับสาเหตุที่คุณอาจได้รับประโยชน์จากทัศนคติของความเรียบง่ายสำหรับกระบวนการทดสอบของคุณ .

เช่นเดียวกับนักพัฒนา Android คนอื่นๆ การจู่โจมครั้งแรกของฉันในการทดสอบบนแพลตฟอร์มทำให้ฉันต้องเผชิญกับศัพท์แสงที่ทำให้เสียขวัญในทันที นอกจากนี้ ตัวอย่างบางส่วนที่ฉันพบในขณะนั้น (ประมาณปี 2015) ไม่ได้นำเสนอกรณีการใช้งานจริง ซึ่งอาจทำให้ฉันคิดว่าอัตราส่วนต้นทุนต่อผลประโยชน์ของการเรียนรู้เครื่องมืออย่าง Espresso เพื่อตรวจสอบว่า TextView.setText( …) ทำงานอย่างถูกต้อง เป็นการลงทุนที่สมเหตุสมผล

ที่แย่ไปกว่านั้นคือ ฉันไม่ได้เข้าใจการทำงานเกี่ยวกับสถาปัตยกรรมซอฟต์แวร์ในทางทฤษฎีหรือทางปฏิบัติ ซึ่งหมายความว่าถึงแม้ฉันจะสนใจที่จะเรียนรู้กรอบงานเหล่านี้ ฉันก็คงจะกำลังเขียนการทดสอบสำหรับการใช้งาน แบบเสาหิน ที่ประกอบด้วยคลาส god สองสามวิชาเป็นลายลักษณ์อักษร ใน รหัสสปาเก็ตตี้ ประเด็นสำคัญคือการสร้าง ทดสอบ และบำรุงรักษาแอปพลิเคชันดังกล่าวเป็นการฝึกทำลายตนเองโดยไม่คำนึงถึงความเชี่ยวชาญด้านกรอบงานของคุณ ทว่าการตระหนักรู้นี้จะชัดเจนก็ต่อเมื่อมีคนสร้างแอปพลิเคชั่น แบบแยกส่วน ประกอบแบบหลวม ๆ และ มีความเหนียวแน่น สูง

จากที่นี่ เรามาถึงหนึ่งในประเด็นหลักของการอภิปรายในบทความนี้ ซึ่งฉันจะสรุปเป็นภาษาง่ายๆ ที่นี่ ท่ามกลางประโยชน์หลักของการใช้หลักการทองของสถาปัตยกรรมซอฟต์แวร์ (ไม่ต้องกังวล ฉันจะพูดถึงพวกเขาด้วยตัวอย่างง่ายๆ และ ภาษา) คือรหัสของคุณสามารถทดสอบได้ง่ายขึ้น มีประโยชน์อื่น ๆ ในการใช้หลักการดังกล่าว แต่ความสัมพันธ์ระหว่างสถาปัตยกรรมซอฟต์แวร์และการทดสอบเป็นจุดสนใจของบทความนี้

อย่างไรก็ตาม เพื่อเห็นแก่ผู้ที่ต้องการทำความเข้าใจว่าทำไมและวิธีที่เราทดสอบโค้ดของเรา ก่อนอื่นเราจะสำรวจแนวคิดของการทดสอบโดยการเปรียบเทียบ โดยที่คุณไม่ต้องจำศัพท์แสงใดๆ ก่อนที่จะเจาะลึกในหัวข้อหลัก เราจะพิจารณาถึงคำถามว่าเหตุใดจึงมีกรอบการทดสอบจำนวนมาก เนื่องจากในการตรวจสอบนี้ เราอาจเริ่มเห็นข้อดี ข้อจำกัด และบางทีอาจถึงกับมีทางเลือกอื่น

เพิ่มเติมหลังกระโดด! อ่านต่อด้านล่าง↓

การทดสอบ: ทำไม และอย่างไร

ส่วนนี้จะไม่ใช่ข้อมูลใหม่สำหรับผู้ทดสอบที่ช่ำชอง แต่บางทีคุณอาจชอบการเปรียบเทียบนี้ แน่นอน ฉันเป็นวิศวกรซอฟต์แวร์ ไม่ใช่วิศวกรจรวด แต่สักครู่หนึ่ง ฉันจะขอยืมการเปรียบเทียบซึ่งเกี่ยวข้องกับการออกแบบและการสร้างวัตถุทั้งในทางกายภาพและในพื้นที่หน่วยความจำของคอมพิวเตอร์ ปรากฎว่าในขณะที่สื่อเปลี่ยนไป กระบวนการในหลักการค่อนข้างเหมือนกัน

สมมุติว่าเราเป็นวิศวกรจรวด หน้าที่ของเราคือสร้างตัวเสริมจรวดระยะแรก* ของกระสวยอวกาศ สมมุติว่าเราได้ออกแบบที่ใช้งานได้สำหรับขั้นตอนแรกเพื่อเริ่มสร้างและทดสอบในสภาวะต่างๆ

“ระยะแรก” หมายถึง บูสเตอร์ที่ยิงเมื่อปล่อยจรวดครั้งแรก

ก่อนที่เราจะเข้าสู่กระบวนการนี้ ฉันต้องการจะชี้ให้เห็นว่าเหตุใดฉันจึงชอบการเปรียบเทียบนี้: คุณไม่ควรมีปัญหาใดๆ ในการตอบคำถาม ว่าเหตุใด เราจึงต้องการทดสอบการออกแบบของเราก่อนที่จะนำไปใช้ในสถานการณ์ที่ชีวิตมนุษย์มีความเสี่ยง แม้ว่าฉันจะไม่พยายามเกลี้ยกล่อมคุณว่าการทดสอบแอปพลิเคชันของคุณก่อน การเปิดตัว สามารถช่วยชีวิตคนได้ (แม้ว่าจะเป็นไปได้ขึ้นอยู่กับลักษณะของแอปพลิเคชัน) แต่ก็สามารถช่วยประหยัดการให้คะแนน บทวิจารณ์ และงานของคุณได้ ในความหมายที่กว้างที่สุด การทดสอบเป็นวิธีที่เราตรวจสอบให้แน่ใจว่าชิ้นส่วนเดียว ส่วนประกอบหลายส่วน และทั้งระบบทำงานก่อนที่เราจะใช้งานในสถานการณ์ที่มีความสำคัญอย่างยิ่งต่อพวกเขาที่จะไม่ล้มเหลว

กลับมาที่ลักษณะการเปรียบเทียบนี้ ฉันจะแนะนำกระบวนการที่วิศวกรทำการทดสอบการออกแบบเฉพาะ: ความซ้ำซ้อน โดยหลักการแล้วความซ้ำซ้อนนั้นเรียบง่าย: สร้างสำเนาของส่วนประกอบที่จะทดสอบตามข้อกำหนดการออกแบบเดียวกันกับสิ่งที่คุณต้องการใช้เมื่อเปิดตัว ทดสอบสำเนาเหล่านี้ในสภาพแวดล้อมที่แยกออกมาซึ่งควบคุมเงื่อนไขเบื้องต้นและตัวแปรอย่างเคร่งครัด แม้ว่าสิ่งนี้ไม่ได้รับประกันว่าตัว เร่งความเร็วจรวด จะทำงานอย่างถูกต้องเมื่อรวมเข้ากับกระสวยทั้งหมด แต่ก็สามารถมั่นใจได้ว่าหากไม่ทำงานในสภาพแวดล้อมที่มีการควบคุม มันจะไม่ทำงานเลย

สมมุติว่าจากตัวแปรหลายร้อยหรืออาจเป็นหลายพันตัวที่ สำเนา ของการออกแบบจรวดได้รับการทดสอบกับมัน มันลงมาที่อุณหภูมิแวดล้อมซึ่งตัว เร่งความเร็วจรวด จะถูกทดสอบการยิง เมื่อทำการทดสอบที่อุณหภูมิ 35 องศาเซลเซียส เราจะพบว่าทุกอย่างทำงานได้โดยไม่มีข้อผิดพลาด อีกครั้ง จรวดได้รับการทดสอบที่อุณหภูมิห้องโดยประมาณโดยไม่เกิดข้อผิดพลาด การทดสอบขั้นสุดท้ายจะอยู่ที่อุณหภูมิต่ำสุดที่บันทึกไว้สำหรับจุดปล่อยตัว ที่ -5 องศาเซลเซียส ในระหว่างการทดสอบขั้นสุดท้ายนี้ จรวดจะยิง แต่หลังจากช่วงเวลาสั้นๆ จรวดจะลุกเป็นไฟและหลังจากนั้นไม่นานก็ระเบิดอย่างรุนแรง แต่โชคดีในสภาพแวดล้อมที่มีการควบคุมและปลอดภัย

ณ จุดนี้ เรารู้ว่าการเปลี่ยนแปลงของอุณหภูมิดูเหมือนจะเกี่ยวข้องกับการทดสอบที่ล้มเหลวเป็นอย่างน้อย ซึ่งทำให้เราต้องพิจารณาว่าส่วนใดของตัว เร่งความเร็วจรวด อาจได้รับผลกระทบในทางลบจากอุณหภูมิที่เย็นจัด เมื่อเวลาผ่านไป พบว่าส่วนประกอบสำคัญอย่างหนึ่ง ซึ่งเป็นยาง โอ ริงซึ่งทำหน้าที่ควบคุมการไหลของน้ำมันเชื้อเพลิงจากช่องหนึ่งไปยังอีกช่องหนึ่ง จะแข็งตัวและไม่มีประสิทธิภาพเมื่อสัมผัสกับอุณหภูมิที่ใกล้หรือต่ำกว่าจุดเยือกแข็ง

เป็นไปได้ว่าคุณสังเกตเห็นว่าการเปรียบเทียบของเขามีพื้นฐานมาจากเหตุการณ์โศกนาฏกรรมของภัยพิบัติกระสวยอวกาศ ชาเลนเจอร์ สำหรับผู้ที่ไม่คุ้นเคย ความจริงที่น่าเศร้า (ตราบเท่าที่การสอบสวนสรุปได้) ก็คือมีการทดสอบและคำเตือนที่ล้มเหลวมากมายจากวิศวกร แต่ปัญหาด้านการบริหารและการเมืองยังกระตุ้นให้ดำเนินการต่อไปโดยไม่คำนึงถึง ไม่ว่าในกรณีใด ไม่ว่าคุณจะจำคำว่า ความซ้ำซ้อน ได้หรือไม่ ฉันหวังว่าคุณจะได้เข้าใจกระบวนการพื้นฐานสำหรับการทดสอบส่วนต่างๆ ของระบบทุกประเภท

เกี่ยวกับซอฟต์แวร์

ในขณะที่การเปรียบเทียบก่อนหน้านี้ได้อธิบายกระบวนการพื้นฐานสำหรับการทดสอบจรวด (ในขณะที่ใช้เสรีภาพมากมายกับรายละเอียดปลีกย่อย) ตอนนี้ผมจะสรุปในลักษณะที่น่าจะเกี่ยวข้องกับคุณและฉันมากกว่า แม้ว่าจะทดสอบซอฟต์แวร์ได้โดยการเปิดตัวเท่านั้น กับอุปกรณ์เมื่ออยู่ในสถานะปรับใช้ได้ทุกประเภท ฉันคิดว่าเราสามารถนำหลักการของ ความซ้ำซ้อน ไปใช้กับแต่ละส่วนของแอปพลิเคชันก่อน

ซึ่งหมายความว่าเราสร้างสำเนาของส่วนเล็กๆ ของแอปพลิเคชันทั้งหมด (โดยทั่วไปเรียกว่า หน่วย ของซอฟต์แวร์) ตั้งค่าสภาพแวดล้อมการทดสอบแบบแยก และดูว่าพวกมันทำงานอย่างไรตามตัวแปร อาร์กิวเมนต์ เหตุการณ์ และการตอบสนองที่อาจเกิดขึ้น ที่รันไทม์ การทดสอบนั้นง่ายพอๆ กับในทางทฤษฎี แต่สิ่งสำคัญในการเข้าสู่กระบวนการนี้ก็คือการสร้างแอปพลิเคชันที่สามารถทดสอบได้ นี่เป็นข้อกังวลสองข้อที่เราจะพิจารณาในสองส่วนถัดไป ข้อกังวลแรกเกี่ยวข้องกับ สภาพแวดล้อมการทดสอบ และข้อกังวลที่สองเกี่ยวข้องกับวิธีที่เราจัดโครงสร้างแอปพลิเคชัน

ทำไมเราต้องมีกรอบงาน?

ในการทดสอบซอฟต์แวร์ (ต่อจากนี้ไปจะเรียกว่า Unit แม้ว่าคำจำกัดความนี้จงใจทำให้เข้าใจง่ายเกินไป) จำเป็นต้องมีสภาพแวดล้อมการทดสอบบางประเภทที่อนุญาตให้คุณโต้ตอบกับซอฟต์แวร์ของคุณได้ในขณะใช้งานจริง สำหรับการสร้างแอปพลิเคชันที่จะดำเนินการบนสภาพแวดล้อม JVM ( Java Virtual Machine ) ทั้งหมด ทั้งหมดที่จำเป็นในการเขียนการทดสอบคือ JRE ( Java Runtime Environment ) ยกตัวอย่างคลาส เครื่องคิดเลข แสนง่ายนี้:

 class Calculator { private int add(int a, int b){ return a + b; } private int subtract(int a, int b){ return a - b; } }

ในกรณีที่ไม่มีเฟรมเวิร์กใดๆ ตราบใดที่เรามีคลาสทดสอบซึ่งมีฟังก์ชัน main ในการรันโค้ดของเราจริง เราก็สามารถทดสอบได้ อย่างที่คุณอาจจำได้ ฟังก์ชัน main แสดงถึงจุดเริ่มต้นของการดำเนินการสำหรับโปรแกรม Java อย่างง่าย สำหรับสิ่งที่เรากำลังทดสอบ เราเพียงแค่ป้อนข้อมูลทดสอบบางส่วนลงในฟังก์ชันของเครื่องคิดเลข และตรวจสอบว่าข้อมูลนั้นดำเนินการคำนวณพื้นฐานอย่างถูกต้องหรือไม่:

 public class Main { public static void main(String[] args){ //create a copy of the Unit to be tested Calculator calc = new Calculator(); //create test conditions to verify behaviour int addTest = calc.add(2, 2); int subtractTest = calc.subtract(2, 2); //verify behaviour by assertion if (addTest == 4) System.out.println("addTest has passed."); else System.out.println("addTest has failed."); if (subtractTest == 0) System.out.println("subtractTest has passed."); else System.out.println("subtractTest has failed."); } }

การทดสอบแอปพลิเคชัน Android เป็นขั้นตอนที่แตกต่างไปจากเดิมอย่างสิ้นเชิง แม้ว่าจะมีฟังก์ชั่น main ฝังอยู่ลึกภายในแหล่งที่มาของไฟล์ ZygoteInit.java (รายละเอียดปลีกย่อยซึ่งไม่สำคัญที่นี่) ซึ่งถูกเรียกใช้ก่อนที่จะเปิดตัวแอปพลิเคชัน Android บน JVM แม้แต่นักพัฒนา Android รุ่นเยาว์ก็ควร รู้ว่าระบบมีหน้าที่เรียกใช้ฟังก์ชันนี้ ไม่ใช่นักพัฒนา แต่จุดเริ่มต้นสำหรับแอปพลิเคชัน Android จะเป็นคลาส Application และคลาส Activity ใดๆ ที่ระบบสามารถชี้ไปผ่านไฟล์ AndroidManifest.xml

ทั้งหมดนี้เป็นเพียงการนำไปสู่ความจริงที่ว่า หน่วย ทดสอบในแอปพลิเคชัน Android นำเสนอระดับความซับซ้อนที่มากขึ้น เนื่องจากตอนนี้สภาพแวดล้อมการทดสอบของเราต้องพิจารณาสำหรับแพลตฟอร์ม Android

ฝึกฝนปัญหาการมีเพศสัมพันธ์แน่น

การเชื่อมต่อแบบแน่นหนา เป็นคำที่อธิบายฟังก์ชัน คลาส หรือโมดูลแอปพลิเคชันซึ่ง ขึ้นอยู่ กับแพลตฟอร์ม เฟรมเวิร์ก ภาษา และไลบรารีเฉพาะ เป็นคำที่สัมพันธ์กัน หมายความว่าตัวอย่าง Calculator.java ของเราเชื่อมโยงกับภาษาการเขียนโปรแกรม Java และไลบรารีมาตรฐานอย่างแน่นหนา แต่นั่นคือขอบเขตของการมีเพศสัมพันธ์ ในทำนองเดียวกัน ปัญหา ของคลาสการทดสอบที่เชื่อมต่อกับแพลตฟอร์ม Android อย่างแน่นหนา คือคุณต้องหาวิธีที่จะทำงานร่วมกับหรือรอบ ๆ แพลตฟอร์ม

สำหรับชั้นเรียนที่เชื่อมต่อกับแพลตฟอร์ม Android อย่างแน่นหนา คุณมีสองตัวเลือก อย่างแรกคือการปรับใช้คลาสของคุณกับอุปกรณ์ Android (ทางกายภาพหรือเสมือน) แม้ว่าฉันจะแนะนำให้คุณทดสอบการปรับใช้โค้ดแอปพลิเคชันของคุณก่อนที่จะส่งไปยังเวอร์ชันที่ใช้งานจริง แต่วิธีนี้เป็นวิธีที่ไม่มีประสิทธิภาพอย่างสูงในช่วงเริ่มต้นและระยะกลางของกระบวนการพัฒนาที่สัมพันธ์กับเวลา

อย่างไรก็ตาม หน่วย ไม่ว่าคุณจะชอบคำจำกัดความทางเทคนิคอย่างไร โดยทั่วไปถือว่าเป็นฟังก์ชันเดียวในคลาส (แม้ว่าบางฟังก์ชันจะขยายคำจำกัดความเพื่อรวมฟังก์ชันตัวช่วยที่ตามมาซึ่งถูกเรียกเป็นการภายในโดยการเรียกใช้ฟังก์ชันเดี่ยวเริ่มต้น) ไม่ว่าจะด้วยวิธีใด หน่วย ก็ควรจะมีขนาดเล็ก การสร้าง รวบรวม และปรับใช้แอปพลิเคชันทั้งหมดเพื่อทดสอบ หน่วย เดียวคือการพลาดจุดทดสอบแบบแยกส่วน โดยสิ้นเชิง

อีกวิธีหนึ่งในการแก้ปัญหาการมีเพศสัมพันธ์อย่างแน่นหนา คือการใช้เฟรมเวิร์กการทดสอบเพื่อโต้ตอบหรือ จำลอง (จำลอง) การขึ้นต่อกันของแพลตฟอร์ม กรอบงานเช่น Espresso และ Robolectric ช่วย ให้นักพัฒนามีวิธีการทดสอบ หน่วย ที่มีประสิทธิภาพมากกว่าวิธีก่อนหน้า อดีตมีประโยชน์สำหรับการทดสอบที่ทำงานบนอุปกรณ์ (เรียกว่า "การทดสอบด้วยเครื่องมือ" เพราะเห็นได้ชัดว่าการเรียกการทดสอบอุปกรณ์นั้นไม่ชัดเจนเพียงพอ) และอย่างหลังมีความสามารถในการเยาะเย้ยกรอบงาน Android ในเครื่อง JVM

ก่อนที่ฉันจะดำเนินการต่อต้านเฟรมเวิร์กดังกล่าวแทนที่จะใช้ทางเลือกอื่น ฉันจะพูดคุยในเร็วๆ นี้ ฉันต้องการชัดเจนว่าฉันไม่ได้หมายความว่าคุณไม่ควรใช้ตัวเลือกเหล่านี้ กระบวนการที่นักพัฒนาซอฟต์แวร์ใช้ในการสร้างและทดสอบแอปพลิเคชันควรเกิดจากความชอบส่วนบุคคลและประสิทธิภาพร่วมกัน

สำหรับผู้ที่ไม่ชอบสร้างแอปพลิเคชันแบบแยกส่วนและแบบหลวมๆ คุณจะไม่มีทางเลือกอื่นนอกจากทำความคุ้นเคยกับเฟรมเวิร์กเหล่านี้ หากคุณต้องการให้ครอบคลุมการทดสอบในระดับที่เพียงพอ แอปพลิเคชั่นที่ยอดเยี่ยมมากมายถูกสร้างขึ้นด้วยวิธีนี้ และฉันไม่ได้ถูกกล่าวหาบ่อยครั้งว่าทำให้แอปพลิเคชันของฉันเป็นแบบโมดูลาร์และเป็นนามธรรมเกินไป ไม่ว่าคุณจะใช้แนวทางของฉันหรือตัดสินใจที่จะพึ่งพาเฟรมเวิร์กอย่างหนัก ฉันขอแสดงความยินดีกับคุณที่สละเวลาและความพยายามในการทดสอบแอปพลิเคชันของคุณ

รักษากรอบงานของคุณไว้ที่ความยาวแขน

สำหรับคำนำสุดท้ายของบทเรียนหลักของบทความนี้ ควรอภิปรายว่าเหตุใดคุณจึงอาจต้องการมีทัศนคติเกี่ยวกับความเรียบง่ายเมื่อใช้เฟรมเวิร์ก (และสิ่งนี้ใช้ได้กับมากกว่าการทดสอบเฟรมเวิร์ก) คำบรรยายด้านบนเป็นการถอดความจากครูผู้สอนที่มีใจกว้างด้านแนวทางปฏิบัติด้านซอฟต์แวร์: Robert “Uncle Bob” C. Martin ในบรรดาอัญมณีมากมายที่เขามอบให้ฉันตั้งแต่ฉันศึกษางานของเขาครั้งแรก อัญมณีชิ้นนี้ใช้เวลาหลายปีกว่าจะเข้าใจ

ตราบเท่าที่ฉันเข้าใจว่าคำกล่าวนี้เกี่ยวกับอะไร ค่าใช้จ่ายในการใช้กรอบงานอยู่ในการลงทุนเวลาที่จำเป็นในการเรียนรู้และบำรุงรักษา บางคนเปลี่ยนค่อนข้างบ่อยและบางคนไม่เปลี่ยนบ่อยพอ ฟังก์ชันต่างๆ เลิกใช้แล้ว เฟรมเวิร์กต่างๆ จะหยุดรักษา และทุกๆ 6-24 เดือน เฟรมเวิร์กใหม่จะมาแทนที่เฟรมเวิร์กสุดท้าย ดังนั้น หากคุณพบวิธีแก้ไขที่สามารถนำมาใช้เป็นแพลตฟอร์มหรือคุณลักษณะภาษาได้ (ซึ่งมักจะใช้งานได้นานกว่ามาก) ก็จะมีแนวโน้มที่จะต่อต้านการเปลี่ยนแปลงประเภทต่าง ๆ ที่กล่าวถึงข้างต้น

ในหมายเหตุทางเทคนิคที่มากขึ้น เฟรมเวิร์กเช่น Espresso และ Robolectric ในระดับที่น้อยกว่านั้นไม่สามารถทำงานได้อย่างมีประสิทธิภาพเท่ากับการทดสอบ JUnit แบบง่ายๆ หรือแม้แต่การทดสอบเฟรมเวิร์กแบบไม่มีตั้งแต่ก่อนหน้านี้ แม้ว่า JUnit เป็นเฟรมเวิร์กอย่างแท้จริง แต่ก็มีความเชื่อมโยงอย่างแน่นหนากับ JVM ซึ่งมีแนวโน้มที่จะเปลี่ยนแปลงในอัตราที่ช้ากว่าแพลตฟอร์ม Android ที่เหมาะสมมาก เฟรมเวิร์กที่น้อยลง แทบจะ สม่ำเสมอหมายถึงโค้ดที่มีประสิทธิภาพมากกว่าในแง่ของเวลาที่ใช้ในการดำเนินการและเขียนการทดสอบอย่างน้อยหนึ่งรายการ

จากนี้ คุณอาจสรุปได้ว่าตอนนี้เรากำลังพูดถึงแนวทางที่จะใช้ประโยชน์จากเทคนิคบางอย่างที่ช่วยให้เรารักษาแพลตฟอร์ม Android ไว้ได้เสมอ ในขณะเดียวกันก็ทำให้เราครอบคลุมโค้ดได้อย่างมากมาย ประสิทธิภาพการทดสอบ และโอกาสที่จะยังคงใช้กรอบงานที่นี่หรือที่นั่นเมื่อมีความจำเป็น

ศิลปะแห่งสถาปัตยกรรม

ในการใช้การเปรียบเทียบที่ไร้สาระ เราอาจคิดว่าเฟรมเวิร์กและแพลตฟอร์มเป็นเหมือนเพื่อนร่วมงานที่เอาแต่ใจที่จะเข้าควบคุมกระบวนการพัฒนาของคุณ เว้นแต่คุณจะกำหนดขอบเขตที่เหมาะสมกับพวกเขา หลักการทองของสถาปัตยกรรมซอฟต์แวร์สามารถให้แนวคิดทั่วไปและเทคนิคเฉพาะที่จำเป็นต่อการสร้างและบังคับใช้ขอบเขตเหล่านี้ ดังที่เราจะได้เห็นกันในอีกสักครู่ หากคุณเคยสงสัยว่าประโยชน์ของการใช้หลักการสถาปัตยกรรมซอฟต์แวร์ในโค้ดของคุณมีอะไรบ้าง บางส่วนโดยตรงและโดยอ้อมหลายๆ ประการทำให้โค้ดของคุณง่ายต่อการทดสอบ

การแยกความกังวล

Separation Of Concerns เกิดจากการประมาณค่าของฉันถึงแนวคิดที่เป็นประโยชน์และเป็นประโยชน์ในระดับสากลที่สุดในสถาปัตยกรรมซอฟต์แวร์โดยรวม การแยกข้อกังวล (SOC) สามารถใช้หรือเพิกเฉยได้ในทุกมุมมองของการพัฒนาซอฟต์แวร์ที่ฉันทราบ เพื่อสรุปแนวคิดโดยสังเขป เราจะพิจารณา SOC เมื่อนำไปใช้กับคลาส แต่โปรดทราบว่า SOC สามารถนำไปใช้กับฟังก์ชันต่างๆ ผ่านการใช้งาน ฟังก์ชันตัวช่วย อย่างกว้างขวาง และสามารถอนุมานไปยัง โมดูล ทั้งหมดของแอปพลิเคชัน ("โมดูล" ที่ใช้ใน บริบทของ Android/Gradle)

หากคุณใช้เวลามากในการค้นคว้ารูปแบบสถาปัตยกรรมซอฟต์แวร์สำหรับแอปพลิเคชัน GUI คุณจะพบอย่างน้อยหนึ่งใน: Model-View-Controller (MVC), Model-View-Presenter (MVP) หรือ Model-View- ดูโมเดล (MVVM) เมื่อสร้างแอปพลิเคชันในทุกรูปแบบ ฉันจะพูดล่วงหน้าว่าฉันไม่ถือว่าแอปพลิเคชันใดเป็นตัวเลือกเดียวที่ดีที่สุดสำหรับทุกโครงการ (หรือแม้แต่คุณลักษณะภายในโครงการเดียว) กระแทกแดกดันรูปแบบที่ทีม Android นำเสนอเมื่อหลายปีก่อนตามแนวทางที่แนะนำ MVVM ดูเหมือนจะทดสอบได้น้อยที่สุดหากไม่มีกรอบการทดสอบเฉพาะของ Android (สมมติว่าคุณต้องการใช้คลาส ViewModel ของแพลตฟอร์ม Android ซึ่งฉันเป็นแฟน ของ).

ไม่ว่าในกรณีใด ลักษณะเฉพาะของรูปแบบเหล่านี้มีความสำคัญน้อยกว่าลักษณะทั่วไป รูปแบบทั้งหมดเหล่านี้เป็นเพียงรสชาติที่แตกต่างกันของ SOC ซึ่งเน้นการแยกรหัสพื้นฐานสามประเภทที่ฉันเรียกว่า: Data , User Interface , Logic

ดังนั้นการแยก Data , User Interface และ Logic ช่วยให้คุณทดสอบแอปพลิเคชันของคุณได้อย่างไร? คำตอบคือโดยการดึงตรรกะออกจากคลาสที่ต้องจัดการกับการพึ่งพาแพลตฟอร์ม/เฟรมเวิร์กในคลาสที่มีการพึ่งพาแพลตฟอร์ม/เฟรมเวิร์กเพียงเล็กน้อยหรือไม่มีเลย การทดสอบจึงกลายเป็นเรื่องง่ายและ เฟรมเวิร์กขั้นต่ำ เพื่อความชัดเจน ฉันกำลังพูดถึงคลาสที่ต้องแสดงอินเทอร์เฟซผู้ใช้ เก็บข้อมูลในตาราง SQL หรือเชื่อมต่อกับเซิร์ฟเวอร์ระยะไกล เพื่อสาธิตวิธีการทำงาน ให้เราดูที่สถาปัตยกรรมสามเลเยอร์แบบง่ายของแอปพลิเคชัน Android ที่สมมติขึ้น

ชั้นหนึ่งจะจัดการส่วนต่อประสานผู้ใช้ของเรา เพื่อให้ง่ายขึ้น ฉันได้ใช้ กิจกรรม เพื่อจุดประสงค์นี้ แต่โดยปกติฉันเลือกใช้ Fragments แทนที่จะเป็นคลาสอินเทอร์เฟซผู้ใช้ ไม่ว่าในกรณีใด ทั้งสองคลาสมีการเชื่อมต่อแบบแน่นหนาที่คล้ายคลึงกันกับแพลตฟอร์ม Android :

 public class CalculatorUserInterface extends Activity implements CalculatorContract.IUserInterface { private TextView display; private CalculatorContract.IControlLogic controlLogic; private final String INVALID_MESSAGE = "Invalid Expression."; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); controlLogic = new DependencyProvider().provideControlLogic(this); display = findViewById(R.id.textViewDisplay); Button evaluate = findViewById(R.id.buttonEvaluate); evaluate.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View view) { controlLogic.handleInput('='); } }); //..bindings for the rest of the calculator buttons } @Override public void updateDisplay(String displayText) { display.setText(displayText); } @Override public String getDisplay() { return display.getText().toString(); } @Override public void showError() { Toast.makeText(this, INVALID_MESSAGE, Toast.LENGTH_LONG).show(); } }

อย่างที่คุณเห็น กิจกรรม มีสองงาน: อย่างแรก เนื่องจากเป็นจุดเริ่มต้นของคุณลักษณะที่กำหนดของแอปพลิเคชัน Android จึงทำหน้าที่เป็น คอนเทนเนอร์ สำหรับส่วนประกอบอื่นๆ ของคุณลักษณะ พูดง่ายๆ ก็คือ คอนเทนเนอร์ สามารถมองได้ว่าเป็นคลาสรูทที่คอมโพเนนต์อื่น ๆ ถูกโยงผ่านการอ้างอิง (หรือฟิลด์สมาชิกส่วนตัวในกรณีนี้) นอกจากนี้ยังขยาย ผูกข้อมูลอ้างอิง และเพิ่มผู้ฟังไปยังเลย์เอาต์ XML (ส่วนต่อประสานผู้ใช้)

ลอจิกควบคุมการทดสอบ

แทนที่จะให้ Activity มีการอ้างอิงถึงคลาสที่เป็นรูปธรรมในส่วนหลัง เราได้พูดคุยกับอินเทอร์เฟซของประเภท CalculatorContract.IControlLogic. เราจะหารือกันว่าทำไมนี่คืออินเทอร์เฟซในหัวข้อถัดไป สำหรับตอนนี้ แค่เข้าใจว่าอะไรก็ตามที่อยู่อีกด้านหนึ่งของอินเทอร์เฟซนั้นควรจะเป็นบางอย่างเช่น Presenter หรือ Controller เนื่องจากคลาสนี้จะควบคุมการโต้ตอบระหว่าง front-end Activity และ back-end Calculator ฉันจึงเลือกที่จะเรียกมันว่า CalculatorControlLogic :

 public class CalculatorControlLogic implements CalculatorContract.IControlLogic { private CalculatorContract.IUserInterface ui; private CalculatorContract.IComputationLogic comp; public CalculatorControlLogic(CalculatorContract.IUserInterface ui, CalculatorContract.IComputationLogic comp) { this.ui = ui; this.comp = comp; } @Override public void handleInput(char inputChar) { switch (inputChar){ case '=': evaluateExpression(); break; //...handle other input events } } private void evaluateExpression() { Optional result = comp.computeResult(ui.getDisplay()); if (result.isPresent()) ui.updateDisplay(result.get()); else ui.showError(); } } public class CalculatorControlLogic implements CalculatorContract.IControlLogic { private CalculatorContract.IUserInterface ui; private CalculatorContract.IComputationLogic comp; public CalculatorControlLogic(CalculatorContract.IUserInterface ui, CalculatorContract.IComputationLogic comp) { this.ui = ui; this.comp = comp; } @Override public void handleInput(char inputChar) { switch (inputChar){ case '=': evaluateExpression(); break; //...handle other input events } } private void evaluateExpression() { Optional result = comp.computeResult(ui.getDisplay()); if (result.isPresent()) ui.updateDisplay(result.get()); else ui.showError(); } }

มีหลายสิ่งที่ละเอียดอ่อนเกี่ยวกับวิธีการที่ชั้นเรียนนี้ได้รับการออกแบบเพื่อให้ง่ายต่อการทดสอบ ประการแรก การอ้างอิงทั้งหมดมาจากไลบรารีมาตรฐาน Java หรืออินเทอร์เฟซที่กำหนดไว้ภายในแอปพลิเคชัน ซึ่งหมายความว่าการทดสอบคลาสนี้โดยไม่มีเฟรมเวิร์กเป็นเรื่องง่าย และสามารถทำได้ในเครื่องบน JVM เคล็ดลับเล็กๆ น้อยๆ แต่มีประโยชน์อีกข้อหนึ่งคือ สามารถเรียกการโต้ตอบที่แตกต่างกันทั้งหมดของคลาสนี้ผ่าน handleInput(...) ทั่วไปเดียว นี่เป็น จุดเริ่มต้นเดียว เพื่อทดสอบทุกพฤติกรรมของคลาสนี้

โปรดทราบด้วยว่าในฟังก์ชัน evaluateExpression() ฉันกำลังส่งคืนคลาสประเภท Optional<String> จากส่วนหลัง โดยปกติฉันจะใช้สิ่งที่โปรแกรมเมอร์ที่ใช้งานได้เรียก ว่าทั้งสอง Monad หรือตามที่ฉันต้องการเรียกว่า Result Wrapper ไม่ว่าคุณจะใช้ชื่องี่เง่าอะไรก็ตาม มันเป็นอ็อบเจ็กต์ที่สามารถแสดงสถานะต่างๆ ได้หลากหลายผ่านการเรียกใช้ฟังก์ชันเพียงครั้งเดียว Optional คือโครงสร้างที่ง่ายกว่าซึ่งสามารถแทน ค่า null หรือค่าบางอย่างของประเภททั่วไปที่ให้มา ไม่ว่าในกรณีใด เนื่องจากแบ็กเอนด์อาจได้รับนิพจน์ที่ไม่ถูกต้อง เราจึงต้องการให้คลาส ControlLogic มีวิธีการบางอย่างในการพิจารณาผลลัพธ์ของการดำเนินการแบ็กเอนด์ บัญชีสำหรับความสำเร็จและความล้มเหลว ในกรณีนี้ ค่า null จะแสดงถึงความล้มเหลว

ด้านล่างนี้คือตัวอย่างคลาสทดสอบที่เขียนโดยใช้ JUnit และคลาสในการทดสอบศัพท์แสงเรียกว่า Fake :

 public class CalculatorControlLogicTest { @Test public void validExpressionTest() { CalculatorContract.IComputationLogic comp = new FakeComputationLogic(); CalculatorContract.IUserInterface ui = new FakeUserInterface(); CalculatorControlLogic controller = new CalculatorControlLogic(ui, comp); controller.handleInput('='); assertTrue(((FakeUserInterface) ui).displayUpdateCalled); assertTrue(((FakeUserInterface) ui).displayValueFinal.equals("10.0")); assertTrue(((FakeComputationLogic) comp).computeResultCalled); } @Test public void invalidExpressionTest() { CalculatorContract.IComputationLogic comp = new FakeComputationLogic(); ((FakeComputationLogic) comp).returnEmpty = true; CalculatorContract.IUserInterface ui = new FakeUserInterface(); ((FakeUserInterface) ui).displayValueInitial = "+7+7"; CalculatorControlLogic controller = new CalculatorControlLogic(ui, comp); controller.handleInput('='); assertTrue(((FakeUserInterface) ui).showErrorCalled); assertTrue(((FakeComputationLogic) comp).computeResultCalled); } private class FakeUserInterface implements CalculatorContract.IUserInterface{ boolean displayUpdateCalled = false; boolean showErrorCalled = false; String displayValueInitial = "5+5"; String displayValueFinal = ""; @Override public void updateDisplay(String displayText) { displayUpdateCalled = true; displayValueFinal = displayText; } @Override public String getDisplay() { return displayValueInitial; } @Override public void showError() { showErrorCalled = true; } } private class FakeComputationLogic implements CalculatorContract.IComputationLogic{ boolean computeResultCalled = false; boolean returnEmpty = false; @Override public Optional computeResult(String expression) { computeResultCalled = true; if (returnEmpty) return Optional.empty(); else return Optional.of("10.0"); } } } public class CalculatorControlLogicTest { @Test public void validExpressionTest() { CalculatorContract.IComputationLogic comp = new FakeComputationLogic(); CalculatorContract.IUserInterface ui = new FakeUserInterface(); CalculatorControlLogic controller = new CalculatorControlLogic(ui, comp); controller.handleInput('='); assertTrue(((FakeUserInterface) ui).displayUpdateCalled); assertTrue(((FakeUserInterface) ui).displayValueFinal.equals("10.0")); assertTrue(((FakeComputationLogic) comp).computeResultCalled); } @Test public void invalidExpressionTest() { CalculatorContract.IComputationLogic comp = new FakeComputationLogic(); ((FakeComputationLogic) comp).returnEmpty = true; CalculatorContract.IUserInterface ui = new FakeUserInterface(); ((FakeUserInterface) ui).displayValueInitial = "+7+7"; CalculatorControlLogic controller = new CalculatorControlLogic(ui, comp); controller.handleInput('='); assertTrue(((FakeUserInterface) ui).showErrorCalled); assertTrue(((FakeComputationLogic) comp).computeResultCalled); } private class FakeUserInterface implements CalculatorContract.IUserInterface{ boolean displayUpdateCalled = false; boolean showErrorCalled = false; String displayValueInitial = "5+5"; String displayValueFinal = ""; @Override public void updateDisplay(String displayText) { displayUpdateCalled = true; displayValueFinal = displayText; } @Override public String getDisplay() { return displayValueInitial; } @Override public void showError() { showErrorCalled = true; } } private class FakeComputationLogic implements CalculatorContract.IComputationLogic{ boolean computeResultCalled = false; boolean returnEmpty = false; @Override public Optional computeResult(String expression) { computeResultCalled = true; if (returnEmpty) return Optional.empty(); else return Optional.of("10.0"); } } }

อย่างที่คุณเห็น ชุดทดสอบนี้ไม่เพียงแต่สามารถทำงานได้อย่างรวดเร็วเท่านั้น แต่ยังใช้เวลาไม่นานในการเขียนอีกด้วย ไม่ว่าในกรณีใด ตอนนี้เราจะพิจารณาบางสิ่งที่ละเอียดยิ่งขึ้นซึ่งทำให้การเขียนคลาสทดสอบนี้ง่ายมาก

พลังแห่งความเป็นนามธรรมและการผกผันการพึ่งพา

มีแนวคิดสำคัญอีกสองประการที่ใช้กับ CalculatorControlLogic ซึ่งทำให้การทดสอบง่ายขึ้นเล็กน้อย ประการแรก หากคุณเคยสงสัยว่าประโยชน์ของการใช้ Interfaces และ Abstract Classes (เรียกรวมกันว่า abstractions ) ใน Java คืออะไร โค้ดด้านบนนี้เป็นการสาธิตโดยตรง เนื่องจากคลาสที่จะทดสอบอ้างอิง abstractions แทนที่จะเป็นคลาสที่ เป็นรูปธรรม เราจึงสามารถสร้างการทดสอบ ปลอม เป็นสองเท่าสำหรับ อินเทอร์เฟซผู้ใช้ และ แบ็กเอนด์ จากภายในคลาสการทดสอบของเรา ตราบใดที่การทดสอบเหล่านี้ใช้อินเทอร์เฟซที่เหมาะสมเป็นสองเท่า CalculatorControlLogic ก็ไม่สนใจว่าสิ่งเหล่านี้ไม่ใช่ของจริง

ประการที่สอง CalculatorControlLogic ได้รับการขึ้นต่อกันผ่านตัวสร้าง (ใช่ นั่นคือรูปแบบหนึ่งของ Dependency Injection ) แทนที่จะสร้างการพึ่งพาของตัวเอง ดังนั้นจึงไม่จำเป็นต้องเขียนใหม่เมื่อใช้ในสภาพแวดล้อมการผลิตหรือการทดสอบ ซึ่งเป็นโบนัสสำหรับประสิทธิภาพ

Dependency Injection เป็นรูปแบบหนึ่งของ Inversion Of Control ซึ่งเป็นแนวคิดที่ยากจะกำหนดในภาษาธรรมดา ไม่ว่าคุณจะใช้ Dependency Injection หรือ Service Locator Pattern ทั้งคู่บรรลุสิ่งที่ Martin Fowler (ครูคนโปรดของฉันในหัวข้อดังกล่าว) อธิบายว่าเป็น "หลักการของการแยกการกำหนดค่าออกจากการใช้งาน" ส่งผลให้ชั้นเรียนง่ายต่อการทดสอบ และสร้างแยกจากกันได้ง่ายขึ้น

การทดสอบลอจิกการคำนวณ

สุดท้าย เรามาที่คลาส ComputationLogic ซึ่งควรจะประมาณ อุปกรณ์ IO เช่น อะแดปเตอร์ไปยังเซิร์ฟเวอร์ระยะไกล หรือฐานข้อมูลในเครื่อง เนื่องจากเราไม่ต้องการสิ่งใดสิ่งหนึ่งสำหรับเครื่องคิดเลขธรรมดา จึงต้องรับผิดชอบในการห่อหุ้มตรรกะที่จำเป็นในการตรวจสอบและประเมินนิพจน์ที่เราให้ไว้:

 public class CalculatorComputationLogic implements CalculatorContract.IComputationLogic { private final char ADD = '+'; private final char SUBTRACT = '-'; private final char MULTIPLY = '*'; private final char DIVIDE = '/'; @Override public Optional computeResult(String expression) { if (hasOperator(expression)) return attemptEvaluation(expression); else return Optional.empty(); } private Optional attemptEvaluation(String expression) { String delimiter = getOperator(expression); Binomial b = buildBinomial(expression, delimiter); return evaluateBinomial(b); } private Optional evaluateBinomial(Binomial b) { String result; switch (b.getOperatorChar()) { case ADD: result = Double.toString(b.firstTerm + b.secondTerm); break; case SUBTRACT: result = Double.toString(b.firstTerm - b.secondTerm); break; case MULTIPLY: result = Double.toString(b.firstTerm * b.secondTerm); break; case DIVIDE: result = Double.toString(b.firstTerm / b.secondTerm); break; default: return Optional.empty(); } return Optional.of(result); } private Binomial buildBinomial(String expression, String delimiter) { String[] operands = expression.split(delimiter); return new Binomial( delimiter, Double.parseDouble(operands[0]), Double.parseDouble(operands[1]) ); } private String getOperator(String expression) { for (char c : expression.toCharArray()) { if (c == ADD || c == SUBTRACT || c == MULTIPLY || c == DIVIDE) return "\\" + c; } //default return "+"; } private boolean hasOperator(String expression) { for (char c : expression.toCharArray()) { if (c == ADD || c == SUBTRACT || c == MULTIPLY || c == DIVIDE) return true; } return false; } private class Binomial { String operator; double firstTerm; double secondTerm; Binomial(String operator, double firstTerm, double secondTerm) { this.operator = operator; this.firstTerm = firstTerm; this.secondTerm = secondTerm; } char getOperatorChar(){ return operator.charAt(operator.length() - 1); } } } public class CalculatorComputationLogic implements CalculatorContract.IComputationLogic { private final char ADD = '+'; private final char SUBTRACT = '-'; private final char MULTIPLY = '*'; private final char DIVIDE = '/'; @Override public Optional computeResult(String expression) { if (hasOperator(expression)) return attemptEvaluation(expression); else return Optional.empty(); } private Optional attemptEvaluation(String expression) { String delimiter = getOperator(expression); Binomial b = buildBinomial(expression, delimiter); return evaluateBinomial(b); } private Optional evaluateBinomial(Binomial b) { String result; switch (b.getOperatorChar()) { case ADD: result = Double.toString(b.firstTerm + b.secondTerm); break; case SUBTRACT: result = Double.toString(b.firstTerm - b.secondTerm); break; case MULTIPLY: result = Double.toString(b.firstTerm * b.secondTerm); break; case DIVIDE: result = Double.toString(b.firstTerm / b.secondTerm); break; default: return Optional.empty(); } return Optional.of(result); } private Binomial buildBinomial(String expression, String delimiter) { String[] operands = expression.split(delimiter); return new Binomial( delimiter, Double.parseDouble(operands[0]), Double.parseDouble(operands[1]) ); } private String getOperator(String expression) { for (char c : expression.toCharArray()) { if (c == ADD || c == SUBTRACT || c == MULTIPLY || c == DIVIDE) return "\\" + c; } //default return "+"; } private boolean hasOperator(String expression) { for (char c : expression.toCharArray()) { if (c == ADD || c == SUBTRACT || c == MULTIPLY || c == DIVIDE) return true; } return false; } private class Binomial { String operator; double firstTerm; double secondTerm; Binomial(String operator, double firstTerm, double secondTerm) { this.operator = operator; this.firstTerm = firstTerm; this.secondTerm = secondTerm; } char getOperatorChar(){ return operator.charAt(operator.length() - 1); } } } public class CalculatorComputationLogic implements CalculatorContract.IComputationLogic { private final char ADD = '+'; private final char SUBTRACT = '-'; private final char MULTIPLY = '*'; private final char DIVIDE = '/'; @Override public Optional computeResult(String expression) { if (hasOperator(expression)) return attemptEvaluation(expression); else return Optional.empty(); } private Optional attemptEvaluation(String expression) { String delimiter = getOperator(expression); Binomial b = buildBinomial(expression, delimiter); return evaluateBinomial(b); } private Optional evaluateBinomial(Binomial b) { String result; switch (b.getOperatorChar()) { case ADD: result = Double.toString(b.firstTerm + b.secondTerm); break; case SUBTRACT: result = Double.toString(b.firstTerm - b.secondTerm); break; case MULTIPLY: result = Double.toString(b.firstTerm * b.secondTerm); break; case DIVIDE: result = Double.toString(b.firstTerm / b.secondTerm); break; default: return Optional.empty(); } return Optional.of(result); } private Binomial buildBinomial(String expression, String delimiter) { String[] operands = expression.split(delimiter); return new Binomial( delimiter, Double.parseDouble(operands[0]), Double.parseDouble(operands[1]) ); } private String getOperator(String expression) { for (char c : expression.toCharArray()) { if (c == ADD || c == SUBTRACT || c == MULTIPLY || c == DIVIDE) return "\\" + c; } //default return "+"; } private boolean hasOperator(String expression) { for (char c : expression.toCharArray()) { if (c == ADD || c == SUBTRACT || c == MULTIPLY || c == DIVIDE) return true; } return false; } private class Binomial { String operator; double firstTerm; double secondTerm; Binomial(String operator, double firstTerm, double secondTerm) { this.operator = operator; this.firstTerm = firstTerm; this.secondTerm = secondTerm; } char getOperatorChar(){ return operator.charAt(operator.length() - 1); } } } public class CalculatorComputationLogic implements CalculatorContract.IComputationLogic { private final char ADD = '+'; private final char SUBTRACT = '-'; private final char MULTIPLY = '*'; private final char DIVIDE = '/'; @Override public Optional computeResult(String expression) { if (hasOperator(expression)) return attemptEvaluation(expression); else return Optional.empty(); } private Optional attemptEvaluation(String expression) { String delimiter = getOperator(expression); Binomial b = buildBinomial(expression, delimiter); return evaluateBinomial(b); } private Optional evaluateBinomial(Binomial b) { String result; switch (b.getOperatorChar()) { case ADD: result = Double.toString(b.firstTerm + b.secondTerm); break; case SUBTRACT: result = Double.toString(b.firstTerm - b.secondTerm); break; case MULTIPLY: result = Double.toString(b.firstTerm * b.secondTerm); break; case DIVIDE: result = Double.toString(b.firstTerm / b.secondTerm); break; default: return Optional.empty(); } return Optional.of(result); } private Binomial buildBinomial(String expression, String delimiter) { String[] operands = expression.split(delimiter); return new Binomial( delimiter, Double.parseDouble(operands[0]), Double.parseDouble(operands[1]) ); } private String getOperator(String expression) { for (char c : expression.toCharArray()) { if (c == ADD || c == SUBTRACT || c == MULTIPLY || c == DIVIDE) return "\\" + c; } //default return "+"; } private boolean hasOperator(String expression) { for (char c : expression.toCharArray()) { if (c == ADD || c == SUBTRACT || c == MULTIPLY || c == DIVIDE) return true; } return false; } private class Binomial { String operator; double firstTerm; double secondTerm; Binomial(String operator, double firstTerm, double secondTerm) { this.operator = operator; this.firstTerm = firstTerm; this.secondTerm = secondTerm; } char getOperatorChar(){ return operator.charAt(operator.length() - 1); } } }

ไม่มีอะไรจะพูดมากเกี่ยวกับคลาสนี้ เนื่องจากโดยทั่วไปแล้วจะมีการเชื่อมต่อกับไลบรารีแบ็กเอนด์โดยเฉพาะ ซึ่งจะนำเสนอปัญหาที่คล้ายคลึงกันในฐานะคลาสที่เชื่อมต่อกับ Android อย่างแน่นหนา ในอีกสักครู่เราจะพูดถึงสิ่งที่ควรทำเกี่ยวกับชั้นเรียนดังกล่าว แต่อันนี้ง่ายต่อการทดสอบซึ่งเราอาจจะลองเช่นกัน:

 public class CalculatorComputationLogicTest { private CalculatorComputationLogic comp = new CalculatorComputationLogic(); @Test public void additionTest() { String EXPRESSION = "5+5"; String ANSWER = "10.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void subtractTest() { String EXPRESSION = "5-5"; String ANSWER = "0.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void multiplyTest() { String EXPRESSION = "5*5"; String ANSWER = "25.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void divideTest() { String EXPRESSION = "5/5"; String ANSWER = "1.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void invalidTest() { String EXPRESSION = "Potato"; Optional result = comp.computeResult(EXPRESSION); assertTrue(!result.isPresent()); } } public class CalculatorComputationLogicTest { private CalculatorComputationLogic comp = new CalculatorComputationLogic(); @Test public void additionTest() { String EXPRESSION = "5+5"; String ANSWER = "10.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void subtractTest() { String EXPRESSION = "5-5"; String ANSWER = "0.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void multiplyTest() { String EXPRESSION = "5*5"; String ANSWER = "25.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void divideTest() { String EXPRESSION = "5/5"; String ANSWER = "1.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void invalidTest() { String EXPRESSION = "Potato"; Optional result = comp.computeResult(EXPRESSION); assertTrue(!result.isPresent()); } } public class CalculatorComputationLogicTest { private CalculatorComputationLogic comp = new CalculatorComputationLogic(); @Test public void additionTest() { String EXPRESSION = "5+5"; String ANSWER = "10.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void subtractTest() { String EXPRESSION = "5-5"; String ANSWER = "0.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void multiplyTest() { String EXPRESSION = "5*5"; String ANSWER = "25.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void divideTest() { String EXPRESSION = "5/5"; String ANSWER = "1.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void invalidTest() { String EXPRESSION = "Potato"; Optional result = comp.computeResult(EXPRESSION); assertTrue(!result.isPresent()); } } public class CalculatorComputationLogicTest { private CalculatorComputationLogic comp = new CalculatorComputationLogic(); @Test public void additionTest() { String EXPRESSION = "5+5"; String ANSWER = "10.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void subtractTest() { String EXPRESSION = "5-5"; String ANSWER = "0.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void multiplyTest() { String EXPRESSION = "5*5"; String ANSWER = "25.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void divideTest() { String EXPRESSION = "5/5"; String ANSWER = "1.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void invalidTest() { String EXPRESSION = "Potato"; Optional result = comp.computeResult(EXPRESSION); assertTrue(!result.isPresent()); } } public class CalculatorComputationLogicTest { private CalculatorComputationLogic comp = new CalculatorComputationLogic(); @Test public void additionTest() { String EXPRESSION = "5+5"; String ANSWER = "10.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void subtractTest() { String EXPRESSION = "5-5"; String ANSWER = "0.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void multiplyTest() { String EXPRESSION = "5*5"; String ANSWER = "25.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void divideTest() { String EXPRESSION = "5/5"; String ANSWER = "1.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void invalidTest() { String EXPRESSION = "Potato"; Optional result = comp.computeResult(EXPRESSION); assertTrue(!result.isPresent()); } } public class CalculatorComputationLogicTest { private CalculatorComputationLogic comp = new CalculatorComputationLogic(); @Test public void additionTest() { String EXPRESSION = "5+5"; String ANSWER = "10.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void subtractTest() { String EXPRESSION = "5-5"; String ANSWER = "0.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void multiplyTest() { String EXPRESSION = "5*5"; String ANSWER = "25.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void divideTest() { String EXPRESSION = "5/5"; String ANSWER = "1.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void invalidTest() { String EXPRESSION = "Potato"; Optional result = comp.computeResult(EXPRESSION); assertTrue(!result.isPresent()); } }

คลาสที่ง่ายที่สุดในการทดสอบคือคลาสที่ได้รับค่าหรืออ็อบเจ็กต์บางอย่าง และคาดว่าจะส่งคืนผลลัพธ์โดยไม่จำเป็นต้องเรียกใช้การพึ่งพาภายนอก ไม่ว่าในกรณีใด มีจุดที่ไม่ว่าคุณจะใช้ตัวช่วยสร้างสถาปัตยกรรมซอฟต์แวร์มากแค่ไหน คุณยังคงต้องกังวลเกี่ยวกับคลาสที่ไม่สามารถแยกออกจากแพลตฟอร์มและเฟรมเวิร์กได้ โชคดีที่ยังมีวิธีที่เราสามารถใช้สถาปัตยกรรมซอฟต์แวร์เพื่อ: อย่างแย่ที่สุดคือทำให้ชั้นเรียนเหล่านี้ง่ายต่อการทดสอบ และอย่างดีที่สุด ง่ายนิดเดียวที่การทดสอบสามารถทำได้ ในพริบ ตา

วัตถุที่ต่ำต้อยและมุมมองแบบพาสซีฟ

ชื่อสองชื่อข้างต้นอ้างถึงรูปแบบที่อ็อบเจ็กต์ที่ต้องพูดคุยกับการพึ่งพาระดับต่ำนั้นถูกทำให้ง่ายขึ้นมากจน อาจ ไม่จำเป็นต้องทำการทดสอบ ครั้งแรกที่ฉันรู้จักรูปแบบนี้ผ่านบล็อกของ Martin Fowler เกี่ยวกับรูปแบบต่างๆ ของ Model-View-Presenter ต่อมาผ่านผลงานของ Robert C. Martin ผมได้แนะนำให้รู้จักกับแนวคิดในการจัดการคลาสบางคลาสเป็น Humble Objects ซึ่งหมายความว่ารูปแบบนี้ไม่จำเป็นต้องจำกัดเฉพาะคลาสอินเทอร์เฟซผู้ใช้ (แม้ว่าผมไม่ได้ตั้งใจจะพูดว่าฟาวเลอร์ก็ตาม บ่งบอกถึงข้อจำกัดดังกล่าว)

ไม่ว่าคุณจะเรียกรูปแบบนี้ว่าอะไรก็ตาม มันก็เข้าใจง่ายดี และในแง่หนึ่ง ฉันเชื่อว่านี่เป็นเพียงผลลัพธ์ของการนำ SOC ไปใช้กับชั้นเรียนของคุณอย่างจริงจัง แม้ว่ารูปแบบนี้จะใช้กับคลาสส่วนหลังด้วย เราจะใช้คลาส อินเทอร์เฟซผู้ใช้ ของเราเพื่อสาธิตหลักการนี้ในการดำเนินการ การแยกส่วนนั้นง่ายมาก: คลาสที่โต้ตอบกับการพึ่งพาแพลตฟอร์มและเฟรมเวิร์ก อย่าคิดเอาเอง (เพราะฉะนั้นชื่อเล่น Humble และ Passive ) เมื่อเกิดเหตุการณ์ขึ้น สิ่งเดียวที่พวกเขาทำคือส่งต่อรายละเอียดของเหตุการณ์นี้ไปยังคลาส ลอจิก ที่กำลังฟังอยู่:

 //from CalculatorActivity's onCreate() function: evaluate.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View view) { controlLogic.handleInput('='); } });

คลาสลอจิก ซึ่งควรจะทดสอบได้ง่ายเพียงเล็กน้อย มีหน้าที่ในการควบคุม ส่วนต่อประสานผู้ใช้ ในลักษณะที่ละเอียดมาก แทนที่จะเรียกใช้ฟังก์ชัน updateUserInterface(...) ทั่วไปเพียงฟังก์ชันเดียวในคลาส user interface และปล่อยให้ทำงานของการอัพเดตจำนวนมาก user interface (หรือคลาสอื่นๆ ดังกล่าว) จะมีฟังก์ชันขนาดเล็กและเฉพาะเจาะจงซึ่งน่าจะง่ายต่อการใช้งาน ชื่อและดำเนินการ:

 //Interface functions of CalculatorActivity: @Override public void updateDisplay(String displayText) { display.setText(displayText); } @Override public String getDisplay() { return display.getText().toString(); } @Override public void showError() { Toast.makeText(this, INVALID_MESSAGE, Toast.LENGTH_LONG).show(); } //…

In principal, these two examples ought to give you enough to understand how to go about implementing this pattern. The object which possesses the logic is loosely coupled, and the object which is tightly coupled to pesky dependencies becomes almost devoid of logic.

Now, at the start of this subsection, I made the statement that these classes become arguably unnecessary to test, and it is important we look at both sides of this argument. In an absolute sense, it is impossible to achieve 100% test coverage by employing this pattern, unless you still write tests for such humble / passive classes. It is also worth noting that my decision to use a Calculator as an example App, means that I cannot escape having a gigantic mass of findViewById(...) calls present in the Activity. Giant masses of repetitive code are a common cause of typing errors, and in the absence of some Android UI testing frameworks, my only recourse for testing would be via deploying the feature to a device and manually testing each interaction. อุ๊ย

It is at this point that I will humbly say that I do not know if 100% code coverage is absolutely necessary. I do not know many developers who strive for absolute test coverage in production code, and I have never done so myself. One day I might, but I will reserve my opinions on this matter until I have the reference experiences to back them up. In any case, I would argue that applying this pattern will still ultimately make it simpler and easier to test tightly coupled classes; if for no reason other than they become simpler to write.

Another objection to this approach, was raised by a fellow programmer when I described this approach in another context. The objection was that the logic class (whether it be a Controller , Presenter , or even a ViewModel depending on how you use it), becomes a God class.

While I do not agree with that sentiment, I do agree that the end result of applying this pattern is that your Logic classes become larger than if you left more decisions up to your user interface class.

This has never been an issue for me as I treat each feature of my applications as self-contained components, as opposed to having one giant controller for managing multiple user interface screens. In any case, I think this argument holds reasonably true if you fail to apply SOC to your front end or back end components. Therefore, my advice is to apply SOC to your front end and back end components quite rigorously.

Further Considerations

After all of this discussion on applying the principles of software architecture to reduce the necessity of using a wide-array of testing frameworks, improve the testability of classes in general, and a pattern which allows classes to be tested indirectly (at least to some degree), I am not actually here to tell you to stop using your preferred frameworks.

For those curious, I often use a library to generate mock classes for my Unit tests (for Java I prefer Mockito , but these days I mostly write Kotlin and prefer Mockk in that language), and JUnit is a framework which I use quite invariably. Since all of these options are coupled to languages as opposed to the Android platform, I can use them quite interchangeably across mobile and web application development. From time to time (if project requirements demand it), I will even use tools like Robolectric , MockWebServer , and in my five years of studying Android, I did begrudgingly use Espresso once.

My hope is that in reading this article, anyone who has experienced a similar degree of aversion to testing due to paralysis by jargon analysis , will come to see that getting started with testing really can be simple and framework minimal .

อ่านเพิ่มเติม เกี่ยวกับ SmashingMag:

  • Sliding In And Out Of Vue.js
  • Designing And Building A Progressive Web Application Without A Framework
  • CSS Framework หรือ CSS Grid: ฉันควรใช้อะไรสำหรับโครงการของฉัน
  • การใช้ Flutter ของ Google เพื่อการพัฒนามือถือข้ามแพลตฟอร์มอย่างแท้จริง