ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

单元测试与 Testcontainers 实战:JUnit 5 + Mockito + AssertJ 完整指南

单元测试与 Testcontainers 实战:JUnit 5 + Mockito + AssertJ 完整指南 1. 引言单元测试是保障代码质量的第一道防线。很多团队虽然写了测试但要么只测了「快乐路径」要么把单元测试写成了集成测试导致测试跑得慢、不稳定、难以维护。本文从 JUnit 5 Mockito AssertJ 这套主流组合出发讲清楚单元测试与集成测试的边界再通过 Testcontainers 在真实 MySQL/Redis 上跑集成测试最后给出 CI 中落地测试的最佳实践。2. 技术栈概览本文使用的核心依赖如下JUnit 5测试框架提供Test、BeforeEach、参数化测试、嵌套测试等能力。MockitoMock 框架用于隔离外部依赖模拟数据库、Redis、外部 HTTP 服务等。AssertJ流式断言库提供更可读、更强大的断言语法。Testcontainers在测试中启动真实 Docker 容器MySQL、Redis 等让集成测试贴近生产环境。对应的 Maven 依赖片段dependencygroupIdorg.junit.jupiter/groupIdartifactIdjunit-jupiter/artifactIdversion5.10.2/versionscopetest/scope/dependencydependencygroupIdorg.mockito/groupIdartifactIdmockito-core/artifactIdversion5.11.0/versionscopetest/scope/dependencydependencygroupIdorg.assertj/groupIdartifactIdassertj-core/artifactIdversion3.25.3/versionscopetest/scope/dependencydependencygroupIdorg.testcontainers/groupIdartifactIdmysql/artifactIdversion1.19.8/versionscopetest/scope/dependency3. 单元测试与集成测试的边界很多测试难以维护根源在于边界模糊。先明确两个概念单元测试只测试一个类/方法的行为外部依赖全部用 Mock 替换。速度快毫秒级、稳定、定位问题精准。集成测试验证多个真实组件协作如 Service 真实 MySQL 真实 Redis。速度慢秒级、更接近生产但环境依赖重。判断标准很简单测试中是否启动了真实的外部资源数据库、缓存、消息队列、HTTP 服务启动了就是集成测试否则是单元测试。实践中推荐「测试金字塔」策略底层大量单元测试覆盖业务逻辑与分支。中层适量集成测试覆盖 Repository 与外部组件交互。顶层少量端到端测试验证核心链路。4. JUnit 5 基础用法JUnit 5 由三部分组成JUnit Platform启动入口、JUnit Jupiter编程模型、JUnit Vintage兼容 JUnit 4。一个最基础的测试类importorg.junit.jupiter.api.Test;importstaticorg.assertj.core.api.Assertions.assertThat;classCalculatorTest{TestvoidshouldAddTwoNumbers(){CalculatorcalculatornewCalculator();intresultcalculator.add(2,3);assertThat(result).isEqualTo(5);}}常用注解BeforeEach/AfterEach每个测试前后执行。BeforeAll/AfterAll所有测试前后执行一次需 static。DisplayName给测试起中文/可读名称。ParameterizedTest参数化测试配合ValueSource、CsvSource使用。参数化测试示例ParameterizedTestCsvSource({2, 3, 5,0, 0, 0,-1, 1, 0})voidshouldAddWithParameters(inta,intb,intexpected){assertThat(newCalculator().add(a,b)).isEqualTo(expected);}5. Mockito 隔离外部依赖单元测试的核心是隔离。当被测类依赖UserRepository、RedisClient等外部组件时用 Mockito 创建 Mock 对象并定义其行为。importorg.junit.jupiter.api.Test;importorg.junit.jupiter.api.extension.ExtendWith;importorg.mockito.InjectMocks;importorg.mockito.Mock;importorg.mockito.junit.jupiter.MockitoExtension;importstaticorg.mockito.Mockito.when;importstaticorg.assertj.core.api.Assertions.assertThat;ExtendWith(MockitoExtension.class)classUserServiceTest{MockprivateUserRepositoryuserRepository;InjectMocksprivateUserServiceuserService;TestvoidshouldReturnUserWhenExists(){UserusernewUser(1L,Alice);when(userRepository.findById(1L)).thenReturn(java.util.Optional.of(user));UserresultuserService.getUser(1L);assertThat(result.getName()).isEqualTo(Alice);}}常用 Mockito 方法when(...).thenReturn(...)定义返回值。when(...).thenThrow(...)定义抛异常。verify(mock).method(...)验证方法是否被调用。doNothing().when(mock).method(...)void 方法不做事。Mock/InjectMocks自动创建 Mock 并注入。6. AssertJ 流式断言AssertJ 让断言更接近自然语言且支持链式调用失败信息也更友好。importstaticorg.assertj.core.api.Assertions.assertThat;TestvoidshouldAssertCollections(){ListStringnamesList.of(Alice,Bob,Charlie);assertThat(names).hasSize(3).contains(Alice).doesNotContain(David).allMatch(name-name.length()2);}常用断言场景字符串isEqualTo、startsWith、contains。集合hasSize、containsExactly、allMatch。异常assertThatThrownBy(...).isInstanceOf(...).hasMessage(...)。对象属性extracting(name)提取字段后断言。异常断言示例assertThatThrownBy(()-userService.getUser(-1L)).isInstanceOf(IllegalArgumentException.class).hasMessageContaining(invalid id);7. Testcontainers 启动 MySQL/Redis集成测试需要真实环境。Testcontainers 通过 Docker 在测试启动时拉起容器测试结束自动销毁无需本地预装数据库。7.1 启动 MySQL 容器importorg.testcontainers.containers.MySQLContainer;importorg.testcontainers.junit.jupiter.Container;importorg.testcontainers.junit.jupiter.Testcontainers;TestcontainersclassUserRepositoryIntegrationTest{ContainerstaticMySQLContainer?mysqlnewMySQLContainer(mysql:8.0).withDatabaseName(testdb).withUsername(test).withPassword(test);TestvoidshouldConnectToMysql(){StringjdbcUrlmysql.getJdbcUrl();assertThat(jdbcUrl).contains(testdb);}}7.2 启动 Redis 容器importorg.testcontainers.containers.GenericContainer;importorg.testcontainers.junit.jupiter.Container;importorg.testcontainers.junit.jupiter.Testcontainers;TestcontainersclassRedisCacheIntegrationTest{ContainerstaticGenericContainer?redisnewGenericContainer(redis:7-alpine).withExposedPorts(6379);TestvoidshouldConnectToRedis(){Stringhostredis.getHost();Integerportredis.getMappedPort(6379);assertThat(host).isNotBlank();assertThat(port).isPositive();}}7.3 与 Spring Boot 集成在 Spring Boot 测试中可通过DynamicPropertySource把容器连接信息注入到应用配置TestcontainersSpringBootTestclassOrderServiceIntegrationTest{staticMySQLContainer?mysqlnewMySQLContainer(mysql:8.0);DynamicPropertySourcestaticvoidconfigureProperties(DynamicPropertyRegistryregistry){registry.add(spring.datasource.url,mysql::getJdbcUrl);registry.add(spring.datasource.username,mysql::getUsername);registry.add(spring.datasource.password,mysql::getPassword);}TestvoidshouldSaveOrder(){// 使用真实数据库执行集成测试}}8. CI 中跑测试的最佳实践在 CI如 GitHub Actions、GitLab CI中跑测试需要注意以下几点8.1 确保 Docker 可用Testcontainers 依赖 Docker。CI Runner 需安装并启动 Docker。GitHub Actions 的ubuntu-latest自带 Docker开箱即用。8.2 分离单元测试与集成测试用 Maven Surefire 插件按命名规则区分plugingroupIdorg.apache.maven.plugins/groupIdartifactIdmaven-surefire-plugin/artifactIdversion3.2.5/versionconfigurationincludesinclude**/*Test.java/includeinclude**/*IntegrationTest.java/include/includes/configuration/pluginCI 中可先跑单元测试快、稳定再跑集成测试慢、依赖 Docker# GitHub Actions 示例jobs:unit-test:runs-on:ubuntu-lateststeps:-uses:actions/checkoutv4-uses:actions/setup-javav4with:distribution:temurinjava-version:17-run:mvn test-Dtest*Test-DfailIfNoTestsfalseintegration-test:runs-on:ubuntu-lateststeps:-uses:actions/checkoutv4-uses:actions/setup-javav4with:distribution:temurinjava-version:17-run:mvn test-Dtest*IntegrationTest-DfailIfNoTestsfalse8.3 控制并行与资源集成测试默认串行执行避免容器端口冲突。可给容器设置固定端口映射但更推荐使用随机映射端口避免并行冲突。设置合理的超时时间防止容器拉取镜像过慢导致 CI 超时。8.4 缓存 Docker 镜像CI 中重复拉取镜像很耗时。GitHub Actions 可缓存 Docker 层或使用docker pull预热镜像-name:Pre-pull testcontainers imagesrun:|docker pull mysql:8.0 docker pull redis:7-alpine9. 常见坑与建议Mock 过度单元测试中把一切 Mock 掉导致测试只验证 Mock 行为而非真实逻辑。建议只 Mock 外部边界。集成测试污染多个测试共享数据库状态导致相互影响。建议每个测试用事务回滚或清理数据。容器启动慢Testcontainers 首次拉镜像较慢可复用容器withReuse(true)或预热镜像。断言不具体assertTrue(result)不如assertThat(result).isEqualTo(expected)可读。测试命名用「should 行为 条件」命名如shouldReturnUserWhenIdExists让失败信息一目了然。10. 总结本文从单元测试与集成测试的边界出发介绍了 JUnit 5、Mockito、AssertJ 的核心用法并通过 Testcontainers 在真实 MySQL/Redis 上跑集成测试最后给出了 CI 中的落地实践。核心要点单元测试用 Mock 隔离外部依赖追求快与稳定。集成测试用 Testcontainers 启动真实容器贴近生产。CI 中分离两类测试确保 Docker 可用并预热镜像。测试不是负担而是对代码质量的持续投资。从今天开始为你的核心业务逻辑补上测试吧。
返回列表