TDD and Junit 5 part 1/3
เนื้อหานี้รวมความคิดที่ได้จากการฝึกเขียน TDD ครับ
TDD ย่อมาจาก Test-Driven Development เป็นแนวความคิดว่าด้วยการเขียนเทสแล้วรันให้พัง (fail) ก่อนค่อยไปเขียนโค้ด, โดยโค้ดที่เขียนขึ้นภายหลังจะทำให้โปรแกรมทำงานถูกต้อง
ส่วน Junit 5 (หรือเวอร์ชันก่อนหน้านี้) เป็นเพียงเครื่องมือเพื่อใช้ตรวจคำตอบของสิ่งที่ต้องการเท่านั้นเอง
จุดหมาย
เราจะใช้ Spring Boot แบ่งเป็น MVC ให้โปรเจกต์ตัวอย่างต่อไปนี้คือรายวิชาของผู้เรียน กำหนดให้ผู้เรียน 1 คนเรียนวิชาคณิต, วิทยาศาสตร์และประวัติศาสตร์ แต่ละวิชามีคะแนนเต็ม 100 คะแนน โปรเจกต์นี้จะเก็บคะแนนของผู้เรียนทุกคน
เตรียมโปรเจกต์
- ชื่อ student-subjects
- ใช้ Spring Boot 2.7.9, Maven, Java 1.8
- dependencies ได้แก่ Spring Web, Spring Data JPA, H2 Database, Lombok
โครงสร้างแรกเริ่ม
มองไปที่ src/test/java
ภายในจะพบชื่อ package ที่ตั้งไว้ ของผมคือ com.pros.studentsubjects
พร้อมกับชื่อคลาส StudentSubjectsApplicationTests
package com.pros.studentsubjects;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest
class StudentSubjectsApplicationTests {
@Test
void contextLoads() {
}
}
เมื่อ @SpringBootTest
อำนวยความสะดวก กำหนด ApplicationContext สำหรับการเทส ด้วยการเริ่มต้นค้นหาคลาสเทสจาก package ปัจจุบัน (ที่เขียน annotation นี้ไว้) ไล่ขึ้นไปผ่านโครงสร้าง package ที่เหลือ
คำถาม ไม่มี
@SpringBootTest
ได้หรือไม่ ? คำตอบ ได้ โดยเลือกใช้ annotation อื่นตามความต้องการทดสอบนั้นๆแทน
เมื่อเมธอด contextLoads เป็นแค่ชื่อที่ตั้งไว้เฉยๆเพื่อทดสอบว่า framework นั้นพร้อมทำงานกับ JUnit Jupiter (Junit 5)
ทดลองรันเทส
จากภาพข้างต้น การรันเทสด้วย IDEA ส่งผลให้โปรเจกต์ถูก boot เหมือนกับการรันตามปกติ (โปรดสังเกตจาก log ที่เกิดขึ้น) จำนวนของเทสคือ 1 และมันไม่ถูกกำหนดผลลัพธ์ให้ตรวจสอบใดๆ ดังนั้น test passed
Model Tests
กลับมาที่จุดหมายตัวอย่างของเรา เราต้องการผู้เรียน 1 คน ที่สามารถเรียนวิชาคณิต, วิทยาศาสตร์และประวัติศาสตร์
- ผู้เรียน ให้เป็นคลาส Student
- วิชาคณิต ให้เป็น Math Subject
- วิทยาศาสตร์ ให้เป็น Science Subject
- ประวัติศาสตร์ ให้เป็น History Subject
ที่ src/test/java
เพิ่ม package ชื่อ model ประกอบด้วย StudentTest, MathSubjectTest, ScienceSubjectTests และ HistorySubjectTest
ขนาดนี้เลยเหรอ ?
StudentTest
กำหนดให้ Student ประกอบด้วย first name, last name และ email ซึ่งต้องส่งค่าเหล่านี้ผ่าน constructor ได้ด้วย
รันให้เทสนี้พังก่อน
เขียนโค้ดให้เทสผ่าน
ให้ IDEA สร้างคลาส Student ให้
คลาสที่สร้างขึ้นยังเหลือว่าส่งค่าผ่าน constructor ไม่ได้
เมื่อเราใช้ Lombok เพื่อทำให้โค้ดจาวาสั้นลงแต่ยังคงได้ความหมาย ดังนั้นเปิดไปที่คลาส Student เพิ่ม @AllArgsConstructor
พร้อมกับ attributes ที่ต้องการ
package com.pros.studentsubjects.model;
import lombok.AllArgsConstructor;
@AllArgsConstructor
public class Student {
private String firstName;
private String lastName;
private String email;
}
กลับมาที่คลาส StudentTest ให้ Junit 5 ตรวจสิ่งที่ expected นั่นคือ ออบเจกต์ที่เกิดขึ้นต้องไม่ null (หรือจะใช้ assertions อื่นที่เห็นควร)
package com.pros.studentsubjects.model;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertNotNull;
public class StudentTest {
@Test
void createStudent() {
Student student = new Student("Phai", "Panda", "panda_phai@mail.com");
assertNotNull(student);
}
}
รันเทสอีกครั้ง
ผลลัพธ์ — เทสผ่าน!
เรายังสามารถดูได้อีกว่าคลาสที่ถูกเทสนั้นๆได้เขียนเทสครอบคลุมแล้วหรือไม่ ขาดอีกเท่าไหร่ คิดเป็นกี่เปอร์เซ็นต์ โดยทั่วไปต้องการที่ 80%
จากเหตุการณ์ที่เกิดขึ้น มี 2 อย่าง อย่างแรกเราต้องสร้างนิสัยให้เขียนเทสก่อน (ให้ชิน) คิดถึงสิ่งที่ต้องการ คิดถึงวิธีการเทส แล้วรันให้เทสพัง อย่างที่สองจึงไปเขียนโค้ด (write real code) เพื่อให้เทสนั้นรันผ่าน
คำถาม เขียนเทสที่ว่ากองอยู่ที่เดียวกันได้ไหม ไม่ต้องแยกเป็นคลาสๆไป ? คำตอบ ได้
คำถาม ปกติเมื่อรันเทสผ่านแล้ว สามารถ refactor code ได้ถูกไหม ? คำตอบ ใช่ เพราะเรามีเทสไว้การันตีอยู่แล้วว่าที่แก้ไขไปนั้น (refactor) จะยังทำงานถูกต้อง
ได้เป็นภาพนี้
ขอให้สังเกตเพิ่มเติมเกี่ยวกับ code ที่ได้เทสผ่านไปแล้ว
จะมีสัญลักษณ์สีเขียวเกิดขึ้นบริเวณที่ถูกเทสผ่านแล้ว ในที่นี้คือ Student ประกอบด้วย first name, last name และ email ซึ่งค่าเหล่านี้ส่งผ่าน constructor โดยที่ @AllArgsConstructor
รับผิดชอบดูแลให้
MathSubjectTest
กำหนดให้ MathSubject ประกอบด้วย score ซึ่งต้องส่งค่าทาง constructor
รันให้เทสนี้พังก่อน
เขียนโค้ดให้เทสผ่าน
package com.pros.studentsubjects.model;
import lombok.AllArgsConstructor;
@AllArgsConstructor
public class MathSubject {
private double score;
}
รันเทสอีกครั้ง
ผลลัพธ์ — เทสผ่าน!
ScienceSubjectTest และ HistorySubjectTest
package com.pros.studentsubjects.model;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertNotNull;
public class ScienceSubjectTest {
@Test
void createScienceSubject() {
ScienceSubject subject = new ScienceSubject(80.0);
assertNotNull(subject);
}
}
package com.pros.studentsubjects.model;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertNotNull;
public class HistorySubjectTest {
@Test
void createHistorySubject() {
HistorySubject subject = new HistorySubject(60.5);
assertNotNull(subject);
}
}
จะเห็นว่าแต่ละวิชามีคะแนนเป็นของตัวเอง แต่ยังไม่รู้ว่าเป็นของผู้เรียนคนใด ฉะนั้นปรับปรุงเพิ่ม student id ให้เป็นพารามิเตอร์ที่สอง
MathSubjectTest เพิ่ม student id
กำหนดให้เทส student id มีค่าเป็น 1 แล้วกันนะ
รันให้เทสนี้พังก่อน
เขียนโค้ดให้เทสผ่าน
package com.pros.studentsubjects.model;
import lombok.AllArgsConstructor;
@AllArgsConstructor
public class MathSubject {
private double score;
private int studentId;
}
วิชาอื่นๆก็ทำแบบเดียวกัน
ผู้เรียนแต่ละคนสามารถเก็บคะแนนวิชาคณิต, วิทยาศาสตร์และประวัติศาสตร์ ได้หลายครั้ง (ในแต่ละภาคเรียน) ดังนั้นเราต้องการ List ของวิชาเหล่านั้นและสร้างเป็น model ให้ชื่อว่า StudentSubjects
StudentSubjects
ได้รวบรวมวิชาของผู้เรียนทุกคนไว้ที่นี่ เก็บแต่ละวิชาแยกเป็นรายการไป
StudentSubjectsTest
สร้างคลาสเทสชื่อ StudentSubjectsTest เพื่อเทส StudentSubjects
รันให้เทสนี้พังก่อน
เขียนโค้ดให้เทสผ่าน — สร้างคลาส StudentSubjects
package com.pros.studentsubjects.model;
public class StudentSubjects {
}
เขียนโค้ดให้เทสผ่าน — กำหนด attributes ที่เหลือ
package com.pros.studentsubjects.model;
import lombok.Getter;
import java.util.List;
@Getter
public class StudentSubjects {
private List<MathSubject> mathSubjectList;
private List<ScienceSubject> scienceSubjectList;
private List<HistorySubject> historySubjectList;
}
รันเทสอีกครั้ง
จากโค้ดเราได้เพิ่ม assertNull เพื่อการันตีว่าแรกเริ่มที่ model นี้ถูกสร้างขึ้น รายการวิชาต่างๆจะต้องเป็น null และก็สมประสงค์ความต้องการ
ถัดมาเราสังเกตว่าแต่ละวิชาจะมี score กับ student id คงจะดีถ้าเรา refactor โค้ดส่วนนี้
เห็นไหม เทสต้องผ่านหมดแล้วนะจึงควรไป refactor
Subject
กำหนดให้เป็น abstract ประกอบด้วย score กับ student id
package com.pros.studentsubjects.model;
import lombok.AllArgsConstructor;
@AllArgsConstructor
public abstract class Subject {
private double score;
private int studentId;
}
ทีนี้ก็เริ่มแก้ไข
เมื่อย้าย score กับ student id ไปไว้ใน Subject นั่นเท่ากับว่า @AllArgsConstructor
ของคลาสลูกนี้ต้องลบออก
แก้ไขให้เรียก super ได้
package com.pros.studentsubjects.model;
public class MathSubject extends Subject {
public MathSubject(double score, int studentId) {
super(score, studentId);
}
}
วิชาที่เหลือก็ทำเหมือนกัน
แก้ไข StudentSubjects ให้ไปใช้ Subject แทน
package com.pros.studentsubjects.model;
import lombok.Getter;
import java.util.List;
@Getter
public class StudentSubjects {
private List<Subject> mathSubjectList;
private List<Subject> scienceSubjectList;
private List<Subject> historySubjectList;
}
รันเทสทั้งหมดให้ผ่าน
แก้ไข configuration ของ IDEA ก่อน
ผลลัพธ์ — เทสผ่าน!