跳转至
  • JUnit 是 java單元測試的必備工具
  • 只要方法上加上 @Test ,即可生成一個單元測試

pom.xml

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
    <exclusions>
        <exclusion>
            <groupId>org.junit.vintage</groupId>
            <artifactId>junit-vintage-engine</artifactId>
        </exclusion>
    </exclusions>
</dependency>

JUnit 和 Spring Boot 的版本關係

Spring Boot 版本為2.1

  • 僅能使用 JUnit4

Spring Boot 版本為2.2、2.3 >需要在 pom.xml 加上額外設定 , 才能禁用 JUnit4

  • 能同時使用 JUnit4 和 JUnit5

Spring Boot 版本為2.4

  • 僅能使用JUnit5

JUnit5 的用法

@Test 加在方法上 ,可以生成一個單元測試

  1. @Test 只能在 test 資料夾底下使用 ,可以將該方法變成可執行的 test case (測項)
  2. 方法為 public void ,並且沒有任何參數
  3. 方法名稱可以隨便取 ,用來表達這個test case 想測試哪個功能點 ,方法名稱是精華所在

JUnit5

Assert 用法

若是不符合 assert 斷言的預期結果 ,即測試失敗

Assert 系列用法 用途
assertNull(A) 斷言 A 為 null
assertNotNull(A) 斷言 A 不為 null
assertEquals(A ,B) 斷言 A 和 B 相等 , 會使用 equals() 方法來判斷
assertTrue(A) 斷言 A 為 true
assertFalse(A) 斷言 A 為 false
assertThrows(exception ,method) 斷言執行 method 時 ,會噴出 exception

練習

Calculator

package com.example.demo.UnitTest;

public class Calculator {
    public int add(int x ,int y){
        return x + y;
    }

    public int divide(int x ,int y){
        return x /y;
    }
    public static void main(String[] args) {
        Calculator calculator = new Calculator();
        int result = calculator.add(1,2);
        System.out.println(result);
    }
}

CalculatorTest

單元測試 Calculator 裡面的方法

package com.example.demo.UnitTest;

import org.junit.jupiter.api.Test;

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

public class CalculatorTest {
    @Test
    public void add(){
        Calculator calculator = new Calculator();
        int result = calculator.add(1,2);
        assertNotNull(result);
        assertTrue(result > 1);
    }

    @Test
    public void divide(){
        Calculator calculator = new Calculator();
        // calculator.divide 這個方法去除以0 的時候 確實噴出了 ArithmeticException 這個 exception
        assertThrows(ArithmeticException.class ,() -> {
            calculator.divide(1,0);
        }); 
    }
}

assertThrows

assertThrows(XxxException.class ,() -> { 
    //要判斷的方法 
}); 

所有的 assert 系列方法都可以在最後面加上一個字串的參數去描述這個錯誤的原因是甚麼

但一般來說 非常需要說明的情況才會加上描述

package com.example.demo.UnitTest;

import org.junit.jupiter.api.Test;

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

public class CalculatorTest {
    @Test
    public void add(){
        Calculator calculator = new Calculator();
        int result = calculator.add(1,2);
        assertEquals(5 ,result ,"加法有問題"); 
    }
}

JUnit5 其他常用註解

創建一個MyTest 測試 ,注意位置要放在 test 資料夾下

package com.example.demo.UnitTest;

import org.aspectj.lang.annotation.Before;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

public class MyTest {
    @Test
    public void test1(){
        System.out.println("執行 test1");
    }
    @Test
    public void test2(){
        System.out.println("執行 test2");
    }
}

加上 @BeforeEach

package com.example.demo.UnitTest;

import org.aspectj.lang.annotation.Before;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

public class MyTest {

    @BeforeEach
    public void beforeEach(){
        System.out.println("執行 @BeforeEaach");
    }
    @Test
    public void test1(){
        System.out.println("執行 test1");
    }
    @Test
    public void test2(){
        System.out.println("執行 test2");
    }
}

console:

執行 @BeforeEaach
執行 test1
執行 @BeforeEaach
執行 test2

加上 @AfterEach

console:

執行 test1
執行 @AfterEach
執行 test2
執行 @AfterEach

@Before 跟 @After each、all

@BeforeEach、@AfterEach >很常用

  • @BeforeEach 在每次@Test 開始前 ,都會執行一次
  • @AfterEach 在每次@Test 結束後 ,都會執行一次

@BeforeAll、@AfterAll >不常用 ,注意方法必須為 public static void

  • @BeforeAll 在所有@Test 開始前執行一次
  • @AfterAll 在所有@Test 結束後執行一次

@BeforeAll、@AfterAll 這個 static 的限制 , 讓他們不能夠去存取到 Spring 容器中的bean ,就不能去對bean 進行一些設定

package com.example.demo.UnitTest;

import org.aspectj.lang.annotation.Before;
import org.junit.jupiter.api.*;

public class MyTest {
    @BeforeEach
    public void beforeEach(){
        System.out.println("執行 @BeforeEaach");
    }
    @AfterEach 
    public void afterEach(){
        System.out.println("執行 @afterEaach");
    }

    @BeforeAll
    public static void beforeAll(){
        System.out.println("執行 @BeforeAll");
    }  
    @AfterAll
    public static void afterAll(){
        System.out.println("執行 @BeforeAll");
    }
}

@Disabled、@DisplayNam

@Disabled 忽略該@Test 不執行

@DisplayNam 自定義顯示名稱

package com.example.demo.UnitTest;

import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

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

public class CalculatorTest {
    @Disabled
    @Test
    public void add(){
        Calculator calculator = new Calculator();
        int result = calculator.add(1,2);
        assertEquals(3 ,result);
    }

    @DisplayName("測試除法問題")
    @Test
    public void divide(){
        Calculator calculator = new Calculator();
        assertThrows(ArithmeticException.class ,() -> {
            calculator.divide(1,0);
        }); // calculator.divide 這個方法去除以0 的時候 確實噴出了 ArithmeticException 這個 exception
    }
}

如何用JUnit5 去測試 Spring Boot 程式

事前準備

application.properties

spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.url=jdbc:mysql://localhost:3308/mytest?serverTimezone=Asia/Taipei&characterEncoding=utf-8
spring.datasource.username=root
spring.datasource.password=ji#@k7au$a83

SQL

create database mytest;
use mytest;
CREATE TABLE student (
    id INT PRIMARY KEY AUTO_INCREMENT,
    name VARCHAR(30),
    score DOUBLE,
    graduate BOOLEAN,
    create_date TIMESTAMP
);

INSERT INTO student (name, score, graduate, create_date) VALUES ('Amy', 90.3, true, '2021-09-01 10:20:33');
INSERT INTO student (name, score, graduate, create_date) VALUES ('Rom', 34.6, false, '2021-08-10 17:21:14');
INSERT INTO student (name, score, graduate, create_date) VALUES ('Judy', 100.0, true, '2021-09-05 12:19:48');
INSERT INTO student (name, score, graduate, create_date) VALUES ('Mike', 87.2, true, '2021-09-03 15:01:15');

撰寫 Service 層 、Dao層測試

使用JUnit5 測試 Spring Boot 程式

只要在測試的class上加上 @SpringBootTest ,運行單元測試時 , Spring Boot 就會去啟動 Spring 容器 ,創建所有的bean 出來

@SpringBootTest
public class DemoApplicationTests{
    @Autowired
    private StudentDao studentDao;

    @Test
    public void getById(){
        studentDao.getById(1);
    }
}

@SpringBootTest 不只是創建 bean , 所有的 @Configuration 設定也都會被執行 ,效果等同於直接運行起 Spring Boot 程式

單元測試 Dao getById()、deleteById()

DaoImpl 新增一個刪除

package com.example.demo.dao;

import com.example.demo.JPA.Student;
import com.example.demo.StudentRowMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.stereotype.Component;

import java.util.HashMap;
import java.util.List;
import java.util.Map;

@Component
public class StudentDaoImpl implements StudentDao{
    @Autowired
    private NamedParameterJdbcTemplate nameParameterJdbcTemplate;
    public Student getById(Integer studentId){
        String sqlstr = "select id ,name from student where id = :studentId";
        Map<String ,Object> map = new HashMap<>();
        map.put("studentId" ,studentId);
        List<Student> list = nameParameterJdbcTemplate.query(sqlstr ,map ,new StudentRowMapper());
        if(list.size() > 0){
            return list.get(0);
        }else{
            return null;
        }
    }

    public void deleteByID(Integer studentId){
        String sqlstr = "delete from student where id = :studentId";
        Map<String ,Object> map = new HashMap<>();
        map.put("studentId"  , studentId);
        nameParameterJdbcTemplate.update(sqlstr , map);
    }
}

創建一個 Test

package com.example.demo.dao;

import com.example.demo.JPA.Student;
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.*;

@SpringBootTest
public class StudentDaoImplTest {
    @Autowired
    private StudentDao studentDao; // 注入 studentDao 這個bean

    @Test
    public void getById(){
        Student student = studentDao.getById(1);
        assertNotNull(student);
        assertEquals("Amy" ,student.getName());
        assertEquals(90.3 ,student.getScore());
        assertTrue(student.isGraduate());
        assertNotNull(student.getCreateDate());
    }

    @Test
    public void deleteById(){
        studentDao.deleteByID(1); // 先刪除
        Student student = studentDao.getById(1); // 查詢
        assertNull(student);
    }
}

但當我們單元測試完getById() 、 deleteById() 會發現一個問題就是

deleteById() 刪除了 id=1 ,導致 getById() 測試失敗

這個時候!!

可以用@Transactional

@Test 加上 @Transactional 後

Spring Boot 會去 rollback 這個單元測試裡面所有執行過的操作 ,這個單元測試所改動過的數據 ,都會被恢復原狀

@Transactional (for 單元測試)

用法: 可以加在方法上 ,也可以加在 class 上

用途: 在單元測試結束後 , rollback (回滾) 所有資料庫操作 , 將數據恢復原狀

package com.example.demo.dao;

import com.example.demo.JPA.Student;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import javax.transaction.Transactional;

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

@SpringBootTest
public class StudentDaoImplTest {
    @Autowired
    private StudentDao studentDao; // 注入 studentDao 這個bean

    @Transactional
    @Test
    public void deleteById(){
        studentDao.deleteByID(1);
        Student student = studentDao.getById(1);
        assertNull(student);
    }
}

單元測試 Dao insert()、update()

在DaoImpl 加上 insert 、update

package com.example.demo.dao;

import com.example.demo.JPA.Student;
import com.example.demo.StudentRowMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.stereotype.Component;

import java.util.HashMap;
import java.util.List;
import java.util.Map;

@Component
public class StudentDaoImpl implements StudentDao{
    @Autowired
    private NamedParameterJdbcTemplate nameParameterJdbcTemplate;
    public Student getById(Integer studentId){
        String sqlstr = "select id ,name from student where id = :studentId";
        Map<String ,Object> map = new HashMap<>();
        map.put("studentId" ,studentId);
        List<Student> list = nameParameterJdbcTemplate.query(sqlstr ,map ,new StudentRowMapper());
        if(list.size() > 0){
            return list.get(0);
        }else{
            return null;
        }
    }

    // 不確定對不對 ,因為單元測試報錯
    public Integer insert(Student student){
        String sqlstr="insert into student (name ,score ,graduate) value (:studentName ,:studentScore ,:studentGraduate)";
        Map<String ,Object> map = new HashMap<>();
        map.put("studentName", student.getName());
        map.put("studentScore", student.getScore());
        map.put("studentGraduate", student.isGraduate());
        nameParameterJdbcTemplate.update(sqlstr , map);
        return student.getId();
    }

    public void update(Student student){
        Integer studentId = getById(student.getId()).getId();
        String sqlstr = "update student set name = :studentName ,score = :studentScore ,graduate = :studentGraduate where id = " + studentId;
        Map<String ,Object> map = new HashMap<>();
        map.put("studentName", student.getName());
        map.put("studentScore", student.getScore());
        map.put("studentGraduate", student.isGraduate());
        nameParameterJdbcTemplate.update(sqlstr , map);
    }
}

Test

package com.example.demo.dao;

import com.example.demo.JPA.Student;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import javax.transaction.Transactional;

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

@SpringBootTest
public class StudentDaoImplTest {
    @Autowired
    private StudentDao studentDao; // 注入 studentDao 這個bean

    @Transactional
    @Test
    public void insert(){
        Student student = new Student();
        student.setName("Kevin");
        student.setScore(66.2);
        student.setGraduate(true);
        Integer studentId = studentDao.insert(student);

        Student result = studentDao.getById(studentId);
        assertNotNull(result);
        assertEquals("Kevin" ,result.getName());
        assertEquals(66.2 ,result.getScore());
        assertTrue(result.isGraduate());
        assertNotNull(result.getCreateDate());
    }

    @Test
    public void update(){
        Student student = studentDao.getById(3);
        student.setName("John");
        studentDao.update(student);
        Student result = studentDao.getById(3);
        assertNotNull(result);
        assertEquals("John" ,result.getName());
    }
}

總結

  1. 在測試用的StudentDaoImplTest class 中加上 @SpringBootTest
  2. 運行後 Spring Boot Spring 容器跟所有的Bean 都創建出來 ex: Bean StudentDao 和 Bean StudentService
  3. 使用@Autowired 注入想要測試的bean
  4. 撰寫單元測試 ,測試該bean 的方法是否運作正常

如何用JUnit5 去測試 Spring Boot 程式 2

Controller 層的測試

ex: 要模擬一個 GET /students/1 請求

@RestController
public class StudentController {
    @Autowired
    private StudentService studentService;
    @GetMapping("/students/{studentId}")
    public Student selectId(@PathVariable Integer studentId){
        return studentService.getById(studentId);
    }
}

目的: 模擬前端的行為 ,測試API 是否運行正確

不能直接注入 bean 來測試 ,需要透過模擬真實的 API call 來測試

要透過模擬一個 http request 來測試 ,而不是直接注入 bean 來測試