# 创建数据库
在上一次提到的 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));
}
}
编写完以后,有三个方法运行这个测试:
- 点击 IDEA
test
左边的绿色箭头,可以运行这一个测试函数 - 点击 IDEA
UserTest
左边的绿色箭头,可以运行这一个类的测试 - 运行
mvn test
命令,或点击右边的 Maventest
,可以运行整个项目的测试。
可以看到测试成功通过,因为我们数据库里什么都没有,所以页面什么也没返回。
我们使用 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
有非常多的重复代码,于是我们简单重构一下:
- 将判断请求的状态码封装为
assertStatusCodeEquals
; - 将
@Autowired
放到父类,这样所有的子类就不用再写; - 将清空数据库的
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() {
}
}