系列目录

创建数据库

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

1
2
3
4
5
6
7
8
9
10
11
12
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:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
// 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(数据访问层):

1
2
3
4
5
6
7
// 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(业务逻辑层):

1
2
3
4
5
6
7
8
9
10
11
// 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(视图层):

1
2
3
4
5
6
7
8
9
10
11
// 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

1
2
3
4
5
6
7
8
$ 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 人类使用指南- 知乎

添加测试依赖

我们添加以下依赖:

1
2
3
4
5
6
7
8
9
10
<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

1
2
3
4
5
6
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 也提供了接口(文档),在测试前会依次执行 /src/test/resource/schema.sql/src/test/resource/schema.sql。于是我们写一个 schema.sql

1
2
3
4
5
6
7
8
9
10
/* 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
测试 | docs.spring.io
SpringBoot Test 人类使用指南- 知乎

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// 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

1
2
3
4
5
6
7
8
9
10
11
// 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);
}

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// 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 指定测试类中每个测试函数的顺序,但是这还需要考虑到不同测试类对数据库造成的影响。


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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
// 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 就行。

1
2
3
4
5
6
import static org.springframework.test.jdbc.JdbcTestUtils.*;

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
// 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;

@AfterEach
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 也放到父类。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// 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());
}

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

}

重构后的 UserTest 类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
// 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"));
}
}