TDD and Junit 5 part 2/3

Phai Panda
6 min readMar 22, 2023

--

จริงๆคงไม่ใช่แค่ Junit แต่ต้องเพิ่ม Mockito ด้วย ซึ่งมีมาให้แล้ว (included) ใน spring-boot-starter-test

จุดหมาย

เราต้องการ service มาคอยบริการรวมคะแนนของแต่ละวิชา รวมถึงหาค่าเฉลี่ยของวิชาเหล่านั้นให้ด้วย โดยไม่สนใจว่าจะเป็นของนักเรียนคนไหน

คิดถึงเทสก่อนเสมอ!

Service Tests

สร้าง package ชื่อ service ไว้ใต้ src/test/java/com/pros/studentsubjects เช่นเดียวกับ model ก่อนหน้านี้

สร้างคลาสเทสชื่อ StudentSubjectsServiceTest

StudentSubjectsServiceTest

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

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

package com.pros.studentsubjects.service;

public class StudentSubjectsService {
}

รันเทสอีกครั้ง — เทสผ่าน!

แต่โดยปกติแล้วเรามักให้ framework เป็นผู้เตรียมสิ่งที่เรียกว่า bean ให้ ดังนั้นจากโค้ดข้างต้นเราจะไม่ new เอง

แก้ไขคลาส StudentSubjectsService เพิ่ม @Service

package com.pros.studentsubjects.service;

import org.springframework.stereotype.Service;

@Service
public class StudentSubjectsService {
}

แก้ไขคลาส StudentSubjectsServiceTest ให้นำ service ที่ต้องการมาให้เลย

package com.pros.studentsubjects.service;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;

import static org.junit.jupiter.api.Assertions.assertNotNull;

public class StudentSubjectsServiceTest {
@Autowired
StudentSubjectsService service;

@Test
void createStudentSubjectsService() {
assertNotNull(service);
}
}

รันเทสอีกครั้ง — เทสไม่ผ่าน

เหตุเพราะว่าเราใช้ @Autowired บอก framework ช่วย new ให้หน่อย ทว่าคลาสเทสนี้กลับไม่อยู่ในบริบท (context) ของ ApplicationContext

จำต้องแก้ไข เพิ่ม @SpringBootTest

package com.pros.studentsubjects.service;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import static org.junit.jupiter.api.Assertions.assertNotNull;

@SpringBootTest
public class StudentSubjectsServiceTest {
@Autowired
StudentSubjectsService service;

@Test
void createStudentSubjectsService() {
assertNotNull(service);
}
}

รันเทสอีกครั้ง — เทสผ่าน!

ให้สังเกตจาก log ว่า @SpringBootTest จะ boot application context ทั้งหมดขึ้นมา จากนั้นจึงจะเทส

กลับไปที่จุดหมาย

  • บริการรวมคะแนนของแต่ละวิชา (sum scores)
  • หาค่าเฉลี่ยของวิชานั้นๆ (average scores)

บริการรวมคะแนน กำหนดให้รับเป็น list แล้วส่งผลรวมเป็น double

StudentSubjectsServiceTest — sumScores

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

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

แก้ไขคลาส StudentSubjectsService

package com.pros.studentsubjects.service;

import com.pros.studentsubjects.model.Subject;
import org.springframework.stereotype.Service;

import java.util.List;

@Service
public class StudentSubjectsService {
public double sumScores(List<Subject> list) {
double result = 0;
for (Subject subject : list) {
result += subject.getScore();
}
return result;
}
}

แก้ไขคลาส Score เพิ่ม @Getter

package com.pros.studentsubjects.model;

import lombok.AllArgsConstructor;
import lombok.Getter;

@Getter
@AllArgsConstructor
public abstract class Subject {
private double score;
private int studentId;
}

รันเทสอีกครั้ง — เทสผ่าน!

เมื่อเทสผ่านแล้ว ก็สามารถ refactor โค้ดให้สวยๆได้

ใช้ความสามารถของ Java 1.8 stream โดยฟังก์ชัน reduce

แก้ไขคลาส StudentSubjectsService

package com.pros.studentsubjects.service;

import com.pros.studentsubjects.model.Subject;
import org.springframework.stereotype.Service;

import java.util.List;

@Service
public class StudentSubjectsService {
public double sumScores(List<Subject> list) {
return list.stream().map(s -> s.getScore()).reduce(0.0, Double::sum);
}
}

รันเทสอีกครั้ง — เทสผ่าน!

หมายเหตุ ขอเปลี่ยนชื่อเมธอด (ไม่ได้สำคัญอะไร) จาก testSum เป็น testSumScore

StudentSubjectsServiceTest — averageScores

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

เพราะ averageScores ที่ IDEA generate ให้ผมเขียนไวๆให้ return 0 ออกไปก่อน เพื่อให้โค้ดสามารถ compile ได้

package com.pros.studentsubjects.service;

import com.pros.studentsubjects.model.Subject;
import org.springframework.stereotype.Service;

import java.util.List;

@Service
public class StudentSubjectsService {
public double sumScores(List<Subject> list) {
return list.stream().map(s -> s.getScore()).reduce(0.0, Double::sum);
}

public double averageScores(List<Subject> list) {
return 0;
}
}

แก้ไขเพื่อให้ผลลัพธ์ถูกต้อง

public double averageScores(List<Subject> list) {
return list.stream().map(s -> s.getScore()).reduce(0.0, Double::sum) / list.size();
}

รันเทสอีกครั้ง — เทสผ่าน!

ต่อด้วยการไป refactor เพื่อ reuse code

public double averageScores(List<Subject> list) {
return sumScores(list) / list.size();
}

อย่าลืมรันเทสอีกครั้ง

Spring Data JPA

เราต้องการให้โปรเจกต์นี้สามารถเก็บคะแนนของแต่ละวิชาลงฐานข้อมูลได้

กลับไปที่ model เราสามารถเพิ่ม @Entity เพื่อระบุไปยังตารางจริงๆ

แก้ไขคลาส MathSubject เพิ่ม column ชื่อ id

package com.pros.studentsubjects.model;

import javax.persistence.Entity;

@Entity
public class MathSubject extends Subject {
public MathSubject(int id, double score, int studentId) {
super(id, score, studentId);
}
}

แก้ไขคลาส Subject ให้มีสมบัติถ่ายทอด id, score และ student id ผูกเป็น column ในตาราง math_subject ด้วย @MappedSuperclass

package com.pros.studentsubjects.model;

import lombok.AllArgsConstructor;
import lombok.Getter;

import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.MappedSuperclass;

@MappedSuperclass
@Getter
@AllArgsConstructor
public abstract class Subject {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private int id;
private double score;
private int studentId;
}

แก้ไขคลาส MathSubjectTest ใส่ id เป็น 1 ไปก่อน (แรกสุด)

package com.pros.studentsubjects.model;

import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertNotNull;

public class MathSubjectTest {
@Test
void createMathSubject() {
MathSubject subject = new MathSubject(1, 70.5, 1);
assertNotNull(subject);
}
}

ที่อื่นๆที่ IDEA บอก

วิชาอื่นๆก็ให้แก้ไขแบบเดียวกัน ที่คลาสเทสด้วยนะ

package com.pros.studentsubjects.model;

public class HistorySubject extends Subject {
public HistorySubject(int id, double score, int studentId) {
super(id, score, studentId);
}
}
package com.pros.studentsubjects.model;

public class ScienceSubject extends Subject {
public ScienceSubject(int id, double score, int studentId) {
super(id, score, studentId);
}
}

กลับไปรันโปรเจกต์ตามปกติ

ผลลัพธ์ต้องรันได้

Tomcat started on port(s): 8080 (http) with context path ‘’

Started StudentSubjectsApplication in 1.687 seconds (JVM running for 1.96)

ไฟล์ application.properties ที่ resources folder ตั้งค่าเชื่อมต่อฐานข้อมูล

spring.datasource.url=jdbc:h2:mem:studentsubjects
spring.datasource.driverClassName=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=password
spring.h2.console.enabled=true
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
spring.jpa.hibernate.ddl-auto=create-drop
spring.jpa.show-sql = true

เปิดไปที่ http://localhost:8080/h2-console/

จะพบกับ table ที่ถูกจับคู่กับ model

เพิ่ม @Entity ให้ครบทุกวิชา

Create Repositories

JPA อำนวยความสะดวกในการเข้าถึงข้อมูลในฐานข้อมูลเพียงสร้าง interface บอกแก่มันเท่านั้น

คิดถึงเทสก่อนเสมอ!

สร้าง package ชื่อ repository ไว้ใต้ src/test/java/com/pros/studentsubjects เช่นเดียวกับ service ก่อนหน้านี้

สร้างคลาสเทสชื่อ MathSubjectRepositoryTest ไว้ใน repository package

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

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

package com.pros.studentsubjects.repository;

import com.pros.studentsubjects.model.MathSubject;
import org.springframework.data.repository.CrudRepository;

public interface MathSubjectRepository extends CrudRepository<MathSubject, Integer> {
}

รันเทสอีกครั้ง — เทสผ่าน!

ทำเช่นเดียวกันนี้กับวิชาที่เหลือ ScienceSubjectRepositoryTest และ HistorySubjectRepositoryTest

เกือบลืม StudentRepositoryTest

package com.pros.studentsubjects.repository;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import static org.junit.jupiter.api.Assertions.assertNotNull;

@SpringBootTest
public class StudentRepositoryTest {
@Autowired
StudentRepository repository;

@Test
void createStudentRepository() {
assertNotNull(repository);
}
}
package com.pros.studentsubjects.repository;

import com.pros.studentsubjects.model.Student;
import org.springframework.data.repository.CrudRepository;

public interface StudentRepository extends CrudRepository<Student, Integer> {
}

แก้ไขคลาส Student ด้วย

package com.pros.studentsubjects.model;

import lombok.AllArgsConstructor;

import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;

@Entity
@AllArgsConstructor
public class Student {
@Id
@GeneratedValue(strategy= GenerationType.IDENTITY)
private int id;
private String firstName;
private String lastName;
private String email;
}

Service Tests — Mockito

กลับมาที่ service tests เราต้องการให้ StudentSubjectsServiceTest สามารถเพิ่ม student ผ่าน repository

แต่เราไม่ต้องการ repository ที่บันทึกข้อมูลลงในฐานข้อมูลจริง เราต้องการแค่กระบวนการบันทึกแค่นั้น เราเรียกสิ่งนี้ว่า mock

กระบวนการ mock เกิดขึ้นภายใน memory

StudentSubjectsServiceTest — createStudent

ให้ import static org.mockito.Mockito.*; เพื่อใช้

  • when จะทำงานกับ mock ในที่นี้จะ mock StudentRepository ต้องไม่ให้ทำงาน insert ข้อมูลจริง
  • any คือใดๆที่เราไม่สนใจจะทดสอบ
  • verify ใช้ตรวจสอบ mock ว่าจะเรียกใช้เมธอดอะไร ในที่นี้คือ createStudent
  • times รับประกันว่าเรียก mock ตามจำนวนครั้งที่ระบุ ในที่นี้คือเพียงครั้งเดียว

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

@SpringBootTest
public class StudentSubjectsServiceTest {
@Autowired
private StudentSubjectsService service;

@MockBean
StudentRepository studentRepository;

...
}

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

package com.pros.studentsubjects.repository;

import com.pros.studentsubjects.model.Student;
import org.springframework.data.repository.CrudRepository;

import java.util.Optional;

public interface StudentRepository extends CrudRepository<Student, Integer> {
Optional<Student> createStudent(Student student);
}

และ

package com.pros.studentsubjects.service;

import com.pros.studentsubjects.model.Student;
import com.pros.studentsubjects.model.Subject;
import org.springframework.stereotype.Service;

import java.util.List;
import java.util.Optional;

@Service
public class StudentSubjectsService {
public double sumScores(List<Subject> list) {
return list.stream().map(s -> s.getScore()).reduce(0.0, Double::sum);
}

public double averageScores(List<Subject> list) {
return sumScores(list) / list.size();
}

public Optional<Student> createStudent(Student student) {
return null;
}
}

รันเทส — ไม่ผ่าน

แก้โค้ด เพิ่มการเรียก StudentRepository

package com.pros.studentsubjects.service;

import com.pros.studentsubjects.model.Student;
import com.pros.studentsubjects.model.Subject;
import com.pros.studentsubjects.repository.StudentRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.util.List;
import java.util.Optional;

@Service
public class StudentSubjectsService {
@Autowired
private StudentRepository studentRepository;

public double sumScores(List<Subject> list) {
return list.stream().map(s -> s.getScore()).reduce(0.0, Double::sum);
}

public double averageScores(List<Subject> list) {
return sumScores(list) / list.size();
}

public Optional<Student> createStudent(Student student) {
return studentRepository.createStudent(student);
}
}

รันเทสอีกครั้ง — เทสผ่าน!

ทดลองเปลี่ยนจำนวน times ที่ต้องเรียก 1 ครั้งเป็น 2 ครั้ง — เทสต้องพัง

ได้ผลตามคาดหมาย

อย่าลืมเปลี่ยนกลับล่ะ

จำได้ไหมที่เราบอกกันว่า @SpringBootTest จะ boot application context ทั้งหมดขึ้นมา จากนั้นจึงจะทำเทส คงดีกว่านี้ถ้าเราจำกัด context เฉพาะสิ่งที่เราต้องการ นั่นคือแค่ Mockito เท่านั้น

@ExtendWith(MockitoExtension.class)

เมื่อไม่ต้องการ context ในรูป ApplicationContext ก็หาเท่าที่จำเป็น

ที่คลาส StudentSubjectsServiceTest ลองเปลี่ยนจาก @SpringBootTest เป็น @ExtendWith(MockitoExtension.class)

นั่นเท่ากับว่า @Autowired จะใช้ไม่ได้ ให้เปลี่ยนเป็น @InjectMocks แทน

เท่ากับว่า @MockBean จะใช้ไม่ได้ ให้เปลี่ยนเป็น @Mock แทน

package com.pros.studentsubjects.service;

import com.pros.studentsubjects.model.MathSubject;
import com.pros.studentsubjects.model.Student;
import com.pros.studentsubjects.model.Subject;
import com.pros.studentsubjects.repository.StudentRepository;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

import java.util.Arrays;
import java.util.List;
import java.util.Optional;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.mockito.Mockito.*;

@ExtendWith(MockitoExtension.class)
public class StudentSubjectsServiceTest {
@InjectMocks
private StudentSubjectsService service;

@Mock
StudentRepository studentRepository;
...
}

รันเทสอีกครั้ง — เทสผ่าน!

--

--