数据库单元测试如何落地——测试框架、样例用例与事务边界

发布时间:2026/9/13 22:35:35
数据库单元测试如何落地——测试框架、样例用例与事务边界 文章目录每日一句正能量前言1. 背景与问题2. 环境与数据3. 复现过程3.1 H2 测试通过MySQL 上线失败3.2 Mock 无法测试唯一约束4. 方案实施4.1 Testcontainers 最小配置4.2 让 Flyway 自动初始化测试数据库4.3 JDBC 正常路径测试4.4 唯一约束异常测试4.5 不要用测试自动回滚掩盖事务问题4.6 Service 回滚测试4.7 JDBC 手工事务测试4.8 MyBatis Mapper 测试4.9 测动态 SQL 的空条件4.10 JPA Repository 测试4.11 JPA 唯一键测试4.12 测试事务传播4.13 并发库存测试4.14 SQLState 与异常分类测试4.15 测试数据怎么管理4.16 SQL Fixture4.17 真实生产数据不要复制进测试4.18 Schema Migration 测试4.19 回滚测试4.20 CI 并行执行4.21 测试矩阵5. 结果对比落地前落地后6. 风险与复盘6.1 不要把所有数据库测试都叫“单元测试”6.2 Testcontainers 不是零成本6.3 自动回滚可能隐藏 commit 问题6.4 并发测试不能要求绝对耗时6.5 数据库版本必须接近生产6.6 测试也要覆盖驱动升级6.7 不要让 Fixture 变成第二套生产数据库结语每日一句正能量爱是细微的看见与为其创造的宁静。自我的内核是你永不熄灭的能量源与庇护所。与世界的相处之道在于平衡——既勇敢地寻找拼图也智慧地修筑城池。这些句子它们不喧嚣却在沉默中震耳欲聋。前言很多研发团队已经有很成熟的 Java 单元测试但一到数据库层测试往往突然变得很薄。常见情况是Service 用 Mockito 测过了 Repository 只测了方法有没有被调用 SQL 没跑过真实数据库 唯一约束没验证 事务回滚没验证 MyBatis 映射没验证 JPA flush 时机没验证结果代码覆盖率看起来很高真正上线后却仍然会遇到SQL 语法不兼容 字段名写错 唯一键异常未处理 事务没回滚 批量 SQL 行数判断错误 MySQL 能跑H2 跑法却完全不同所以数据库单元测试不能只理解成“给 DAO 写几个 Mock”。更可靠的做法是把数据库层测试设计成一个轻量但真实的集成环境让Schema 驱动 ORM SQL 约束 事务一起被验证。本文以 Spring Boot 为例给出 Testcontainers JUnit Flyway 的落地方式并分别覆盖 JDBC、MyBatis、JPA/Hibernate以及异常与事务边界测试。1. 背景与问题先看一段很常见的 RepositoryRepositoryRequiredArgsConstructorpublicclassOrderRepository{privatefinalJdbcTemplatejdbcTemplate;publicintinsert(StringorderNo,BigDecimalamount){returnjdbcTemplate.update( INSERT INTO orders( order_no, amount, status ) VALUES (?, ?, CREATED) ,orderNo,amount);}}如果测试只写verify(jdbcTemplate).update(...);实际上没有验证任何数据库行为。下面这些问题都发现不了order_no 字段拼错 DECIMAL 精度不匹配 唯一约束不存在 SQL 方言不支持 驱动参数绑定有差异 事务异常没有回滚数据库测试最有价值的地方就是验证这些“只有真实数据库才能给答案”的行为。2. 环境与数据示例环境JDK 21 Spring Boot 3.3 JUnit 5 Testcontainers MySQL 8.0 PostgreSQL 15 Flyway Spring JDBC MyBatis 3.x Hibernate 6 / JPA AssertJMaven 依赖示例dependencygroupIdorg.testcontainers/groupIdartifactIdmysql/artifactIdscopetest/scope/dependencydependencygroupIdorg.testcontainers/groupIdartifactIdjunit-jupiter/artifactIdscopetest/scope/dependencydependencygroupIdorg.springframework.boot/groupIdartifactIdspring-boot-starter-test/artifactIdscopetest/scope/dependency测试 SchemaCREATETABLEorders(idBIGINTPRIMARYKEYAUTO_INCREMENT,order_noVARCHAR(64)NOTNULL,amountDECIMAL(18,2)NOTNULL,statusVARCHAR(32)NOTNULL,created_atTIMESTAMP(6)NOTNULLDEFAULTCURRENT_TIMESTAMP(6),UNIQUEKEYuk_order_no(order_no));库存表CREATETABLEinventory(sku_idBIGINTPRIMARYKEY,stockINTNOTNULL,versionBIGINTNOTNULLDEFAULT0);这些 DDL 不应该单独复制进测试目录。最好直接复用生产迁移脚本db/migration让测试容器启动时真正执行 Flyway。3. 复现过程3.1 H2 测试通过MySQL 上线失败很多项目过去喜欢用H2做 Repository 测试。问题是 H2 和 MySQL/PostgreSQL 在很多细节上并不完全一致类型 函数 关键字 索引 锁 事务隔离 JSON ON DUPLICATE KEY RETURNING例如生产 SQLINSERTINTOorders(...)VALUES(...)ONDUPLICATEKEYUPDATEstatusVALUES(status);H2 测试环境未必能真实模拟 MySQL 行为。这就是为什么现在越来越多团队使用Testcontainers直接启动真实 MySQL/PostgreSQL。3.2 Mock 无法测试唯一约束应用代码orderRepository.insert(O-1001,newBigDecimal(100));第一次成功。第二次相同order_no应该由数据库唯一键拒绝。Mock Repository 无法验证uk_order_no到底存在不存在。4. 方案实施4.1 Testcontainers 最小配置JUnitTestcontainersSpringBootTestclassOrderRepositoryTest{ContainerstaticMySQLContainer?mysqlnewMySQLContainer(mysql:8.0).withDatabaseName(testdb).withUsername(test).withPassword(test);DynamicPropertySourcestaticvoiddatasource(DynamicPropertyRegistryregistry){registry.add(spring.datasource.url,mysql::getJdbcUrl);registry.add(spring.datasource.username,mysql::getUsername);registry.add(spring.datasource.password,mysql::getPassword);}}Spring Boot 启动后DataSource Flyway Repository全部连接到真实 MySQL 容器。4.2 让 Flyway 自动初始化测试数据库测试配置spring:flyway:enabled:truelocations:-classpath:db/migration这样 CI 每次运行测试时都会验证从空数据库开始 所有 migration 能否完整执行。这是非常有价值的 Schema 回归测试。4.3 JDBC 正常路径测试AutowiredOrderRepositoryrepository;TestvoidshouldInsertOrder(){introwsrepository.insert(O-1001,newBigDecimal(100.00));assertThat(rows).isEqualTo(1);Orderorderrepository.findByOrderNo(O-1001);assertThat(order.amount()).isEqualByComparingTo(100.00);assertThat(order.status()).isEqualTo(CREATED);}这个测试同时验证SQL 参数绑定 字段映射 DECIMAL 查询4.4 唯一约束异常测试TestvoidduplicateOrderNoShouldFail(){repository.insert(O-1001,newBigDecimal(100));assertThatThrownBy(()-repository.insert(O-1001,newBigDecimal(200))).isInstanceOf(DuplicateKeyException.class);}这个用例验证的不只是 Java 异常。它实际验证数据库唯一约束 Spring 异常转换 Repository 异常传播都正常工作。4.5 不要用测试自动回滚掩盖事务问题Spring Test 很常见TransactionalTestvoidtestSomething(){}测试结束后自动回滚。这很方便但有一个副作用你可能永远没有真正测试过 commit。例如 JPA 某些异常只在flush / commit时发生。所以事务测试最好区分两类数据清理型测试 可用测试事务自动回滚 事务行为测试 显式执行真实事务并验证结果4.6 Service 回滚测试业务代码TransactionalpublicvoidcreateOrderAndLedger(StringorderNo,BigDecimalamount){orderRepository.insert(orderNo,amount);ledgerRepository.insert(orderNo,amount);}人为让第二步失败ledgerRepository.insert(...)触发唯一键冲突。测试TestvoidledgerFailureShouldRollbackOrder(){assertThatThrownBy(()-service.createOrderAndLedger(O-ROLLBACK-1,newBigDecimal(100))).isInstanceOf(DataAccessException.class);assertThat(orderRepository.exists(O-ROLLBACK-1)).isFalse();}这里真正验证的是第二步异常 - Spring 事务标记回滚 - 第一条 INSERT 不落库4.7 JDBC 手工事务测试如果项目不用 Spring 事务TestvoidmanualTransactionShouldRollback()throwsException{try(ConnectioncdataSource.getConnection()){c.setAutoCommit(false);try{insertOrder(c,O-1);thrownewRuntimeException(injected failure);}catch(Exceptione){c.rollback();}}assertThat(countOrder(O-1)).isZero();}这样能验证自己封装的事务代码是否正确。4.8 MyBatis Mapper 测试MapperselectidfindByOrderNoresultTypeOrderSELECT id, order_no, amount, status FROM orders WHERE order_no #{orderNo}/select测试SpringBootTestTestcontainersclassOrderMapperTest{AutowiredOrderMappermapper;TestvoidshouldMapFieldsCorrectly(){mapper.insert(newOrder(null,O-2001,newBigDecimal(88.80),CREATED));Orderresultmapper.findByOrderNo(O-2001);assertThat(result.getOrderNo()).isEqualTo(O-2001);assertThat(result.getAmount()).isEqualByComparingTo(88.80);}}MyBatis 测试最应该抓XML SQL 字段别名 resultMap TypeHandler 动态 SQL 影响行数4.9 测动态 SQL 的空条件例如selectidsearchSELECT * FROM orderswhereifteststatus ! nullstatus #{status}/if/where/select必须测试status 有值 status null因为动态 SQL 最容易在空条件时生成错误语句。4.10 JPA Repository 测试DataJpaTestTestcontainersclassOrderJpaTest{AutowiredOrderJpaRepositoryrepository;TestvoidshouldPersistEntity(){OrderEntityentitynewOrderEntity(O-JPA-1,newBigDecimal(99.00));repository.saveAndFlush(entity);OrderEntityfoundrepository.findByOrderNo(O-JPA-1).orElseThrow();assertThat(found.getId()).isNotNull();}}注意saveAndFlush比单独save更适合验证数据库约束。因为 Hibernate 可能延迟执行 SQL。4.11 JPA 唯一键测试TestvoidduplicateShouldFailOnFlush(){repository.saveAndFlush(newOrderEntity(O-DUP-1,newBigDecimal(100)));assertThatThrownBy(()-repository.saveAndFlush(newOrderEntity(O-DUP-1,newBigDecimal(200)))).isInstanceOf(DataIntegrityViolationException.class);}如果只调用save()异常可能推迟到事务提交时才发生。4.12 测试事务传播例如Transactionalpublicvoidouter(){insertOrder();innerService.audit();thrownewRuntimeException();}而Transactional(propagationPropagation.REQUIRES_NEW)publicvoidaudit(){insertAudit();}测试必须验证order 被回滚 audit 是否保留因为这是事务传播的真实业务语义。4.13 并发库存测试数据库测试不能只做单线程。库存扣减UPDATEinventorySETstockstock-1WHEREsku_id?ANDstock1;并发测试TestvoidshouldNotOversell()throwsException{intthreads20;ExecutorServicepoolExecutors.newFixedThreadPool(threads);CountDownLatchstartnewCountDownLatch(1);CountDownLatchdonenewCountDownLatch(threads);AtomicIntegersuccessnewAtomicInteger();for(inti0;ithreads;i){pool.submit(()-{try{start.await();introwsinventoryRepository.deduct(1001L);if(rows1){success.incrementAndGet();}}catch(Exceptionignored){}finally{done.countDown();}});}start.countDown();done.await();assertThat(success.get()).isEqualTo(10);assertThat(inventoryRepository.getStock(1001L)).isZero();}这里必须使用真实数据库。Mock 和 H2 很难可靠模拟生产锁行为。4.14 SQLState 与异常分类测试例如死锁、唯一键、锁超时。测试可以断言Spring 异常类型 底层 SQLSTATE确保应用的异常分类器不会因为驱动升级后失效。4.15 测试数据怎么管理不要让测试之间互相依赖。推荐三种方式每个测试自己准备数据 BeforeEach 清理 事务自动回滚不要testA 先创建订单 testB 假设 testA 已运行测试顺序必须无关。4.16 SQL Fixture可以使用DELETEFROMsettlement_ledger;DELETEFROMorders;INSERTINTOorders(...)VALUES(...);但要控制 Fixture 大小。测试数据越接近“最小必要数据”越容易维护。4.17 真实生产数据不要复制进测试尤其不要把手机号 身份证 真实订单 支付信息直接导入 CI。如果需要分布特征可以生成脱敏或合成数据。4.18 Schema Migration 测试CI 应至少做一次空数据库 - 执行全部 Flyway - 应用启动还可以做旧版本 Schema - 执行增量 migration - 应用启动这能提前发现脚本顺序 checksum 缺失 DDL 兼容问题4.19 回滚测试对于数据迁移不一定每个 DDL 都能物理回滚。但必须测试应用回滚后旧 Schema 是否还能工作这比写一个形式上的DROP COLUMN反向脚本更有价值。4.20 CI 并行执行Testcontainers 容器启动有成本。可以测试类共享容器 CI 层缓存镜像 合理分组但不要为了快退回全部 Mock否则数据库层失去测试价值。4.21 测试矩阵团队至少应该建立统一 Checklist正常 CRUD 唯一约束 外键/检查约束 NULL 边界长度 事务回滚 事务传播 批量 SQL 动态 SQL 并发冲突 SQLState Migration这样数据库测试才会从个人习惯变成工程规范。5. 结果对比落地前测试Service Mock Repository Mock H2 少量测试上线风险SQL 方言错误 约束缺失 事务未回滚 ORM flush 时机问题 并发行为未知落地后CI启动真实 MySQL/PostgreSQL 执行 Flyway 运行 JDBC/MyBatis/JPA 测试 验证异常 验证事务 验证并发收益数据库改动能被自动回归 Repository 不再是黑盒 Migration 进入测试链 异常映射可验证 事务语义可验证数据库问题被前移到开发提交阶段而不是生产环境。6. 风险与复盘6.1 不要把所有数据库测试都叫“单元测试”严格来说使用真实数据库后更接近集成测试但在研发团队实践里它依然可以作为 Repository 层的快速测试套件。关键不是名称而是反馈速度 隔离性 真实性6.2 Testcontainers 不是零成本缺点包括Docker 依赖 启动耗时 CI 资源 镜像下载所以要合理分层。纯业务计算仍然使用普通单元测试。只有数据库行为才进入容器。6.3 自动回滚可能隐藏 commit 问题JPA 项目尤其要注意。有些约束只会在flush commit时出现。测试要主动saveAndFlush或执行真实事务。6.4 并发测试不能要求绝对耗时CI 环境性能波动很大。不要断言SQL 必须 10ms更适合断言数据结果 无超卖 无重复 事务正确性能测试应单独做。6.5 数据库版本必须接近生产如果生产MySQL 8.0.36测试却长期使用latest可能发生行为漂移。建议固定主版本甚至具体版本。6.6 测试也要覆盖驱动升级JDBC Driver 升级后异常类型 时间类型 批量行为 参数绑定都可能变化。依赖升级 PR 应自动运行数据库测试套件。6.7 不要让 Fixture 变成第二套生产数据库测试数据集越大越难维护。目标应该是用最小数据触发最大边界。结语数据库单元测试真正要解决的不是DAO 有没有被调用。而是SQL 能不能执行 Schema 是否匹配 约束是否生效 异常是否正确传播 事务是否真的回滚。一套成熟的落地方案通常是JUnit Testcontainers 真实数据库 Flyway Repository/Service 边界测试可以把核心原则总结为一句话能由数据库决定的行为 就尽量让真实数据库参与测试。当 Repository、Schema、驱动和事务都进入 CI 后数据库开发就不再是“上线后才能验证”的黑盒而会真正成为持续交付体系里可重复、可自动化、可回归的一部分。转载自https://blog.csdn.net/u014727709/article/details/165243478欢迎 点赞✍评论⭐收藏欢迎指正