使用TestContainer对dao层做单元测试 -- 以mysql为例
引言
背景
在日常开发中,数据访问层(DAO)是应用程序与数据库交互的核心部分。DAO 层的健壮性直接关系到数据操作的正确性。然而,传统的单元测试通常依赖于内存数据库(如 H2),这可能导致以下问题:
- 行为差异:内存数据库与生产环境中的数据库(如 MySQL、PostgreSQL)可能存在行为差异,比如 SQL 语法支持、事务隔离级别、默认值等。
- 测试覆盖不足:部分数据库特性(如索引、存储过程、触发器)无法在内存数据库中正确模拟。
- 环境复杂性:直接连接生产或预发布环境进行测试容易造成数据污染,且难以实现数据的快速清理与重置。
难点
- 环境依赖:需要运行一个真实的数据库实例,通常涉及复杂的 CI/CD 集成。
- 数据污染:测试过程中插入的数据可能干扰其他测试用例。
- 测试速度:真实数据库可能因性能瓶颈而导致测试变慢。
- 可重复性:生产环境或共享测试数据库中的数据状态可能因其他进程或测试影响,导致测试结果不一致。
TestContainers 简介
TestContainers 是一个轻量级开源库,它允许开发者通过 Docker 容器运行测试所需的依赖服务,例如数据库、消息队列、WebDriver 等。它通过 Java API 提供对容器的精细控制,从而简化集成测试的环境配置。它既支持MySQL、PostgreSQL、MariaDB、Oracle、SQL Server 等关系型数据库,又支持MongoDB、Redis、Elasticsearch 等非关系型数据库。
TestContainers 的优势和特点
- 隔离性:每次测试运行时,创建全新的数据库实例,保证数据隔离。
- 真实性:直接运行生产数据库的 Docker 镜像,避免行为差异。
- 自动化:容器的启动与停止由 TestContainers 自动管理,无需手动干预。
- 跨平台:支持多种数据库、消息队列和其他依赖服务。
TestContainers 的使用简化了 DAO 层测试的环境搭建,使得测试更加可靠和高效。
示例
Talk is cheap.我们以一个简单的用户管理 - 增删改查能力来做演示。
准备工作
确保本地环境已安装 Docker并启动,这一步网上教程很多,不赘述。
依赖引入
<spring-boot.version>3.1.3</spring-boot.version>
<junit-jupiter.version>5.9.2</junit-jupiter.version>
<testcontainers.version>1.20.4</testcontainers.version>
<!-- test-starter and JUnit Jupiter running tests -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<version>${spring-boot.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-api</artifactId>
<version>${junit-jupiter.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-engine</artifactId>
<version>${junit-jupiter.version}</version>
<scope>test</scope>
</dependency>
<!-- testcontainers -->
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers</artifactId>
<version>${testcontainers.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<version>${testcontainers.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>mysql</artifactId>
<version>${testcontainers.version}</version>
<scope>test</scope>
</dependency>
创建表
create table t_user
(
id bigint unsigned auto_increment comment '自增主键' primary key,
username varchar(256) not null comment '账号',
password varchar(256) not null comment '密码',
email varchar(256) not null comment '邮箱',
mobile_phone varchar(256) not null comment '手机号',
delete_status tinyint not null default '0' comment '手机号',
gmt_created bigint not null comment '创建时间,时间戳',
gmt_modified bigint not null comment '最近更新时间,时间戳',
constraint uniq_email unique (email),
constraint uniq_mobile_phone unique (mobile_phone),
constraint uniq_username unique (username)
) comment '用户信息' collate = utf8mb4_bin row_format = DYNAMIC;
实体类设计
UserDO 用于DB交互
@Data
@TableName("t_user")
public class UserDO {
@TableId(type = IdType.AUTO)
private Long id;
private String username;
private String password;
private String email;
private String mobilePhone;
private Integer deleteStatus;
@TableField(fill = FieldFill.INSERT)
private Long gmtCreated;
@TableField(fill = FieldFill.INSERT_UPDATE)
private Long gmtModified;
}
User 领域实体
@EqualsAndHashCode(callSuper = true)
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class User extends BaseBO {
private Long id;
private String username;
private String password;
private String email;
private String mobilePhone;
private Integer deleteStatus;
@EqualsAndHashCode.Exclude
private Long gmtCreated;
@EqualsAndHashCode.Exclude
private Long gmtModified;
}
converter 用于UserDO和User的互相转换
import java.util.List;
import org.mapstruct.Mapper;
import org.mapstruct.factory.Mappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
@Mapper
public interface UserConverter {
UserConverter INSTANCE = Mappers.getMapper(UserConverter.class);
User convert(UserDO userDO);
UserDO convert(User user);
List<User> convert(List<UserDO> userDOList);
Page<User> convert(Page<UserDO> page);
}
创建mapper
mapper查询返回UserDO,在manager层通过converter处理成User返回,其它层都不感知UserDO。
import org.apache.ibatis.annotations.Mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
@Mapper
public interface UserMapper extends BaseMapper<UserDO> {}
测试类编写
准备工作结束,我们开始写测试类
测试类初始化
- 通过注解初始化table,否则执行的时候会报错“Table 'test.t_user' doesn't exist”
- 需要注解以Spring容器执行测试,否则无法加载mapper的bean。
- 需要指定db driver否则mapper无法加载,会报错:java.lang.NullPointerException: Cannot invoke xxx because "this.mapper" is null
import static org.junit.jupiter.api.Assertions.*;
import org.junit.jupiter.api.*;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.jdbc.Sql;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import org.springframework.util.CollectionUtils;
import org.testcontainers.containers.MySQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import lombok.extern.slf4j.Slf4j;
import net.sf.jsqlparser.util.validation.feature.MySqlVersion;
@Sql(statements = "上面的建表语句,太长了就不在这里写了",
executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD)
@ExtendWith(SpringExtension.class)
@Testcontainers
// manager层无法引用StarterApplication,因此在test包里自建了一个TestConfig用于启动spring容器
@SpringBootTest(classes = TestConfig.class)
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
@Slf4j
public class UserManagerTest {
@Container
// 如果初始化的sql比较复杂,可以把sql文件放在resource目录下,
// 并在初始化MySQLContainer的时候加上.withInitScript("init-xxx.sql");
private static final MySQLContainer mysqlContainer =
new MySQLContainer<>("mysql:" + MySqlVersion.V8_0.getVersionString()).withUsername("test_un")
.withPassword("test_pw")
.withDatabaseName("test_dn");
@Autowired
private UserManager userManager;
@BeforeAll
static void setUpContainer() {
mysqlContainer.start();
System.setProperty("spring.datasource.url", mysqlContainer.getJdbcUrl());
System.setProperty("spring.datasource.username", mysqlContainer.getUsername());
System.setProperty("spring.datasource.password", mysqlContainer.getPassword());
System.setProperty("spring.datasource.driver-class-name", "com.mysql.cj.jdbc.Driver");
}
}
测试方法编写
完整验证了add -> get -> page -> update -> page -> delete -> page的流程。
注意User类需要实现hashCode和equals方法。
public static final String NEW_USERNAME = "newUsername" + UUID.randomUUID().toString().replaceAll("-", "");
private User defaultUser;
@BeforeEach
void setUp() {
defaultUser = new User();
defaultUser.setEmail("test@example.com");
defaultUser.setId(1L);
defaultUser.setMobilePhone("1234567890");
defaultUser.setPassword("password");
defaultUser.setUsername("username");
defaultUser.setDeleteStatus(0);
}
@Test
@Order(1)
public void testAddUser() {
User user = defaultUser;
User result = userManager.addUser(user);
assertEquals(user, result);
}
@Test
@Order(2)
public void testGetUser() {
User user = defaultUser;
User result = userManager.getUserById(1L);
assertEquals(user, result);
}
@Test
@Order(3)
public void testPageUser() {
// arrange
User user = defaultUser;
PageUserParam param = new PageUserParam();
param.setPageNum(1);
param.setPageSize(10);
param.setUsernames(Collections.singletonList(user.getUsername()));
// act
Page<User> page = userManager.pageUser(param);
// assert
assertNotNull(page);
assertFalse(CollectionUtils.isEmpty(page.getRecords()));
assertEquals(1, page.getTotal());
assertEquals(user, page.getRecords().get(0));
}
@Test
@Order(4)
public void testUpdateUser() {
User user = defaultUser;
user.setUsername(NEW_USERNAME);
User result = userManager.updateUser(user);
assertEquals(user, result);
}
@Test
@Order(5)
public void testPageUser2() {
// arrange
User user = defaultUser;
user.setUsername(NEW_USERNAME);
PageUserParam param = new PageUserParam();
param.setPageNum(1);
param.setPageSize(10);
param.setUsernames(Collections.singletonList(NEW_USERNAME));
param.setDeleteStatus(0);
// act
Page<User> page = userManager.pageUser(param);
// assert
assertNotNull(page);
assertFalse(CollectionUtils.isEmpty(page.getRecords()));
assertEquals(1, page.getTotal());
assertEquals(user, page.getRecords().get(0));
}
@Test
@Order(6)
public void testDeleteUser() {
// act
boolean result = userManager.deleteUser(1L);
// assert
assertTrue(result);
}
@Test
@Order(7)
public void testPageUser3() {
// arrange
PageUserParam param = new PageUserParam();
param.setPageNum(1);
param.setPageSize(10);
param.setUsernames(Collections.singletonList(NEW_USERNAME));
param.setDeleteStatus(0);
// act
Page<User> page = userManager.pageUser(param);
// assert
assertNotNull(page);
assertEquals(page.getRecords().size(), 0);
assertEquals(0, page.getTotal());
}
执行结果
补充信息
TestConfig
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication(scanBasePackages = "com.xxx")
@MapperScan("com.xxx.dal.mapper")
public class TestConfig {
public static void main(String[] args) {
SpringApplication.run(TestConfig.class, args);
}
}
总结
- 在 DAO 层引入 TestContainers 测试,能有效提升代码健壮性和可靠性。
- 在测试中配置动态环境变量,确保测试用例的可重复性。
- 建议在 CI/CD 流程中全面集成 TestContainers,进一步提升开发效率与交付质量。
通过以上实践,开发团队不仅能更好地掌控 DAO 层的质量,还能为整个项目的测试体系打下坚实的基础。

浙公网安备 33010602011771号