Mokito 單元測試與 Spring-Boot 整合測試

Zhang_Xiang發表於2021-04-13

Mokito 單元測試與 Spring-Boot 整合測試

版本說明
Java:1.8
JUnit:5.x
Mokito:3.x
H2:1.4.200
spring-boot-starter-test:2.3.9.RELEASE

前言:通常任何軟體都會劃分為不同的模組和元件。單獨測試一個元件時,我們叫做單元測試。單元測試用於驗證相關的一小段程式碼是否正常工作。
單元測試不驗證應用程式程式碼是否和外部依賴正常工作。它聚焦與單個元件並且 Mock 所有和它互動的依賴。
整合測試主要用於發現使用者端到端請求時不同模組互動產生的問題。
整合測試範圍可以是整個應用程式,也可以是一個單獨的模組,取決於要測試什麼。
典型的 Spring boot CRUD 應用程式,單元測試可以分別用於測試控制器(Controller)層、DAO 層等。它不需要任何嵌入服務,例如:Tomcat、Jetty、Undertow。
在整合測試中,我們應該聚焦於從控制器層到持久層的完整請求。應用程式應該執行嵌入服務(例如:Tomcat)以建立應用程式上下文和所有 bean。這些 bean 有的可能會被 Mock 覆蓋。

單元測試

單元測試的動機,單元測試不是用於發現應用程式範圍內的 bug,或者迴歸測試的 bug,而是分別檢測每個程式碼片段。

幾個要點

  • 快,極致的快,500ms 以內
  • 同一個單元測試可重複執行 N 次
  • 每次執行應得到相同的結果
  • 不依賴任何模組

Gradle 引入

plugins {
    id 'java'
    id "org.springframework.boot" version "2.3.9.RELEASE"
    id 'org.jetbrains.kotlin.jvm' version '1.4.32'
}

apply from: 'config.gradle'
apply from: file('compile.gradle')

group rootProject.ext.projectDes.group
version rootProject.ext.projectDes.version

repositories {
    mavenCentral()
}


dependencies {
    implementation rootProject.ext.dependenciesMap["lombok"]
    annotationProcessor rootProject.ext.dependenciesMap["lombok"]
    implementation rootProject.ext.dependenciesMap["commons-lang3"]
    implementation rootProject.ext.dependenciesMap["mybatis-plus"]
    implementation rootProject.ext.dependenciesMap["spring-boot-starter-web"]
    implementation rootProject.ext.dependenciesMap["mysql-connector"]
    implementation rootProject.ext.dependenciesMap["druid"]

    testImplementation group: 'org.springframework.boot', name: 'spring-boot-starter-test', version: '2.3.9.RELEASE'
    testImplementation rootProject.ext.dependenciesMap["h2"]
    implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8"
}

test {
    useJUnitPlatform()
}

引入 spring-boot-starter-test 做為測試框架。該框架已經包含了 JUnit5 和 Mokito 。

對 Service 層進行單元測試

工程結構

  1. Domain 中定義 student 物件。

    @Data
    @AllArgsConstructor
    public class Student {
    
        public Student() {
            this.createTime = LocalDateTime.now();
        }
    
        /**
        * 學生唯一標識
        */
        @TableId(type = AUTO)
        private Integer id;
    
        /**
        * 學生名稱
        */
        private String name;
    
        /**
        * 學生地址
        */
        private String address;
    
        private LocalDateTime createTime;
    
        private LocalDateTime updateTime;
    }
    
  2. Service 層定義 student 增加和檢索的能力。

    public interface StudentService extends IService<Student> {
    
    /**
     * 建立學生
     * <p>
     * 驗證學生名稱不能為空
     * 驗證學生地址不能為空
     *
     * @param dto 建立學生傳輸模型
     * @throws BizException.ArgumentNullException 無效的引數,學生姓名和學生住址不能為空
     */
    void create(CreateStudentDto dto) throws BizException.ArgumentNullException;
    
    /**
     * 檢索學生資訊
     *
     * @param id 學生資訊 ID
     * @return 學生資訊
     * @throws DbException.InvalidPrimaryKeyException 無效的主鍵異常
     */
    StudentVo retrieve(Integer id) throws DbException.InvalidPrimaryKeyException;
    }
    
  3. Service 實現,單元測試針對該實現進行測試。

    @Service
    public class StudentServiceImpl extends ServiceImpl<StudentRepository, Student> implements StudentService {
    
        private final Mapper mapper;
    
        public StudentServiceImpl(Mapper mapper) {
            this.mapper = mapper;
        }
    
        @Override
        public void create(CreateStudentDto dto) throws BizException.ArgumentNullException {
            if (stringNotEmptyPredicate.test(dto.getName())) {
                throw new BizException.ArgumentNullException("學生名稱不能為空,不能建立學生");
            }
            if (stringNotEmptyPredicate.test(dto.getAddress())) {
                throw new BizException.ArgumentNullException("學生住址不能為空,不能建立學生");
            }
    
            Student student = mapper.map(dto, Student.class);
            save(student);
        }
    
        @Override
        public StudentVo retrieve(Integer id) throws DbException.InvalidPrimaryKeyException {
            if (integerLessZeroPredicate.test(id)) {
                throw new DbException.InvalidPrimaryKeyException("無效的主鍵,主鍵不能為空");
            }
    
            Student student = getById(id);
            return mapper.map(student, StudentVo.class);
        }
    
    }
    
  4. 建立單元測試,Mock 一切。

    class StudentServiceImplTest {
    
        @Spy
        @InjectMocks
        private StudentServiceImpl studentService;
    
        @Mock
        private Mapper mapper;
    
        @Mock
        private StudentRepository studentRepository;
    
        @BeforeEach
        public void setUp() {
            MockitoAnnotations.initMocks(this);
        }
    
        @Test
        public void testCreateStudent_NullName_ShouldThrowException() {
            CreateStudentDto createStudentDto = new CreateStudentDto("", "一些測試地址");
            String msg = Assertions.assertThrows(BizException.ArgumentNullException.class, () -> studentService.create(createStudentDto)).getMessage();
            String expected = "學生名稱不能為空,不能建立學生";
            Assertions.assertEquals(expected, msg);
        }
    
        @Test
        public void testCreateStudent_NullAddress_ShouldThrowException() {
            CreateStudentDto createStudentDto = new CreateStudentDto("小明", "");
            String msg = Assertions.assertThrows(BizException.ArgumentNullException.class, () -> studentService.create(createStudentDto)).getMessage();
            String expected = "學生住址不能為空,不能建立學生";
            Assertions.assertEquals(expected, msg);
        }
    
        @Test
        public void testCreateStudent_ShouldPass() throws BizException.ArgumentNullException {
            CreateStudentDto createStudentDto = new CreateStudentDto("小明", "住址測試");
    
            when(studentService.getBaseMapper()).thenReturn(studentRepository);
            when(studentRepository.insert(any(Student.class))).thenReturn(1);
            Student student = new Student();
            when(mapper.map(createStudentDto, Student.class)).thenReturn(student);
            studentService.create(createStudentDto);
        }
    
        @Test
        public void testRetrieve_NullId_ShouldThrowException() {
            String msg = Assertions.assertThrows(DbException.InvalidPrimaryKeyException.class, () -> studentService.retrieve(null)).getMessage();
            String expected = "無效的主鍵,主鍵不能為空";
            Assertions.assertEquals(expected, msg);
        }
    
        @Test
        public void testRetrieve_ShouldPass() throws DbException.InvalidPrimaryKeyException {
            when(studentService.getBaseMapper()).thenReturn(studentRepository);
    
            Integer studentId = 1;
            String studentName = "小明";
            String studentAddress = "學生地址";
            LocalDateTime createTime = LocalDateTime.now();
            LocalDateTime updateTime = LocalDateTime.now();
            Student student = new Student(studentId, studentName, studentAddress, createTime, updateTime);
            when(studentRepository.selectById(studentId)).thenReturn(student);
            StudentVo studentVo = new StudentVo(studentId, studentName, studentAddress, createTime, updateTime);
            when(mapper.map(student, StudentVo.class)).thenReturn(studentVo);
    
            StudentVo studentVoReturn = studentService.retrieve(studentId);
    
            Assertions.assertEquals(studentId, studentVoReturn.getId());
            Assertions.assertEquals(studentName, studentVoReturn.getName());
            Assertions.assertEquals(studentAddress, studentVoReturn.getAddress());
            Assertions.assertEquals(createTime, studentVoReturn.getCreateTime());
            Assertions.assertEquals(updateTime, studentVoReturn.getUpdateTime());
        }
    }
    
    • @RunWith(MockitoJUnitRunner.class):新增該 Class 註解,可以自動初始化 @Mock 和 @InjectMocks 註解的物件。
    • MockitoAnnotations.initMocks():該方法為 @RunWith(MockitoJUnitRunner.class) 註解的替代品,正常情況下二選一即可。但是我在寫單元測試的過程中發現新增 @RunWith(MockitoJUnitRunner.class) 註解不生效。我懷疑和 Junit5 廢棄 @Before 註解有關,各位可作為參考。檢視原始碼找到問題是更佳的解決方式。
    • @Spy:呼叫真實方法。
    • @Mock:建立一個標註類的 mock 實現。
    • @InjectMocks:建立一個標註類的 mock 實現。此外依賴注入 Mock 物件。在上面的例項中 StudentServiceImpl 被標註為 @InjectMocks 物件,所以 Mokito 將為 StudentServiceImpl 建立 Mock 物件,並依賴注入 MapperStudentRepository 物件。
  5. 結果

整合測試

  • 整合測試的目的是測試不同的模組一共工作能否達到預期。
  • 整合測試不應該有實際依賴(例如:資料庫),而是模擬它們的行為。
  • 應用程式應該在 ApplicationContext 中執行。
  • Spring boot 提供 @SpringBootTest 註解建立執行上下文。
  • 使用 @TestConfiguration 配置測試環境。例如 DataSource。

我們把整合測試集中在 Controller 層。

  1. 建立 Controller ,語法使用了 Kotlin ?,提供 Create 和 Reitreve 能力。

    @RestController
    @RequestMapping("student")
    class StudentController(private val studentService: StudentService) {
        /**
        * 建立學生
        * 新增一條學生記錄到資料庫中
        *
        * @param createStudentDto 建立學生傳輸模型
        */
        @PostMapping("create")
        fun create(@RequestBody createStudentDto: CreateStudentDto?): Result<String> = try {
            studentService.create(createStudentDto)
            Result.success("建立成功")
        } catch (e: ArgumentNullException) {
            e.printStackTrace()
            Result.failure(e.message)
        }
    
    
        /**
        * 檢索學生資訊
        *
        * @param id 學生唯一標識
        * @return 學生資訊
        */
        @GetMapping("retrieve")
        fun retrieve(id: Int?): Result<StudentVo> = try {
            val studentVo = studentService.retrieve(id)
            Result.success(studentVo)
        } catch (e: InvalidPrimaryKeyException) {
            e.printStackTrace()
            Result.failure(e.message)
        }
    }
    
  2. 配置 H2 為資料來源。並通過 schema.sql 建立 table,student_data.sql 初始化資料。

    @TestConfiguration
    public class ToyTestConfiguration {
    
        @Bean
        DataSource createDataSource() {
            return new EmbeddedDatabaseBuilder()
                    .generateUniqueName(true)
                    .setType(EmbeddedDatabaseType.H2)
                    .setScriptEncoding("UTF-8")
                    .ignoreFailedDrops(true)
                    .addScript("schema.sql")
                    .addScript("student_data.sql")
                    .build();
        }
    }
    

    schema.sql

    create table if not exists student
    (
        id          INTEGER(64) PRIMARY KEY AUTO_INCREMENT,
        name        varchar(20)  null,
        address     varchar(200) null,
        create_time datetime     null,
        update_time datetime     null
    );
    

    student_data.sql

    INSERT INTO student (id, name, address, create_time, update_time)
    VALUES (1, '小明', '一些測試地址', now(), now());
    
  3. 整合測試

    @Import(ToyTestConfiguration.class)
    @SpringBootTest(classes = ToyTestApplication.class,
            webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
    public class StudentControllerTest {
    
        @LocalServerPort
        private int port;
    
        @Autowired
        DataSource datasource;
    
        @Autowired
        private TestRestTemplate restTemplate;
    
        @Test
        public void testCreateStudent_ShouldPass() {
            CreateStudentDto createStudentDto = new CreateStudentDto("小明", "住址測試");
            ResponseEntity<Result> responseEntity = restTemplate.postForEntity("http://localhost:" + port + "/student/create", createStudentDto, Result.class);
            Assertions.assertNotNull(responseEntity.getBody());
            Assertions.assertTrue(responseEntity.getBody().getSucceeded());
        }
    
        @Test
        public void testRetrieveStudent_ShouldPass() {
            ResponseEntity<Result> responseEntity = restTemplate.getForEntity("http://localhost:" + port + "/student/retrieve?id=1", Result.class);
            Assertions.assertNotNull(responseEntity.getBody());
            Assertions.assertTrue(responseEntity.getBody().getSucceeded());
        }
    
    }
    
    • @SpringBootTest:載入真實環境應用程式上下文
    • WebEnvironment.RANDOM_PORT:建立隨機埠
    • @LocalServerPort:獲取執行埠。
    • TestRestTemplate:發起 HTTP 請求。
  4. 結果

Github 原始碼

相關文章