คู่มือการทดสอบหน่วยที่แข็งแกร่งและการรวมเข้ากับ JUnit
เผยแพร่แล้ว: 2022-03-11การทดสอบซอฟต์แวร์อัตโนมัติมีความสำคัญอย่างยิ่งต่อคุณภาพในระยะยาว ความสามารถในการบำรุงรักษา และความสามารถในการขยายของโครงการซอฟต์แวร์ และสำหรับ Java แล้ว JUnit คือเส้นทางสู่การทำงานอัตโนมัติ
แม้ว่าบทความนี้ส่วนใหญ่จะเน้นที่การเขียนการทดสอบหน่วยที่มีประสิทธิภาพและการใช้ stubbing การเยาะเย้ย และการพึ่งพาอาศัยกัน เราจะพูดถึง JUnit และการทดสอบการรวม
เฟรมเวิร์กการทดสอบ JUnit เป็นเครื่องมือทั่วไป ฟรี และโอเพ่นซอร์สสำหรับการทดสอบโปรเจ็กต์ที่ใช้ Java
ในการเขียนนี้ JUnit 4 เป็นรีลีสหลักในปัจจุบัน ซึ่งเปิดตัวเมื่อ 10 ปีที่แล้ว โดยมีการอัปเดตครั้งล่าสุดเมื่อสองปีที่แล้ว
JUnit 5 (พร้อมโมเดลการเขียนโปรแกรมและส่วนขยายของ Jupiter) กำลังอยู่ในระหว่างการพัฒนา รองรับคุณสมบัติภาษาที่นำมาใช้ใน Java 8 ได้ดีกว่าและรวมถึงคุณสมบัติใหม่ที่น่าสนใจอื่นๆ บางทีมอาจพบว่า JUnit 5 พร้อมใช้งาน ในขณะที่บางทีมอาจใช้ JUnit 4 ต่อไปจนกว่า 5 จะออกอย่างเป็นทางการ เราจะดูตัวอย่างจากทั้งคู่
วิ่ง JUnit
การทดสอบ JUnit สามารถรันได้โดยตรงใน IntelliJ แต่สามารถรันใน IDE อื่นๆ เช่น Eclipse, NetBeans หรือแม้แต่บรรทัดคำสั่งได้
การทดสอบควรทำงานที่เวลาสร้างเสมอ โดยเฉพาะการทดสอบหน่วย บิลด์ที่มีการทดสอบที่ล้มเหลวควรพิจารณาว่าล้มเหลว ไม่ว่าปัญหาจะอยู่ที่การผลิตหรือโค้ดทดสอบก็ตาม ซึ่งต้องใช้วินัยจากทีมและความเต็มใจที่จะให้ความสำคัญสูงสุดในการแก้ไขการทดสอบที่ล้มเหลว แต่จำเป็นต้องปฏิบัติตาม จิตวิญญาณของระบบอัตโนมัติ
การทดสอบ JUnit ยังสามารถเรียกใช้และรายงานโดยระบบการรวมอย่างต่อเนื่อง เช่น Jenkins โปรเจ็กต์ที่ใช้เครื่องมือเช่น Gradle, Maven หรือ Ant มีข้อได้เปรียบเพิ่มเติมในการเรียกใช้การทดสอบซึ่งเป็นส่วนหนึ่งของกระบวนการสร้าง
Gradle
สำหรับโปรเจ็กต์ Gradle ตัวอย่างสำหรับ JUnit 5 โปรดดูส่วน Gradle ของคู่มือผู้ใช้ JUnit และที่เก็บ junit5-samples.git โปรดทราบว่ามันยังสามารถรันการทดสอบที่ใช้ JUnit 4 API (เรียกว่า “วินเทจ” )
สามารถสร้างโปรเจ็กต์ใน IntelliJ ได้โดยใช้ตัวเลือกเมนู ไฟล์ > เปิด… > นำทางไปยัง junit-gradle-consumer sub-directory
> ตกลง > เปิดเป็นโครงการ > ตกลง เพื่อนำเข้าโครงการจาก Gradle
สำหรับ Eclipse สามารถติดตั้งปลั๊กอิน Buildship Gradle ได้จากวิธีใช้ > Eclipse Marketplace... จากนั้นสามารถนำเข้าโครงการด้วยไฟล์ > นำเข้า… > Gradle > Gradle Project > ถัดไป > ถัดไป > เรียกดูไดเร็กทอรีย่อยของ junit-gradle-consumer
> ถัดไป > ถัดไป > เสร็จสิ้น
หลังจากตั้งค่าโปรเจ็กต์ Gradle ใน IntelliJ หรือ Eclipse การรันงาน Gradle build
จะรวมการรันการทดสอบ JUnit ทั้งหมดด้วยงาน test
โปรดทราบว่าการทดสอบอาจถูกข้ามในการดำเนินการบิลด์ในครั้ง build
ๆ ไป หากไม่มีการเปลี่ยนแปลงใดๆ กับโค้ด
สำหรับ JUnit 4 ดูการใช้งานของ JUnit กับ Gradle wiki
Maven
สำหรับ JUnit 5 โปรดดูส่วน Maven ของคู่มือผู้ใช้และที่เก็บ Junit5-samples.git สำหรับตัวอย่างของโปรเจ็กต์ Maven นอกจากนี้ยังสามารถเรียกใช้การทดสอบแบบโบราณ (อันที่ใช้ JUnit 4 API)
ใน IntelliJ ใช้ ไฟล์ > เปิด… > นำทางไปยัง junit-maven-consumer/pom.xml
> ตกลง > เปิดเป็นโครงการ การทดสอบสามารถเรียกใช้จาก Maven Projects > junit5-maven-consumer > Lifecycle > Test
ใน Eclipse ให้ใช้ไฟล์ > นำเข้า… > Maven > โครงการ Maven ที่มีอยู่ > ถัดไป > เรียกดูไดเร็กทอรี junit-maven-consumer
> โดยเลือก pom.xml
> เสร็จสิ้น
การทดสอบสามารถทำได้โดยการรันโปรเจ็กต์เป็น Maven build… > ระบุเป้าหมายของ test
> Run
สำหรับ JUnit 4 โปรดดู JUnit ในที่เก็บ Maven
สภาพแวดล้อมการพัฒนา
นอกเหนือจากการรันการทดสอบผ่านเครื่องมือบิลด์ เช่น Gradle หรือ Maven แล้ว IDE จำนวนมากสามารถรันการทดสอบ JUnit ได้โดยตรง
IntelliJ IDEA
ต้องใช้ IntelliJ IDEA 2016.2 หรือใหม่กว่าสำหรับการทดสอบ JUnit 5 ในขณะที่การทดสอบ JUnit 4 ควรทำงานใน IntelliJ เวอร์ชันเก่ากว่า
สำหรับวัตถุประสงค์ของบทความนี้ คุณอาจต้องการสร้างโปรเจ็กต์ใหม่ใน IntelliJ จากหนึ่งในที่เก็บ GitHub ของฉัน ( JUnit5IntelliJ.git หรือ JUnit4IntelliJ.git) ซึ่งรวมถึงไฟล์ทั้งหมดในตัวอย่างคลาส Person
อย่างง่าย และใช้บิวด์อิน ห้องสมุด JUnit การทดสอบสามารถทำได้โดยใช้ Run > Run 'All Tests' การทดสอบยังสามารถเรียกใช้ใน IntelliJ จากคลาส PersonTest
ที่เก็บเหล่านี้สร้างขึ้นด้วยโปรเจ็กต์ IntelliJ Java ใหม่ และสร้างโครงสร้างไดเร็กทอรี src/main/java/com/example
และ src/test/java/com/example
ไดเร็กทอรี src/main/java
ถูกระบุเป็นโฟลเดอร์ต้นทาง ในขณะที่ src/test/java
ถูกระบุเป็นโฟลเดอร์ต้นทางสำหรับการทดสอบ หลังจากสร้างคลาส PersonTest
ด้วยวิธีการทดสอบที่มีคำอธิบายประกอบ @Test
อาจไม่สามารถคอมไพล์ได้ ซึ่งในกรณีนี้ IntelliJ เสนอคำแนะนำให้เพิ่ม JUnit 4 หรือ JUnit 5 ไปยังคลาสพาธซึ่งสามารถโหลดได้จากการกระจาย IntelliJ IDEA (ดูสิ่งเหล่านี้ คำตอบใน Stack Overflow สำหรับรายละเอียดเพิ่มเติม) สุดท้าย มีการเพิ่มคอนฟิกูเรชันการรัน JUnit สำหรับการทดสอบทั้งหมด
ดูเพิ่มเติมที่ แนวทางการทดสอบ IntelliJ
คราส
โปรเจ็กต์ Java ว่างใน Eclipse จะไม่มีไดเร็กทอรีรูททดสอบ เพิ่มสิ่งนี้จากคุณสมบัติโปรเจ็กต์ > Java Build Path > เพิ่มโฟลเดอร์… > สร้างโฟลเดอร์ใหม่… > ระบุชื่อโฟลเดอร์ > เสร็จสิ้น ไดเร็กทอรีใหม่จะถูกเลือกเป็นโฟลเดอร์ต้นทาง คลิกตกลงในกล่องโต้ตอบที่เหลือทั้งสอง
การทดสอบ JUnit 4 สามารถสร้างได้ด้วย File > New > JUnit Test Case เลือก "การทดสอบ JUnit 4 ใหม่" และโฟลเดอร์ต้นทางที่สร้างขึ้นใหม่สำหรับการทดสอบ ระบุ "คลาสภายใต้การทดสอบ" และ "แพ็คเกจ" ตรวจสอบให้แน่ใจว่าแพ็คเกจตรงกับคลาสที่ทดสอบ จากนั้น ระบุชื่อสำหรับคลาสทดสอบ หลังจากเสร็จสิ้นวิซาร์ด หากได้รับแจ้ง ให้เลือก "เพิ่มไลบรารี JUnit 4" ในพาธบิลด์ โปรเจ็กต์หรือคลาสการทดสอบแต่ละรายการสามารถรันเป็น JUnit Test ดูเพิ่มเติมที่ การทดสอบการเขียนและการรัน JUnit ของ Eclipse
NetBeans
NetBeans รองรับการทดสอบ JUnit 4 เท่านั้น สามารถสร้างคลาสการทดสอบในโปรเจ็กต์ NetBeans Java ด้วย File > New File… > Unit Tests > JUnit Test หรือ Test for Existing Class โดยค่าเริ่มต้น ไดเร็กทอรี root ของการทดสอบจะมีชื่อว่า test
ในไดเร็กทอรีโปรเจ็กต์
Simple Production Class และ JUnit Test Case
มาดูตัวอย่างง่ายๆ ของรหัสการผลิตและรหัสทดสอบหน่วยที่สอดคล้องกันสำหรับคลาส Person
ง่ายมาก คุณสามารถดาวน์โหลดโค้ดตัวอย่างจากโปรเจ็กต์ github ของฉันและเปิดผ่าน IntelliJ
src/main/java/com/example/Person.java
package com.example; class Person { private final String givenName; private final String surname; Person(String givenName, String surname) { this.givenName = givenName; this.surname = surname; } String getDisplayName() { return surname + ", " + givenName; } }
คลาส Person
ไม่เปลี่ยนรูปมีคอนสตรัคเตอร์และ getDisplayName()
เราต้องการทดสอบว่า getDisplayName()
ส่งคืนชื่อที่จัดรูปแบบตามที่เราคาดไว้ นี่คือรหัสทดสอบสำหรับการทดสอบหน่วยเดียว (JUnit 5):
src/test/java/com/example/PersonTest.java
package com.example; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.*; class PersonTest { @Test void testGetDisplayName() { Person person = new Person("Josh", "Hayden"); String displayName = person.getDisplayName(); assertEquals("Hayden, Josh", displayName); } }
PersonTest
ใช้ @Test
ของ JUnit 5 และการยืนยัน สำหรับ JUnit 4 คลาส PersonTest
และเมธอดต้องเป็นสาธารณะ และควรใช้อิมพอร์ตที่แตกต่างกัน นี่คือตัวอย่าง Gist ของ JUnit 4
เมื่อรันคลาส PersonTest
ใน IntelliJ การทดสอบจะผ่านและตัวบ่งชี้ UI จะเป็นสีเขียว
อนุสัญญา JUnit ทั่วไป
การตั้งชื่อ
แม้ว่าจะไม่จำเป็น แต่เราใช้หลักการทั่วไปในการตั้งชื่อคลาสการทดสอบ โดยเฉพาะอย่างยิ่ง เราเริ่มต้นด้วยชื่อของคลาสที่กำลังทดสอบ ( Person
) และต่อท้าย "Test" ( PersonTest
) การตั้งชื่อวิธีการทดสอบนั้นคล้ายคลึงกัน โดยเริ่มจากวิธีการทดสอบ ( getDisplayName()
) และใส่ "test" ไว้ข้างหน้า ( testGetDisplayName()
) แม้ว่าจะมีข้อตกลงอื่นๆ มากมายที่ยอมรับได้สำหรับการตั้งชื่อวิธีการทดสอบ แต่สิ่งสำคัญคือต้องสอดคล้องกันในทีมและโครงการ
ชื่อในการผลิต | ชื่อในการทดสอบ |
---|---|
บุคคล | การทดสอบบุคคล |
getDisplayName() | testDisplayName() |
แพ็คเกจ
เรายังใช้แบบแผนในการสร้างรหัสทดสอบคลาส PersonTest
ในแพ็คเกจเดียวกัน ( com.example
) เป็นคลาส Person
ของรหัสการผลิต หากเราใช้แพ็คเกจอื่นสำหรับการทดสอบ เราจะต้องใช้ตัวแก้ไขการเข้าถึงสาธารณะในคลาสรหัสที่ใช้งานจริง ตัวสร้าง และวิธีการอ้างอิงโดยการทดสอบหน่วย แม้ว่าจะไม่เหมาะสมก็ตาม ดังนั้นจึงควรเก็บไว้ในแพ็คเกจเดียวกัน . อย่างไรก็ตาม เราใช้ไดเร็กทอรีต้นทางแยกต่างหาก ( src/main/java
และ src/test/java
) เนื่องจากโดยทั่วไปเราไม่ต้องการรวมโค้ดทดสอบในบิลด์ที่ใช้งานจริงที่เผยแพร่
โครงสร้างและคำอธิบายประกอบ
คำอธิบายประกอบ @Test
(JUnit 4/5) บอกให้ JUnit ดำเนินการ testGetDisplayName()
เป็นวิธีการทดสอบและรายงานว่าผ่านหรือล้มเหลว ตราบใดที่การยืนยันทั้งหมด (ถ้ามี) ผ่านและไม่มีข้อยกเว้น การทดสอบจะถือว่าผ่าน
รหัสทดสอบของเราเป็นไปตามรูปแบบโครงสร้างของ Arrange-Act-Assert (AAA) รูปแบบทั่วไปอื่นๆ ได้แก่ Given-When-That และ Setup-Exercise-Verify-Teardown (Teardown มักไม่จำเป็นสำหรับการทดสอบหน่วย) แต่เราใช้ AAA ในบทความนี้
มาดูกันว่าตัวอย่างการทดสอบของเราเป็นไปตาม AAA อย่างไร บรรทัดแรก "การจัดเรียง" สร้างวัตถุ Person
ที่จะทดสอบ:
Person person = new Person("Josh", "Hayden");
บรรทัดที่สอง "การกระทำ" ใช้ เมธอด Person.getDisplayName()
ของรหัสการผลิต:
String displayName = person.getDisplayName();
บรรทัดที่สาม "ยืนยัน" ตรวจสอบว่าผลลัพธ์เป็นไปตามที่คาดไว้
assertEquals("Hayden, Josh", displayName);
ภายใน การ assertEquals()
จะใช้เมธอดเท่ากับ "Hayden, Josh" String เพื่อตรวจสอบค่าจริงที่ส่งคืนจากรหัสการผลิต ( displayName
) ที่ตรงกัน หากไม่ตรงกัน การทดสอบจะถูกทำเครื่องหมายว่าล้มเหลว
โปรดทราบว่าการทดสอบมักจะมีมากกว่าหนึ่งบรรทัดสำหรับแต่ละเฟส AAA เหล่านี้
การทดสอบหน่วยและรหัสการผลิต
ตอนนี้เราได้ครอบคลุมข้อตกลงในการทดสอบแล้ว เรามาเปลี่ยนความสนใจของเราไปที่การทำให้โค้ดที่ใช้งานจริงสามารถทดสอบได้
เรากลับไปที่คลาส Person
ของเรา ซึ่งฉันได้ใช้วิธีในการคืนอายุของบุคคลตามวันเกิดของเขาหรือเธอ ตัวอย่างโค้ดต้องใช้ Java 8 เพื่อใช้ประโยชน์จากวันที่ใหม่และ API ที่ใช้งานได้ นี่คือลักษณะของคลาส Person.java
ใหม่:
Person.java
// ... class Person { // ... private final LocalDate dateOfBirth; Person(String givenName, String surname, LocalDate dateOfBirth) { // ... this.dateOfBirth = dateOfBirth; } // ... long getAge() { return ChronoUnit.YEARS.between(dateOfBirth, LocalDate.now()); } public static void main(String... args) { Person person = new Person("Joey", "Doe", LocalDate.parse("2013-01-12")); System.out.println(person.getDisplayName() + ": " + person.getAge() + " years"); // Doe, Joey: 4 years } }
การวิ่งคลาสนี้ (ตอนที่เขียน) ประกาศว่าโจอี้อายุ 4 ขวบ มาเพิ่มวิธีทดสอบกัน:
PersonTest.java
// ... class PersonTest { // ... @Test void testGetAge() { Person person = new Person("Joey", "Doe", LocalDate.parse("2013-01-12")); long age = person.getAge(); assertEquals(4, age); } }
วันนี้มันผ่านไป แต่แล้วเมื่อเราดำเนินการหนึ่งปีต่อจากนี้ล่ะ? การทดสอบนี้ไม่มีการกำหนดและเปราะบาง เนื่องจากผลลัพธ์ที่คาดหวังขึ้นอยู่กับวันที่ปัจจุบันของระบบที่ทำการทดสอบ
การสะดุดและการฉีดซัพพลายเออร์ที่มีคุณค่า
เมื่อใช้งานจริงในเวอร์ชันที่ใช้งานจริง เราต้องการใช้วันที่ปัจจุบัน LocalDate.now()
สำหรับคำนวณอายุของบุคคลนั้น แต่หากต้องการทำการทดสอบแบบกำหนดแม้ในหนึ่งปีนับจากนี้ การทดสอบจำเป็นต้องระบุค่า currentDate
ของตนเอง
สิ่งนี้เรียกว่าการฉีดพึ่งพา เราไม่ต้องการให้วัตถุ Person
ของเรากำหนดวันที่ปัจจุบัน แต่เราต้องการส่งผ่านตรรกะนี้เป็นการพึ่งพา การทดสอบหน่วยจะใช้ค่าที่รู้จัก ค่าที่ตัดตอนมา และรหัสการผลิตจะอนุญาตให้ระบบระบุค่าจริงในขณะใช้งานจริง
มาเพิ่มซัพพลายเออร์ LocalDate
ให้กับ Person.java
:
Person.java
// ... class Person { // ... private final LocalDate dateOfBirth; private final Supplier<LocalDate> currentDateSupplier; Person(String givenName, String surname, LocalDate dateOfBirth) { this(givenName, surname, dateOfBirth, LocalDate::now); } // Visible for testing Person(String givenName, String surname, LocalDate dateOfBirth, Supplier<LocalDate> currentDateSupplier) { // ... this.dateOfBirth = dateOfBirth; this.currentDateSupplier = currentDateSupplier; } // ... long getAge() { return ChronoUnit.YEARS.between(dateOfBirth, currentDateSupplier.get()); } public static void main(String... args) { Person person = new Person("Joey", "Doe", LocalDate.parse("2013-01-12")); System.out.println(person.getDisplayName() + ": " + person.getAge() + " years"); // Doe, Joey: 4 years } }
เพื่อให้ง่ายต่อการทดสอบเมธอด getAge()
เราจึงเปลี่ยนไปใช้ currentDateSupplier
ซึ่งเป็นซัพพลายเออร์ LocalDate
เพื่อดึงข้อมูลวันที่ปัจจุบัน หากคุณไม่ทราบว่าซัพพลายเออร์คืออะไร เราขอแนะนำให้คุณอ่านเกี่ยวกับอินเทอร์เฟซการทำงานในตัวของ Lambda
นอกจากนี้เรายังเพิ่มการแทรกการพึ่งพา: ตัวสร้างการทดสอบใหม่ช่วยให้การทดสอบระบุค่าวันที่ปัจจุบันของตนเองได้ ตัวสร้างดั้งเดิมเรียกตัวสร้างใหม่นี้ โดยผ่านการอ้างอิงวิธีการคงที่ของ LocalDate::now
ซึ่งจัดหาวัตถุ LocalDate
ดังนั้นวิธีการหลักของเราจึงยังคงทำงานเหมือนเมื่อก่อน แล้ววิธีการทดสอบของเราล่ะ? มาอัปเดต PersonTest.java
กันเถอะ:
PersonTest.java
// ... class PersonTest { // ... @Test void testGetAge() { LocalDate dateOfBirth = LocalDate.parse("2013-01-02"); LocalDate currentDate = LocalDate.parse("2017-01-17"); Person person = new Person("Joey", "Doe", dateOfBirth, ()->currentDate); long age = person.getAge(); assertEquals(4, age); } }
ขณะนี้การทดสอบได้เพิ่มค่าวันที่ปัจจุบันของตัวเอง currentDate
การทดสอบของเราจะยังคงผ่านเมื่อดำเนินการในปีหน้าหรือระหว่างปีใดๆ โดยทั่วไปจะเรียกว่า stubbing หรือการให้ค่าที่ทราบเพื่อส่งคืน แต่ก่อนอื่นเราต้องเปลี่ยน Person
เพื่ออนุญาตให้ฉีดการพึ่งพานี้
สังเกตไวยากรณ์แลมบ์ดา ( ()->currentDate
) เมื่อสร้างวัตถุ Person
สิ่งนี้ถือเป็นซัพพลายเออร์ของ LocalDate
ตามที่คอนสตรัคเตอร์ใหม่กำหนด
การเยาะเย้ยและขัดขวางบริการเว็บ
เราพร้อมสำหรับวัตถุ Person
ของเราซึ่งมีอยู่ทั้งหมดอยู่ในหน่วยความจำ JVM เพื่อสื่อสารกับโลกภายนอก เราต้องการเพิ่มสองวิธี: วิธี publishAge()
ซึ่งจะโพสต์อายุปัจจุบันของบุคคลนั้น และ getThoseInCommon()
ซึ่งจะส่งคืนชื่อคนดังที่มีวันเกิดวันเดียวกันหรืออายุเท่ากับ Person
ของเรา สมมติว่ามีบริการ RESTful ที่เราสามารถโต้ตอบด้วยที่เรียกว่า "People Birthdays" เรามีไคลเอนต์ Java ที่ประกอบด้วยคลาสเดียว BirthdaysClient
com.example.birthdays.BirthdaysClient
package com.example.birthdays; import java.io.IOException; import java.util.Arrays; import java.util.Collection; public class BirthdaysClient { public void publishRegularPersonAge(String name, long age) throws IOException { System.out.println("publishing " + name + "'s age: " + age); // HTTP POST with name and age and possibly throw an exception } public Collection<String> findFamousNamesOfAge(long age) throws IOException { System.out.println("finding famous names of age " + age); return Arrays.asList(/* HTTP GET with age and possibly throw an exception */); } public Collection<String> findFamousNamesBornOn(int month, int dayOfMonth) throws IOException { System.out.println("finding famous names born on day " + dayOfMonth + " of month " + month); return Arrays.asList(/* HTTP GET with month and day and possibly throw an exception */); } }
มาพัฒนาคลาส Person
ของเรากันเถอะ เราเริ่มต้นด้วยการเพิ่มวิธีการทดสอบใหม่สำหรับพฤติกรรมที่ต้องการของ publishAge()
เหตุใดจึงเริ่มต้นด้วยการทดสอบ แทนที่จะเป็นฟังก์ชัน เรากำลังปฏิบัติตามหลักการของการพัฒนาที่ขับเคลื่อนด้วยการทดสอบ (หรือที่เรียกว่า TDD) ซึ่งเราจะเขียนการทดสอบก่อน จากนั้นจึงเขียนโค้ดเพื่อให้ผ่านการทดสอบ
PersonTest.java
// … class PersonTest { // … @Test void testPublishAge() { LocalDate dateOfBirth = LocalDate.parse("2000-01-02"); LocalDate currentDate = LocalDate.parse("2017-01-01"); Person person = new Person("Joe", "Sixteen", dateOfBirth, ()->currentDate); person.publishAge(); } }
ณ จุดนี้ โค้ดทดสอบไม่สามารถคอมไพล์ได้เนื่องจากเราไม่ได้สร้าง publishAge()
ที่กำลังเรียกใช้ เมื่อเราสร้าง Person.publishAge()
ที่ว่างเปล่า ทุกอย่างก็ผ่านไป ตอนนี้เราพร้อมสำหรับการทดสอบเพื่อตรวจสอบว่าอายุของบุคคลนั้นได้รับการเผยแพร่ไปยัง BirthdaysClient
จริงหรือไม่

การเพิ่มวัตถุเยาะเย้ย
เนื่องจากเป็นการทดสอบหน่วย จึงควรทำงานอย่างรวดเร็วและอยู่ในหน่วยความจำ ดังนั้นการทดสอบจะสร้างวัตถุ Person
ของเราด้วย BirthdaysClient
จำลอง ดังนั้นจึงไม่ส่งคำขอทางเว็บจริงๆ การทดสอบจะใช้วัตถุจำลองนี้เพื่อตรวจสอบว่ามีการเรียกตามที่คาดไว้ ในการทำเช่นนี้ เราจะเพิ่มการพึ่งพา Mockito framework (ใบอนุญาต MIT) สำหรับการสร้างวัตถุจำลอง แล้วสร้างวัตถุ BirthdaysClient
ที่จำลองขึ้นมา:
PersonTest.java
// ... import com.example.birthdays.BirthdaysClient; // ... import static org.mockito.Mockito.mock; class PersonTest { private BirthdaysClient birthdaysClient = mock(BirthdaysClient.class); // ... @Test void testPublishAge() { // ... Person person = new Person("Joe", "Sixteen", dateOfBirth, ()->currentDate, birthdaysClient); // ... } }
นอกจากนี้เรายังเพิ่มลายเซ็นของตัวสร้าง Person
เพื่อใช้วัตถุ BirthdaysClient
และเปลี่ยนการทดสอบเพื่อฉีดวัตถุ BirthdaysClient
ที่เยาะเย้ย
เพิ่มความคาดหวังจำลอง
ต่อไป เราเพิ่มส่วนท้ายของ testPublishAge
ความคาดหวังว่ามีการเรียก BirthdaysClient
Person.publishAge()
ควรเรียกมันตามที่แสดงใน PersonTest.java
ใหม่ของเรา:
PersonTest.java
// ... class PersonTest { // ... @Test void testPublishAge() throws IOException { // ... Person person = new Person("Joe", "Sixteen", dateOfBirth, ()->currentDate, birthdaysClient); verifyZeroInteractions(birthdaysClient); person.publishAge(); verify(birthdaysClient).publishRegularPersonAge("Joe Sixteen", 16); } }
BirthdaysClient
ที่ปรับปรุง Mockito ของเราจะติดตามการเรียกทั้งหมดที่เกิดขึ้นกับวิธีการของมัน ซึ่งเป็นวิธีที่เราตรวจสอบว่าไม่มีการเรียกใด ๆ ไปยัง BirthdaysClient
ด้วย verifyZeroInteractions()
ก่อนเรียก publishAge()
แม้ว่าเนื้อหาจะไม่จำเป็น แต่ในการทำเช่นนี้ เรารับรองว่าคอนสตรัคเตอร์จะไม่ทำการเรียกอันธพาลใดๆ ในบรรทัด verify()
เราระบุว่าเราคาดหวังการเรียกไปยัง BirthdaysClient
อย่างไร
โปรดทราบว่าเนื่องจาก publishRegularPersonAge มี IOException ในลายเซ็น เราจึงเพิ่มลงในลายเซ็นวิธีการทดสอบของเราด้วย
ณ จุดนี้ การทดสอบล้มเหลว:
Wanted but not invoked: birthdaysClient.publishRegularPersonAge( "Joe Sixteen", 16L ); -> at com.example.PersonTest.testPublishAge(PersonTest.java:40)
นี่เป็นสิ่งที่คาดหวัง เนื่องจากเรายังไม่ได้ทำการเปลี่ยนแปลงที่จำเป็นกับ Person.java
เนื่องจากเรากำลังติดตามการพัฒนาที่ขับเคลื่อนด้วยการทดสอบ ตอนนี้เราจะทำการทดสอบนี้ผ่านโดยทำการเปลี่ยนแปลงที่จำเป็น:
Person.java
// ... class Person { // ... private final BirthdaysClient birthdaysClient; Person(String givenName, String surname, LocalDate dateOfBirth) { this(givenName, surname, dateOfBirth, LocalDate::now, new BirthdaysClient()); } // Visible for testing Person(String givenName, String surname, LocalDate dateOfBirth, Supplier<LocalDate> currentDateSupplier, BirthdaysClient birthdaysClient) { // ... this.birthdaysClient = birthdaysClient; } // ... void publishAge() { String nameToPublish = givenName + " " + surname; long age = getAge(); try { birthdaysClient.publishRegularPersonAge(nameToPublish, age); } catch (IOException e) { // TODO handle this! e.printStackTrace(); } } }
การทดสอบข้อยกเว้น
เราได้สร้างตัวสร้างโค้ดที่ใช้งานจริงสร้างอินสแตนซ์ BirthdaysClient
ใหม่และ publishAge()
ตอนนี้เรียก birthdaysClient
การทดสอบทั้งหมดผ่าน ทุกอย่างเป็นสีเขียว ยอดเยี่ยม! แต่สังเกตว่า publishAge()
กำลังกลืน IOException แทนที่จะปล่อยให้เป็นฟอง เราต้องการห่อด้วย PersonException ของเราเองในไฟล์ใหม่ชื่อ PersonException.java
:
PersonException.java
package com.example; public class PersonException extends Exception { public PersonException(String message, Throwable cause) { super(message, cause); } }
เราใช้สถานการณ์นี้เป็นวิธีการทดสอบใหม่ใน PersonTest.java
:
PersonTest.java
// ... class PersonTest { // ... @Test void testPublishAge_IOException() throws IOException { LocalDate dateOfBirth = LocalDate.parse("2000-01-02"); LocalDate currentDate = LocalDate.parse("2017-01-01"); Person person = new Person("Joe", "Sixteen", dateOfBirth, ()->currentDate, birthdaysClient); IOException ioException = new IOException(); doThrow(ioException).when(birthdaysClient).publishRegularPersonAge("Joe Sixteen", 16); try { person.publishAge(); fail("expected exception not thrown"); } catch (PersonException e) { assertSame(ioException, e.getCause()); assertEquals("Failed to publish Joe Sixteen age 16", e.getMessage()); } } }
Mockito doThrow()
เรียก stubs birthdaysClient
เพื่อสร้างข้อยกเว้นเมื่อมีการ publishRegularPersonAge()
ถ้า PersonException
ไม่ถูกส่งออกไป เราจะล้มเหลวในการทดสอบ มิฉะนั้น เราขอยืนยันว่าข้อยกเว้นถูกโยงอย่างเหมาะสมกับ IOException และตรวจสอบว่าข้อความแสดงข้อยกเว้นเป็นไปตามที่คาดไว้ ขณะนี้ เนื่องจากเราไม่ได้ใช้การจัดการใดๆ ในโค้ดที่ใช้งานจริง การทดสอบของเราจึงล้มเหลวเนื่องจากไม่มีข้อยกเว้นที่คาดหวัง นี่คือสิ่งที่เราต้องเปลี่ยนใน Person.java
เพื่อให้ผ่านการทดสอบ:
Person.java
// ... class Person { // ... void publishAge() throws PersonException { // ... try { // ... } catch (IOException e) { throw new PersonException("Failed to publish " + nameToPublish + " age " + age, e); } } }
Stubs: เมื่อไรและการยืนยัน
ตอนนี้เราใช้เมธอด Person.getThoseInCommon()
ทำให้คลาส Person.Java
ของเรามีลักษณะดังนี้
testGetThoseInCommon()
ของเรา ซึ่งแตกต่างจาก testPublishAge()
ไม่ได้ตรวจสอบว่ามีการเรียกใช้เมธอด birthdaysClient
โดยเฉพาะ แต่จะใช้ when
มีการเรียก stub คืนค่าสำหรับการเรียก findFamousNamesOfAge()
และ findFamousNamesBornOn()
ที่ getThoseInCommon()
จะต้องทำ จากนั้นเราขอยืนยันว่ามีการส่งคืนชื่อสตับทั้งสามที่เราให้ไว้
การห่อการยืนยันหลายรายการด้วย assertAll()
JUnit 5 อนุญาตให้ตรวจสอบการยืนยันทั้งหมดโดยรวม แทนที่จะหยุดหลังจากการยืนยันครั้งแรกล้มเหลว นอกจากนี้เรายังรวมข้อความที่มี assertTrue()
เพื่อระบุชื่อเฉพาะที่ไม่ได้รวมอยู่ด้วย วิธีทดสอบ "เส้นทางแห่งความสุข" (สถานการณ์ในอุดมคติ) ของเรามีดังต่อไปนี้ (โปรดทราบว่า นี่ไม่ใช่ชุดการทดสอบที่แข็งแกร่งโดยธรรมชาติของการเป็น "เส้นทางแห่งความสุข" แต่เราจะพูดถึงเหตุผลในภายหลัง
PersonTest.java
// ... class PersonTest { // ... @Test void testGetThoseInCommon() throws IOException, PersonException { LocalDate dateOfBirth = LocalDate.parse("2000-01-02"); LocalDate currentDate = LocalDate.parse("2017-01-01"); Person person = new Person("Joe", "Sixteen", dateOfBirth, ()->currentDate, birthdaysClient); when(birthdaysClient.findFamousNamesOfAge(16)).thenReturn(Arrays.asList("JoeFamous Sixteen", "Another Person")); when(birthdaysClient.findFamousNamesBornOn(1, 2)).thenReturn(Arrays.asList("Jan TwoKnown")); Set<String> thoseInCommon = person.getThoseInCommon(); assertAll( setContains(thoseInCommon, "Another Person"), setContains(thoseInCommon, "Jan TwoKnown"), setContains(thoseInCommon, "JoeFamous Sixteen"), ()-> assertEquals(3, thoseInCommon.size()) ); } private <T> Executable setContains(Set<T> set, T expected) { return () -> assertTrue(set.contains(expected), "Should contain " + expected); } // ... }
รักษารหัสทดสอบให้สะอาด
แม้ว่ามักจะถูกมองข้าม แต่ก็สำคัญไม่แพ้กันที่จะรักษาโค้ดทดสอบไม่ให้เกิดการซ้ำซ้อน โค้ดและหลักการที่สะอาดหมดจด เช่น "อย่าพูดซ้ำซาก" มีความสำคัญมากสำหรับการรักษาฐานโค้ดคุณภาพสูง โค้ดที่ใช้งานจริง และโค้ดทดสอบเหมือนกัน ขอให้สังเกตว่า PersonTest.java ล่าสุดมีการทำซ้ำบางส่วนในขณะนี้ ซึ่งเรามีวิธีการทดสอบหลายวิธี
ในการแก้ไขปัญหานี้ เราสามารถทำได้หลายอย่าง:
แยกวัตถุ IOException ลงในฟิลด์สุดท้ายส่วนตัว
แยกการสร้างอ็อบเจ็กต์
Person
เป็นเมธอดของตัวเอง (createJoeSixteenJan2()
ในกรณีนี้) เนื่องจากอ็อบเจ็กต์ Person ส่วนใหญ่ถูกสร้างขึ้นด้วยพารามิเตอร์เดียวกันสร้าง
assertCauseAndMessage()
สำหรับการทดสอบต่างๆ ที่ตรวจสอบการโยนPersonExceptions
ผลลัพธ์ของโค้ดสะอาดสามารถเห็นได้ในการแปลความหมายของไฟล์ PersonTest.java
ทดสอบมากกว่าเส้นทางแห่งความสุข
เราควรทำอย่างไรเมื่อวัตถุ Person
มีวันเกิดที่ช้ากว่าวันที่ปัจจุบัน? ข้อบกพร่องในการใช้งานมักเกิดจากการป้อนข้อมูลที่ไม่คาดคิดหรือขาดการมองการณ์ไกลในคดีมุม ขอบ หรือขอบเขต สิ่งสำคัญคือต้องพยายามคาดการณ์สถานการณ์เหล่านี้ให้ดีที่สุดเท่าที่จะทำได้ และการทดสอบหน่วยมักเป็นสถานที่ที่เหมาะสมในการทำเช่นนั้น ในการสร้าง Person
and PersonTest
ของเรา เราได้รวมการทดสอบสองสามอย่างสำหรับข้อยกเว้นที่คาดหวังไว้ แต่ก็ไม่ได้หมายความว่าจะเสร็จสมบูรณ์ ตัวอย่างเช่น เราใช้ LocalDate
ซึ่งไม่ได้แสดงหรือจัดเก็บข้อมูลเขตเวลา อย่างไรก็ตาม การเรียกของเราไปที่ LocalDate.now()
จะส่งคืน LocalDate
ตามเขตเวลาเริ่มต้นของระบบ ซึ่งอาจมาก่อนหรือช้ากว่าวันของผู้ใช้ระบบหนึ่งวัน ควรพิจารณาปัจจัยเหล่านี้ด้วยการทดสอบและพฤติกรรมที่เหมาะสม
ขอบเขตควรได้รับการทดสอบด้วย พิจารณาวัตถุ Person
ด้วย getDaysUntilBirthday()
การทดสอบควรรวมถึงว่าวันเกิดของบุคคลนั้นผ่านไปแล้วในปีปัจจุบันหรือไม่ วันเกิดของบุคคลนั้นคือวันนี้หรือไม่ และปีอธิกสุรทินส่งผลต่อจำนวนวันอย่างไร สถานการณ์เหล่านี้ครอบคลุมได้ด้วยการตรวจสอบหนึ่งวันก่อนวันเกิดของบุคคลนั้น วันที่ และหนึ่งวันหลังจากวันเกิดของบุคคลนั้น โดยที่ปีหน้าเป็นปีอธิกสุรทิน นี่คือรหัสทดสอบที่เกี่ยวข้อง:
PersonTest.java
// ... class PersonTest { private final Supplier<LocalDate> currentDateSupplier = ()-> LocalDate.parse("2015-05-02"); private final LocalDate ageJustOver5 = LocalDate.parse("2010-05-01"); private final LocalDate ageExactly5 = LocalDate.parse("2010-05-02"); private final LocalDate ageAlmost5 = LocalDate.parse("2010-05-03"); // ... @Test void testGetDaysUntilBirthday() { assertAll( createPersonAndAssertValue(ageAlmost5, 1, Person::getDaysUntilBirthday), createPersonAndAssertValue(ageExactly5, 0, Person::getDaysUntilBirthday), createPersonAndAssertValue(ageJustOver5, 365, Person::getDaysUntilBirthday) ); } private Executable createPersonAndAssertValue(LocalDate dateOfBirth, long expectedValue, Function<Person, Long> personLongFunction) { Person person = new Person("Given", "Sur", dateOfBirth, currentDateSupplier); long actualValue = personLongFunction.apply(person); return () -> assertEquals(expectedValue, actualValue); } }
การทดสอบบูรณาการ
เรามุ่งเน้นที่การทดสอบหน่วยเป็นส่วนใหญ่ แต่ JUnit ยังสามารถใช้สำหรับการรวม การยอมรับ การทำงาน และการทดสอบระบบ การทดสอบดังกล่าวมักต้องการรหัสการตั้งค่าเพิ่มเติม เช่น การเริ่มเซิร์ฟเวอร์ การโหลดฐานข้อมูลด้วยข้อมูลที่ทราบ เป็นต้น แม้ว่าเราจะสามารถเรียกใช้การทดสอบหน่วยได้หลายพันครั้งในไม่กี่วินาที แต่ชุดทดสอบการผสานรวมขนาดใหญ่อาจใช้เวลาเป็นนาทีหรือหลายชั่วโมง โดยทั่วไป ไม่ควรใช้การทดสอบการรวมเพื่อพยายามครอบคลุมทุกการเปลี่ยนแปลงหรือเส้นทางผ่านโค้ด การทดสอบหน่วยมีความเหมาะสมกว่าสำหรับสิ่งนั้น
การสร้างการทดสอบสำหรับเว็บแอปพลิเคชันที่ขับเคลื่อนเว็บเบราว์เซอร์ในการกรอกแบบฟอร์ม การคลิกปุ่ม รอการโหลดเนื้อหา ฯลฯ ทำได้โดยใช้ Selenium WebDriver (ใบอนุญาต Apache 2.0) ควบคู่ไปกับ 'Page Object Pattern' (ดู SeleniumHQ github wiki และบทความของ Martin Fowler เกี่ยวกับ Page Objects)
JUnit มีประสิทธิภาพสำหรับการทดสอบ RESTful API ด้วยการใช้ไคลเอ็นต์ HTTP เช่น Apache HTTP Client หรือ Spring Rest Template (HowToDoInJava.com เป็นตัวอย่างที่ดี)
ในกรณีของเรากับอ็อบเจ็กต์ Person
การทดสอบการรวมอาจเกี่ยวข้องกับการใช้ BirthdaysClient
จริงมากกว่าการจำลอง ด้วยการกำหนดค่าที่ระบุ URL พื้นฐานของบริการ People Birthdays การทดสอบการรวมจะใช้อินสแตนซ์การทดสอบของบริการดังกล่าว ตรวจสอบว่ามีการเผยแพร่วันเกิดไปยังบริการดังกล่าว และสร้างบุคคลที่มีชื่อเสียงในบริการที่จะถูกส่งคืน
คุณสมบัติ JUnit อื่น ๆ
JUnit มีคุณสมบัติเพิ่มเติมมากมายที่เรายังไม่ได้สำรวจในตัวอย่าง เราจะอธิบายบางส่วนและให้ข้อมูลอ้างอิงสำหรับผู้อื่น
อุปกรณ์ทดสอบ
ควรสังเกตว่า JUnit สร้างอินสแตนซ์ใหม่ของคลาสทดสอบสำหรับการรันแต่ละวิธี @Test
JUnit ยังจัดเตรียมตะขอคำอธิบายประกอบเพื่อเรียกใช้เมธอดเฉพาะก่อนหรือหลัง @Test
ทั้งหมดหรือแต่ละวิธี hook เหล่านี้มักใช้สำหรับการตั้งค่าหรือล้างฐานข้อมูลหรือวัตถุจำลอง และแตกต่างกันระหว่าง JUnit 4 และ 5
Junit4 | มิถุนายน 5 | สำหรับวิธีการคงที่? |
---|---|---|
@BeforeClass | @BeforeAll | ใช่ |
@AfterClass | @AfterAll | ใช่ |
@Before | @BeforeEach | ไม่ |
@After | @AfterEach | ไม่ |
ในตัวอย่าง PersonTest
ของเรา เราเลือกที่จะกำหนดค่าวัตถุจำลอง BirthdaysClient
ใน @Test
ด้วยตัวเอง แต่บางครั้ง โครงสร้างจำลองที่ซับซ้อนกว่านั้นจำเป็นต้องสร้างขึ้นจากหลายอ็อบเจ็กต์ @BeforeEach
(ใน JUnit 5) และ @Before
(ใน JUnit 4) มักจะเหมาะสมสำหรับสิ่งนี้
คำอธิบายประกอบ @After*
เป็นเรื่องปกติในการทดสอบการรวมมากกว่าการทดสอบหน่วย เนื่องจากการรวบรวมขยะ JVM จัดการอ็อบเจ็กต์ส่วนใหญ่ที่สร้างขึ้นสำหรับการทดสอบหน่วย คำอธิบายประกอบ @BeforeClass
และ @BeforeAll
มักใช้สำหรับการทดสอบการรวมที่จำเป็นต้องดำเนินการตั้งค่าและการแยกส่วนซึ่งมีค่าใช้จ่ายสูงเพียงครั้งเดียว แทนที่จะใช้สำหรับวิธีการทดสอบแต่ละวิธี
สำหรับ JUnit 4 โปรดดูคู่มือการติดตั้งการทดสอบ (แนวคิดทั่วไปยังคงใช้กับ JUnit 5)
ห้องทดสอบ
บางครั้งคุณต้องการเรียกใช้การทดสอบที่เกี่ยวข้องหลายรายการ แต่ไม่ใช่การทดสอบทั้งหมด ในกรณีนี้ การจัดกลุ่มการทดสอบสามารถประกอบเป็นชุดทดสอบได้ สำหรับวิธีการทำสิ่งนี้ใน JUnit 5 ให้ดูบทความ JUnit 5 ของ HowToProgram.xyz และในเอกสารประกอบของทีม JUnit สำหรับ JUnit 4
JUnit 5's @ Nested และ @DisplayName
JUnit 5 เพิ่มความสามารถในการใช้คลาสภายในที่ซ้อนกันแบบไม่คงที่เพื่อแสดงความสัมพันธ์ระหว่างการทดสอบได้ดียิ่งขึ้น สิ่งนี้น่าจะคุ้นเคยกับผู้ที่ทำงานกับ nested description ในกรอบการทดสอบเช่น Jasmine สำหรับ JavaScript คลาสภายในมีคำอธิบายประกอบด้วย @Nested
เพื่อใช้สิ่งนี้
คำอธิบายประกอบ @DisplayName
ยังใหม่สำหรับ JUnit 5 ซึ่งช่วยให้คุณอธิบายการทดสอบสำหรับการรายงานในรูปแบบสตริง ที่จะแสดงเพิ่มเติมจากตัวระบุวิธีการทดสอบ
แม้ว่า @Nested
และ @DisplayName
สามารถใช้แยกจากกัน แต่ก็สามารถให้ผลการทดสอบที่ชัดเจนขึ้นซึ่งอธิบายพฤติกรรมของระบบได้
แฮมเครส แมชเชอร์
เฟรมเวิร์ก Hamcrest แม้ว่าจะไม่ได้เป็นส่วนหนึ่งของโค้ดเบส JUnit แต่ก็ให้ทางเลือกอื่นแทนการใช้วิธีการยืนยันแบบเดิมในการทดสอบ ซึ่งช่วยให้โค้ดทดสอบแสดงออกและอ่านได้มากขึ้น ดูการตรวจสอบต่อไปนี้โดยใช้ทั้ง assertEquals ดั้งเดิมและ Hamcrest assertThat:
//Traditional assert assertEquals("Hayden, Josh", displayName); //Hamcrest assert assertThat(displayName, equalTo("Hayden, Josh"));
Hamcrest สามารถใช้ได้กับทั้ง JUnit 4 และ 5 บทช่วยสอนเกี่ยวกับ Hamcrest ของ Vogella.com ค่อนข้างครอบคลุม
แหล่งข้อมูลเพิ่มเติม
บทความ Unit Tests, How to Write Testable Code and Why it Matters ครอบคลุมตัวอย่างที่เฉพาะเจาะจงมากขึ้นของการเขียนโค้ดที่สะอาดและทดสอบได้
สร้างด้วยความมั่นใจ: คู่มือสำหรับการทดสอบ JUnit จะตรวจสอบวิธีการต่างๆ ในการทดสอบหน่วยและการทดสอบการรวม และเหตุใดจึงดีที่สุดที่จะเลือกและปฏิบัติตาม
คู่มือผู้ใช้ JUnit 4 Wiki และ JUnit 5 เป็นจุดอ้างอิงที่ยอดเยี่ยมเสมอ
เอกสาร Mockito ให้ข้อมูลเกี่ยวกับฟังก์ชันและตัวอย่างเพิ่มเติม
JUnit คือเส้นทางสู่การทำงานอัตโนมัติ
เราได้สำรวจหลายแง่มุมของการทดสอบในโลก Java ด้วย JUnit เราได้ดูการทดสอบหน่วยและการรวมโดยใช้เฟรมเวิร์ก JUnit สำหรับโค้ดเบส Java การผสานรวม JUnit ในสภาพแวดล้อมการพัฒนาและการสร้าง วิธีใช้ม็อคและสตับกับซัพพลายเออร์และ Mockito ข้อตกลงทั่วไปและแนวทางปฏิบัติเกี่ยวกับโค้ดที่ดีที่สุด สิ่งที่ต้องทดสอบ และบางส่วนของ คุณสมบัติ JUnit ที่ยอดเยี่ยมอื่น ๆ
ถึงเวลาของผู้อ่านที่จะเติบโตในการใช้ บำรุงรักษา และเก็บเกี่ยวผลประโยชน์จากการทดสอบอัตโนมัติโดยใช้กรอบงาน JUnit อย่างเชี่ยวชาญ