在上一篇文章中,我们介绍了如何编写单元测试。这篇文章将更进一步,讲解如何编写集成测试。我们将首先讨论单元测试和集成测试之间的区别,接着介绍集成测试的目录结构,最后展示集成测试的具体实现。
编写集成测试时,主要使用 Testcontainers 和 Failsafe 这两个工具。Testcontainers 允许在测试过程中启动容器,从而实现端到端的测试,而 Failsafe 则负责在构建阶段控制集成测试的执行。
单元测试和集成测试的区别
单元测试 是针对代码中的最小功能单元(通常是单个方法或类)进行的测试,目的是验证这些单元是否按预期工作。单元测试通常通过模拟外部依赖来隔离测试环境。
集成测试 则验证多个组件或模块之间的交互是否正确,确保它们能够协同工作。集成测试通常涉及实际的数据库、网络等外部系统,目的是检查模块之间的接口和数据流是否正确。
如果对以上概念仍有些模糊,别担心,接下来我们将通过实际案例来详细说明如何实现集成测试。本文的集成测试是基于上一篇单元测试教程中的代码进行的。如果你还没有看过上一篇,可以先去了解一下。
集成测试规范
在 Java 集成测试中,遵循以下规范可以帮助确保测试的有效性和可靠性:
真实环境配置:尽量在与生产环境相似的环境中进行测试,使用真实的数据库、消息队列等,以捕捉集成中的潜在问题。
使用 @SpringBootTest
注解:通过 @SpringBootTest
注解启动整个 Spring 应用上下文,确保各个组件之间的依赖关系能够得到正确测试。
事务管理:使用 @Transactional
注解保证测试的数据隔离,测试完成后自动回滚事务,避免对数据库造成污染。
测试覆盖:覆盖关键的业务流程和模块交互,确保在模块组合后仍然能够满足业务需求。
依赖注入的使用:通过依赖注入的方式加载真实组件,而不是使用模拟对象(如 Mockito),以确保组件之间的真实交互能够正确工作。
日志和监控:在测试中开启详细日志和监控,帮助快速定位问题。
数据准备与清理:使用合适的工具或框架(如 Testcontainers 或 Flyway)进行数据的准备和清理,确保每次测试的数据状态一致。
这些规范有助于在集成测试中确保系统模块之间的正确性和稳定性。
集成测试实践
目录结构
如下所示的目录结构展示了整个项目的组成。项目主要包括两个模块:server
模块和 integration-test
模块。
server
模块包含了上一篇文章中的代码,为了使项目结构更为规范,我们将这些代码放在了 server
模块中。
integration-test
模块则专门用于存放 server
模块中代码的集成测试。
目录结构如下:
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
| . ├── integration-test │ ├── pom.xml │ ├── src │ └── test │ └── java │ └── cn │ └── tangrl │ └── server │ ├── controller │ │ └── UserControllerIT.java │ ├── repository │ │ └── UserRepositoryIT.java │ └── service │ └── UserServiceIT.java │ └── server │ ├── pom.xml │ ├── src │ ├── main │ │ ├── java │ │ │ └── cn │ │ │ └── tangrl │ │ │ └── server │ │ │ ├── ItApplication.java │ │ │ ├── controller │ │ │ │ └── UserController.java │ │ │ ├── model │ │ │ │ └── User.java │ │ │ ├── repository │ │ │ │ └── UserRepository.java │ │ │ └── service │ │ │ └── UserService.java │ │ └── resources │ │ └── application.properties │ └── test │ ├── java │ │ └── cn │ │ └── tangrl │ │ └── server │ │ ├── controller │ │ │ └── UserControllerTest.java │ │ ├── model │ │ │ └── UserTest.java │ │ ├── repository │ │ │ └── UserRepositoryTest.java │ │ └── service │ │ └── UserServiceTest.java │ └── resources ├── pom.xml
|
将集成测试代码放在独立的一个模块是 Apache 项目的常见做法(例如 flink),这样的好处是:
- 模块化管理:集成测试与单元测试、业务逻辑代码分离,模块化管理使项目结构更加清晰,方便维护和理解。
- 依赖隔离:集成测试模块可以独立配置自己的依赖,如测试数据库、测试工具等,不会影响其他模块,避免了不必要的冲突。
- 提高构建效率:在 CI/CD 流程中,可以选择性地运行集成测试模块,而不影响单元测试或业务逻辑的构建流程,这样可以加快开发反馈周期。
- 测试环境独立性:可以为集成测试配置专属的测试环境配置文件,确保测试与生产配置隔离,降低误操作风险。
- 更好的代码组织:有助于清晰地组织和管理测试代码,特别是在大型项目中,独立模块的管理使得测试代码易于扩展和维护。
编写集成测试
下面是 Server
模块中 pom.xml
文件的 build
部分。为了使 integration-test
模块能够引用 server
模块的业务代码并进行集成测试,我们需要通过 repackage
生成一个可执行的 JAR 文件。默认情况下,Spring Boot 会生成一个包含嵌入式 Tomcat 的可部署 JAR 包,这个包只能用于部署,无法作为依赖引用。因此,我们在 build
配置中进行了特殊设置:
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
| <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> <executions> <execution> <id>repackage</id> <goals> <goal>repackage</goal> </goals> <configuration> <classifier>exec</classifier> </configuration> </execution> </executions> </plugin> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-surefire-plugin</artifactId> <version>3.3.1</version> </plugin> </plugins> </build>
|
integration-test
模块的 pom.xml
文件内容如下。该模块使用了 Testcontainers
和 Failsafe
这两个工具。Testcontainers
可以在测试运行时启动容器,实现端到端的测试。而 Failsafe
插件则用于控制集成测试的执行,确保在构建阶段运行集成测试。
注意:使用 Testcontainers
时,需要确保本地已安装 Docker。
在 build
配置中,Failsafe
插件会在 Maven 的 verify
阶段执行集成测试并检查测试结果。如果集成测试失败,构建过程将终止,从而保证代码质量。
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 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89
| <?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <parent> <groupId>cn.tangrl</groupId> <artifactId>it</artifactId> <version>0.0.1-SNAPSHOT</version> </parent>
<artifactId>integration-test</artifactId>
<properties> <maven.compiler.source>21</maven.compiler.source> <maven.compiler.target>21</maven.compiler.target> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> </properties>
<dependencies> <dependency> <groupId>cn.tangrl</groupId> <artifactId>server</artifactId> <version>0.0.1-SNAPSHOT</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-test</artifactId> <scope>test</scope> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> <scope>test</scope> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> </dependency> <dependency> <groupId>org.testcontainers</groupId> <artifactId>testcontainers</artifactId> <scope>test</scope> </dependency> <dependency> <groupId>org.testcontainers</groupId> <artifactId>junit-jupiter</artifactId> <scope>test</scope> </dependency> <dependency> <groupId>org.testcontainers</groupId> <artifactId>mysql</artifactId> <scope>test</scope> </dependency> </dependencies>
<build> <plugins> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-failsafe-plugin</artifactId> <version>3.3.1</version> <executions> <execution> <goals> <goal>integration-test</goal> <goal>verify</goal> </goals> </execution> </executions> </plugin> </plugins> </build>
</project>
|
通过这种配置,integration-test
模块可以引用 server
模块中的代码,并使用 Testcontainers
和 Failsafe
进行集成测试,确保各模块的功能能够在一起顺利工作。
- 测试 repository 代码
在进行集成测试时,通常不会单独测试 model
实体类。实体类的验证通常依赖于上层 repository
的集成测试,主要测试实体类的 CRUD 操作。
对于 repository
层的测试,重点是测试自定义的方法。
下面的代码展示了 UserRepository
类的集成测试。测试中使用 Testcontainers 来模拟真实的 MySQL 数据库环境。通过 @Container
注解启动 MySQL 容器,并使用 @DynamicPropertySource
动态注入容器的连接信息到 Spring 环境中。测试运行在完整的 Spring Boot 上下文中,@SpringBootTest
注解确保加载所有必要的 Bean。在测试方法中,首先将一些测试数据保存到数据库中,然后使用 findByName
方法查询用户,并验证查询结果是否正确。这一系列步骤确保了 UserRepository
在真实数据库环境中的功能表现是正确的。
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
| @Testcontainers @SpringBootTest class UserRepositoryIT {
@Container public static MySQLContainer<?> mysqlContainer = new MySQLContainer<>( "mysql:8.0.33").withDatabaseName("testdb").withUsername("testuser") .withPassword("testpass");
@Autowired private UserRepository userRepository;
@DynamicPropertySource static void configureTestDatabase(DynamicPropertyRegistry registry) { registry.add("spring.datasource.url", mysqlContainer::getJdbcUrl); registry.add("spring.datasource.username", mysqlContainer::getUsername); registry.add("spring.datasource.password", mysqlContainer::getPassword); registry.add("spring.datasource.driver-class-name", () -> "com.mysql.cj.jdbc.Driver"); }
@BeforeEach void setUp() { }
@AfterEach void tearDown() { userRepository.deleteAll(); }
@Test void testFindByName() { User user1 = new User(null, "John Doe", "john.doe@example.com"); User user2 = new User(null, "Jane Doe", "jane.doe@example.com"); userRepository.save(user1); userRepository.save(user2);
List<User> johns = userRepository.findByName("John Doe"); List<User> janes = userRepository.findByName("Jane Doe");
assertEquals(1, johns.size(), "Should find one John Doe"); assertEquals("John Doe", johns.get(0).getName());
assertEquals(1, janes.size(), "Should find one Jane Doe"); assertEquals("Jane Doe", janes.get(0).getName()); } }
|
- 测试 service 服务类代码
下述代码是 UserService
类的集成测试,使用 Testcontainers 通过容器化的 MySQL 数据库来模拟真实的数据库环境。测试运行在完整的 Spring Boot 上下文中,通过 @SpringBootTest
注解加载所有必要的 Bean。在每次测试之前,通过 @BeforeEach
准备数据,在测试完成后通过 @AfterEach
清理数据库。测试用例 testFindUsersByName
验证了 UserService
的 findUsersByName
方法,确保它能够正确地根据用户名查询到用户。这种集成测试方式确保了 UserService
在实际运行环境中的功能是正确的,并且与数据库的交互正常。
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
| @Testcontainers @SpringBootTest public class UserServiceIT {
@Container public static MySQLContainer<?> mysqlContainer = new MySQLContainer<>( "mysql:8.0.33").withDatabaseName("testdb").withUsername("testuser") .withPassword("testpass");
@Autowired private UserService userService; @Autowired private UserRepository userRepository;
@DynamicPropertySource static void configureTestDatabase(DynamicPropertyRegistry registry) { registry.add("spring.datasource.url", mysqlContainer::getJdbcUrl); registry.add("spring.datasource.username", mysqlContainer::getUsername); registry.add("spring.datasource.password", mysqlContainer::getPassword); registry.add("spring.datasource.driver-class-name", () -> "com.mysql.cj.jdbc.Driver"); }
@BeforeEach void setUp() { }
@AfterEach void tearDown() { userRepository.deleteAll(); }
@Test void testFindUsersByName() { User user1 = new User(null, "John Doe", "john.doe@example.com"); User user2 = new User(null, "Jane Doe", "jane.doe@example.com"); userRepository.save(user1); userRepository.save(user2);
List<User> foundUsers = userService.findUsersByName("John Doe");
assertEquals(1, foundUsers.size()); assertEquals("John Doe", foundUsers.get(0).getName()); } }
|
- 测试 controller 接口类代码
下述代码是对 UserController
类的集成测试,使用 TestRestTemplate
来模拟实际的 HTTP 请求和响应。测试运行在完整的 Spring Boot 应用上下文中,并通过 Testcontainers
提供的 MySQL 容器模拟真实的数据库环境。测试用例验证了 API 在查询存在和不存在的用户时的行为,确保 UserController
的 REST API 能够正确返回预期的结果。
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 58 59 60 61 62 63 64 65 66
| @Testcontainers @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) public class UserControllerIT {
@Container public static MySQLContainer<?> mysqlContainer = new MySQLContainer<>( "mysql:8.0.33").withDatabaseName("testdb").withUsername("testuser") .withPassword("testpass");
@Autowired private TestRestTemplate restTemplate;
@Autowired private UserRepository userRepository;
@Autowired private UserService userService;
@DynamicPropertySource static void configureTestDatabase(DynamicPropertyRegistry registry) { registry.add("spring.datasource.url", mysqlContainer::getJdbcUrl); registry.add("spring.datasource.username", mysqlContainer::getUsername); registry.add("spring.datasource.password", mysqlContainer::getPassword); registry.add("spring.datasource.driver-class-name", () -> "com.mysql.cj.jdbc.Driver"); }
@BeforeEach void setUp() { userRepository.deleteAll(); userRepository.save(new User(null, "John Doe", "john.doe@example.com")); userRepository.save(new User(null, "Jane Doe", "jane.doe@example.com")); }
@Test void testGetUsersByName() { ResponseEntity<User[]> response = restTemplate.getForEntity("/api/users/name/John Doe", User[].class);
assertEquals(HttpStatus.OK, response.getStatusCode());
User[] users = response.getBody(); assertNotNull(users); assertEquals(1, users.length); assertEquals("John Doe", users[0].getName()); assertEquals("john.doe@example.com", users[0].getEmail()); }
@Test void testGetUsersByName_NotFound() { ResponseEntity<User[]> response = restTemplate.getForEntity( "/api/users/name/Nonexistent User", User[].class);
assertEquals(HttpStatus.OK, response.getStatusCode());
User[] users = response.getBody(); assertNotNull(users); assertEquals(0, users.length); } }
|
运行集成测试
运行集成测试的方法有下面几种:
- 通过 IDE 运行:右键点击测试类或方法,然后选择 “Run” 或 “Debug” 选项来执行测试。IDE 通常会提供一个测试结果窗口,显示测试通过、失败或被忽略的详细信息。
- 通过构建工具运行:例如
mvn verify
。
- 在 CI/CD 环境中自动运行:在持续集成/持续交付(CI/CD)管道中,测试通常会在每次代码提交后自动运行。CI/CD 工具(如 Jenkins、GitLab CI、Travis CI)会在构建过程中执行测试,并根据测试结果决定是否继续后续步骤。
代码仓库
https://github.com/rongliangtang/Spring-Boot-Demo/tree/main/demo-it