# 创建数据库

在上一次提到的 springbootdemo 数据库中添加一个表 users,并插入两条测试数据:

create table users
(
	id int auto_increment,
	email varchar(100) not null,
	password varchar(100) not null,
	name varchar(100) not null,
	constraint User_email_uindex unique (email),
	constraint User_id_uindex unique (id)
);

INSERT INTO springtest.users (id, email, password, name) VALUES (1, 'lyh543@outlook.com', '123456', 'lyh543');
INSERT INTO springtest.users (id, email, password, name) VALUES (2, 'test@example.com', 'test', 'test');

# 简单写一个获取用户信息的 API

编写一个 User(模型层),并用 IDEA 自动生成构造函数、Getters 和 Setters:

// src/main/java/com/lyh543/springbootdemo/entity/User.java

public class User {
    private long id;
    private String email, password, name;

    public User(int id, String email, String password, String name) {
        this.id = id;
        this.email = email;
        this.password = password;
        this.name = name;
    }

    public User(String email, String password, String name) {
        this.email = email;
        this.password = password;
        this.name = name;
    }

    public long getId() {
        return id;
    }

    public void setId(long id) {
        this.id = id;
    }

    public String getEmail() {
        return email;
    }

    public void setEmail(String email) {
        this.email = email;
    }

    public String getPassword() {
        return password;
    }

    public void setPassword(String password) {
        this.password = password;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }
}

编写一个 UserMapper(数据访问层):

// src/main/java/com/lyh543/springbootdemo/mapper/UserMapper.java

@Repository
public interface UserMapper {
    @Select("SELECT * FROM users WHERE email = #{email}")
    User getByEmail(@Param("email") String email);
}

编写一个 UserService(业务逻辑层):

// src/main/java/com/lyh543/springbootdemo/service/UserService.java

@Repository
public class UserService {
    @Autowired
    UserMapper userMapper;

    public User getByEmail(String email) {
        return userMapper.getByEmail(email);
    }
}

最后是 UserController(视图层):

// src/main/java/com/lyh543/springbootdemo/web/UserController.java

@RestController
public class UserController {
    @Autowired
    UserService userService;
    @GetMapping("/api/user/1")
    public User getUserInfo() {
        return userService.getByEmail("lyh543@outlook.com");
    }
}

安装命令行工具 httpie,然后 http http://localhost:8080/api/user/1

$ sudo apt install httpie
$ http http://localhost:8080/api/user/1
{
  "id": 1,
  "email": "lyh543@outlook.com",
  "password": "123456",
  "name": "lyh543"
}

# 测试!

每次写完一个 API 都得运行好几次 httpie,太麻烦了。有没有运行代码、每次修改以后自动发送 HTTP 请求测试之前的 API 的工具呢?有!那就是测试!

Spring Boot 项目常见的测试形式有单元测试和集成测试。单元测试是对每一层 (Mapper, Service, Controller) 进行测试;而像我们这种发送 HTTP 请求调用 API、需要集成所有层的测试,叫做集成测试

Spring Boot 单元测试可以参考 SpringBoot Test 人类使用指南- 知乎 (opens new window)

# 添加测试依赖

我们添加以下依赖:

<dependency>
    <groupId>org.hsqldb</groupId>
    <artifactId>hsqldb</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>

HSQLDB 是一个小型嵌入式数据库,我们测试的时候使用 HSQLDB,开发和测试时就不会使用同一个数据库。

需要注意的是,MySQL 默认隔离级别为可重复读,HSQLDB 不支持可重复读、默认为读提交,因此在测试的时候可能无法使用事务。

# 配置测试数据库

安装了 HSQLDB 依赖,我们还要进行配置,告诉 Spring Boot 在测试的时候使用 HSQLDB 而不是 MySQL。修改 /src/test/resources/application.yaml

spring:
  datasource:
    # sql.syntax_mys 会让 hsqldb 兼容 mysql 的语法,虽然兼容的不完全
    url: jdbc:hsqldb:mem:testdb;sql.syntax_mys=true;DB_CLOSE_DELAY=-1
    username: sa
    password:

测试的时候还需要执行生成表结构的 SQL,Spring 也提供了接口(文档 (opens new window)),在测试前会依次执行 /src/test/resource/schema.sql/src/test/resource/schema.sql。于是我们写一个 schema.sql

/* src/test/resources/schema.sql */
create table if not exists users
(
    id int auto_increment,
    email varchar(100) not null,
    password varchar(100) not null,
    name varchar(100) not null,
    constraint User_email_uindex unique (email),
    constraint User_id_uindex unique (id)
);

# 编写第一个测试

参考:Getting Started | Testing the Web Layer (opens new window) 测试 | docs.spring.io (opens new window) SpringBoot Test 人类使用指南- 知乎 (opens new window)

编写一个测试类 test/UserTest.java

// src/test/java/com/lyh543/springbootdemo/test/UserTest.java

package com.lyh543.springbootdemo.test;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.SpringBootTest.WebEnvironment;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.boot.web.server.LocalServerPort;
import org.springframework.transaction.annotation.Transactional;

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

@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
public class UserTest {
    // Spring Boot 会随机指定一个端口运行,如果需要端口号,可以像下面这样注入
    // @LocalServerPort
    // private int port;

    // 一个可用于测试的 Client
    @Autowired
    private TestRestTemplate restTemplate;

    @Test
    void test() {
        assertNull(restTemplate.getForObject("/api/user/1", String.class));
    }
}

编写完以后,有三个方法运行这个测试:

  1. 点击 IDEA test 左边的绿色箭头,可以运行这一个测试函数
  2. 点击 IDEA UserTest 左边的绿色箭头,可以运行这一个类的测试
  3. 运行 mvn test 命令,或点击右边的 Maven test,可以运行整个项目的测试。

可以看到测试成功通过,因为我们数据库里什么都没有,所以页面什么也没返回。

我们使用 MyBatis Plus 往数据库塞一点数据,然后再测试。

# 编写第二个测试

由于测试需要会向空数据插入数据,所以我们完善一下 UserMapper,加一个 insert

// src/main/java/com/lyh543/springbootdemo/mapper/UserMapper.java

@Repository
public interface UserMapper {
    @Select("SELECT * FROM users WHERE email = #{email}")
    User getByEmail(@Param("email") String email);

    @Insert("INSERT INTO users (email, password, name) VALUES (#{email}, #{password}, #{name})")
    @Options(useGeneratedKeys=true, keyProperty = "id") // 注意 insert 的返回值返回仍然是插入行数,而 id 被放自动在了 user.id 里
    int insert(User user);
}

然后我们就可以编写第二个测试函数了:

// src/test/java/com/lyh543/springbootdemo/test/UserTest.java

@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
public class UserTest {
    @LocalServerPort
    private int port;

    @Autowired
    private TestRestTemplate restTemplate;

    @Autowired
    private UserMapper userMapper;

    @Test
    void test() {
        assertNull(restTemplate.getForObject("/api/user/1", String.class));
    }

    @Test
    public void test2() {
        List<Long> userIds = new ArrayList<>();
        assertNull(restTemplate.getForObject("/api/user/1", String.class));
        for (int i = 0; i < 10; i++) {
            User user = new User("lyh543@outlook.com" + Math.random(), "123456", "lyh543");
            userMapper.insert(user);
            userIds.add(user.getId());
        }
        for (Long i : userIds) {
            assertTrue(restTemplate
                    .getForObject("/api/user/" + i, String.class)
                    .contains("lyh543"));
        }
        assertNull(restTemplate.getForObject("/api/user/" + (Collections.min(userIds) - 1), String.class));
        assertNull(restTemplate.getForObject("/api/user/" + (Collections.max(userIds) + 1), String.class));

        assertEquals(400, restTemplate
                .getForEntity("/api/user/lyh543", String.class)
                .getStatusCodeValue());
    }
}

运行测试类 / mvn test,发现两个测试均成功。

# 自动清理数据库

上面的测试虽然成功运行了,但是好像有点问题:test2 向数据库插入了数据而没有清理。

如果我们的 test 测试是在 test2 后进行的,就会出错。(试试交换两个函数名以交换其执行顺序)


一个解决方案是使用 @Order (opens new window) 指定测试类中每个测试函数的顺序,但是这还需要考虑到不同测试类对数据库造成的影响。


另一个解决方案是事务。但是这种方法目前挂掉了,所以先想想别的办法吧。

// src/test/java/com/lyh543/springbootdemo/test/UserTest.java

package com.lyh543.springbootdemo.test;

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

import com.lyh543.springbootdemo.entity.User;
import com.lyh543.springbootdemo.mapper.UserMapper;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.SpringBootTest.WebEnvironment;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.boot.web.server.LocalServerPort;
import org.springframework.http.HttpEntity;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;

@Transactional
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
public class UserTest {
    @LocalServerPort
    private int port;

    @Autowired
    private TestRestTemplate restTemplate;

    @Autowired
    private UserMapper userMapper;

    @Test
    public void test() {
        assertNull(restTemplate.getForObject("/api/user/1", String.class));
    }

    @Test
    public void test2() {
        List<Long> userIds = new ArrayList<>();
        assertNull(restTemplate.getForObject("/api/user/1", String.class));
        for (int i = 0; i < 10; i++) {
            User user = new User("lyh543@outlook.com" + Math.random(), "123456", "lyh543");
            userMapper.insert(user);
            userIds.add(user.getId());
        }
        for (Long i : userIds) {
            assertTrue(restTemplate
                    .getForObject("/api/user/" + i, String.class)
                    .contains("lyh543"));
        }
        assertNull(restTemplate.getForObject("/api/user/" + (Collections.min(userIds) - 1), String.class));
        assertNull(restTemplate.getForObject("/api/user/" + (Collections.max(userIds) + 1), String.class));

        assertEquals(400, restTemplate
                .getForEntity("/api/user/lyh543", String.class)
                .getStatusCodeValue());
    }
}

第三个解决方案,是在每次函数执行完成以后手动清空数据库。我们当然不必在每个测试函数以后都加两行代码,直接使用 @AfterEach 就行。

import static org.springframework.test.jdbc.JdbcTestUtils.*;

@AfterEach
public void clearDatabase() {
    deleteFromTables(jdbcTemplate, "users");
}

Spring 在JdbcTestUtils 中提供了五个好用的静态函数 (opens new window),这里我们使用 deleteFromTables 一键清空表。

// src/test/java/com/lyh543/springbootdemo/test/UserTest.java

import static org.junit.jupiter.api.Assertions.*;
import static org.springframework.test.jdbc.JdbcTestUtils.*;


@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
public class UserTest {
    @Autowired
    private JdbcTemplate jdbcTemplate;

    @Autowired
    private TestRestTemplate restTemplate;

    @Autowired
    private UserMapper userMapper;

    @BeforeEach
    public void clearDatabase() {
        deleteFromTables(jdbcTemplate, "users");
    }

    @Test
    public void test() {
        assertNull(restTemplate.getForObject("/api/user/1", String.class));
    }

    @Test
    public void test2() {
        List<Long> userIds = new ArrayList<>();
        assertNull(restTemplate.getForObject("/api/user/1", String.class));
        for (int i = 0; i < 10; i++) {
            User user = new User("lyh543@outlook.com" + Math.random(), "123456", "lyh543");
            userMapper.insert(user);
            userIds.add(user.getId());
        }
        for (Long i : userIds) {
            assertTrue(restTemplate
                    .getForObject("/api/user/" + i, String.class)
                    .contains("lyh543"));
        }
        assertNull(restTemplate.getForObject("/api/user/" + (Collections.min(userIds) - 1), String.class));
        assertNull(restTemplate.getForObject("/api/user/" + (Collections.max(userIds) + 1), String.class));

        assertEquals(400, restTemplate
                .getForEntity("/api/user/lyh543", String.class)
                .getStatusCodeValue());
    }

    @Test
    public void test3() {
        assertEquals(0, countRowsInTable(jdbcTemplate, "users"));
    }
}

三个测试都能成功通过。

# 重构测试类

注意到 UserTest 有非常多的重复代码,于是我们简单重构一下:

  1. 将判断请求的状态码封装为 assertStatusCodeEquals
  2. @Autowired 放到父类,这样所有的子类就不用再写;
  3. 将清空数据库的 clearDatabase 也放到父类。
// src/test/java/com/lyh543/springbootdemo/utils/TestTemplate.java

package com.lyh543.springbootdemo.utils;

import com.lyh543.springbootdemo.mapper.UserMapper;
import org.junit.jupiter.api.AfterEach;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.jdbc.core.JdbcTemplate;

import static org.junit.jupiter.api.Assertions.*;
import static org.springframework.test.jdbc.JdbcTestUtils.*;

public abstract class TestTemplate {
    @Autowired
    protected JdbcTemplate jdbcTemplate;

    @Autowired
    protected TestRestTemplate restTemplate;

    @Autowired
    protected UserMapper userMapper;

    public void assertStatusCodeEquals(int expected, String url) {
        assertEquals(expected, restTemplate
                .getForEntity(url, String.class)
                .getStatusCodeValue());
    }

    @BeforeEach
    public void clearDatabase() {
        deleteFromTables(jdbcTemplate, "users");
    }

}

重构后的 UserTest 类:

// src/test/java/com/lyh543/springbootdemo/test/UserTest.java
package com.lyh543.springbootdemo.test;

import com.lyh543.springbootdemo.entity.User;
import com.lyh543.springbootdemo.utils.TestTemplate;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.SpringBootTest.WebEnvironment;

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.springframework.test.jdbc.JdbcTestUtils.countRowsInTable;


@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
public class UserTest extends TestTemplate {
    @Test
    public void test() {
        assertStatusCodeEquals(404, "/api/user/1");
    }

    @Test
    public void test2() {
        assertStatusCodeEquals(404, "/api/user/1");

        List<Long> userIds = new ArrayList<>();
        for (int i = 0; i < 10; i++) {
            User user = new User("lyh543@outlook.com" + Math.random(), "123456", "lyh543");
            userMapper.insert(user);
            userIds.add(user.getId());
        }
        for (Long i : userIds)
            assertTrue(restTemplate.getForObject("/api/user/" + i, String.class).contains("lyh543"));

        for (long i: new long[]{-1, 0, Collections.min(userIds) - 1, Collections.max(userIds) + 1})
            assertStatusCodeEquals(404, "/api/user/" + i);

        assertStatusCodeEquals(400, "/api/user/lyh543");
    }

    @Test
    public void test3() {
        assertEquals(0, countRowsInTable(jdbcTemplate, "users"));
    }
}

# mock

最近遇到了需要 mock 测试对象用到的 bean 的场景,这里先贴代码记录一下当时是怎么 mock 的,之后再补成新教程。

package com.ruoyi.ddars.service.impl;

import com.fasterxml.jackson.databind.JsonNode;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

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

@SpringBootTest
class CommonRightsServiceImplTest extends TestTemplate {
    // 测试对象,将 mock 对象注入到其中
    @Autowired
    @InjectMocks
    CommonRightsServiceImpl commonRightsService;
    // 被 mocked 的 services
    @Mock
    ISysUserProjectService userProjectService;

    SysProject project;



    @BeforeEach
    void setUp() {
        clearDatabase();
        project = newTestProject();
        projectMapper.insertSysProject(project);
        assertNotNull(project);
        // 正常情况这个 service 无法正常运行
        assertThrows(Exception.class, () -> commonRightsService.sysUserProjectService.getCurrentProjectId());
        // 初始化 mocks
        // deprecated
        MockitoAnnotations.initMocks(this);
        when(userProjectService.getCurrentProjectId()).thenReturn(project.getProjectId());
        // service 已经被我们 mock
        assertEquals(commonRightsService.sysUserProjectService.getCurrentProjectId(), project.getProjectId());
        // 测试对象的其他 service 正确地被 autowired 了
        assertNotNull(commonRightsService.commonRightsMapper);
    }

    @Test
    void selectRightsStatsWithNoData() {
    }
}