TDD and Junit 5 part 2/3
จริงๆคงไม่ใช่แค่ 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;
...
}
รันเทสอีกครั้ง — เทสผ่าน!