TDD and Junit 5 part 1/3

Phai Panda
5 min readMar 22, 2023

--

เนื้อหานี้รวมความคิดที่ได้จากการฝึกเขียน 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

ขนาดนี้เลยเหรอ ?

สร้างคลาสเทส โดยเติม Test ต่อท้าย (แนวทางเฉยๆ)

StudentTest

กำหนดให้ Student ประกอบด้วย first name, last name และ email ซึ่งต้องส่งค่าเหล่านี้ผ่าน constructor ได้ด้วย

รันให้เทสนี้พังก่อน

หาคลาส Student ไม่พบ

เขียนโค้ดให้เทสผ่าน

ให้ IDEA สร้างคลาส Student ให้

เปลี่ยนแหล่งจาก src/test เป็น src/main โดยกดปุ่มไข่ปลา

คลาสที่สร้างขึ้นยังเหลือว่าส่งค่าผ่าน 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%

run with coverage

จากเหตุการณ์ที่เกิดขึ้น มี 2 อย่าง อย่างแรกเราต้องสร้างนิสัยให้เขียนเทสก่อน (ให้ชิน) คิดถึงสิ่งที่ต้องการ คิดถึงวิธีการเทส แล้วรันให้เทสพัง อย่างที่สองจึงไปเขียนโค้ด (write real code) เพื่อให้เทสนั้นรันผ่าน

test failed before passed

คำถาม เขียนเทสที่ว่ากองอยู่ที่เดียวกันได้ไหม ไม่ต้องแยกเป็นคลาสๆไป ? คำตอบ ได้

คำถาม ปกติเมื่อรันเทสผ่านแล้ว สามารถ refactor code ได้ถูกไหม ? คำตอบ ใช่ เพราะเรามีเทสไว้การันตีอยู่แล้วว่าที่แก้ไขไปนั้น (refactor) จะยังทำงานถูกต้อง

ได้เป็นภาพนี้

refactor after all tests are passed

ขอให้สังเกตเพิ่มเติมเกี่ยวกับ 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;
}
parent & child relation

ทีนี้ก็เริ่มแก้ไข

เมื่อย้าย 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 ก่อน

เลือก Edit Configurations
เลือกเป็น All in package และกำหนด package ที่ต้องการ

ผลลัพธ์ — เทสผ่าน!

code coverage เต็ม 100%

--

--

No responses yet