คู่มือการทดสอบหน่วยที่แข็งแกร่งและการรวมเข้ากับ 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 มีข้อได้เปรียบเพิ่มเติมในการเรียกใช้การทดสอบซึ่งเป็นส่วนหนึ่งของกระบวนการสร้าง

กลุ่มของเฟืองที่ระบุความเข้ากันได้: JUnit 4 กับ NetBeans ในหนึ่ง JUnit 5 ที่มี Eclipse และ Gradle ในอีกชุดหนึ่ง และชุดสุดท้ายมี JUnit 5 กับ Maven และ IntelliJ IDEA

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 อย่างเชี่ยวชาญ