近几年接手过几个跑了五六年的老后台系统,最让人头疼的从来不是代码写得多烂,而是没人敢动——改一行,跑一遍 JUnit,全绿,发上线,炸了。后来团队内部定了一条死规矩:凡是碰持久层的提交,单测必须附覆盖率报告,行覆盖率不达标不允许合并。工具链也很朴素,没什么花哨的:JUnit 负责写用例,HSQLDB 顶替真实数据库跑内存态数据,JPA 走 ORM 把实体映射跑通,Cobertura 负责出覆盖率报告并卡住构建。这套组合在当年几乎是 Java 后端的标配,但 Cobertura 这个工具本身早就停止维护了,踩坑的概率比新工具高得多,很多细节如果没人提前告诉你,能把人耗掉一整天。这篇就把这套组合从头到尾拆一遍:它解决的是什么问题、为什么在特定场景下仍值得用、依赖怎么配、内存库怎么接、报告怎么读、覆盖率卡在 60% 上不去的时候从哪儿下手。无论你是刚接手老项目的初中级开发,还是正在给遗留系统补测试的技术负责人,下面的内容都可以直接抄去改。
1. 这套覆盖率加单测加内存库加 ORM 的组合到底解决什么问题
1.1 从一次"测试全绿但线上炸了"的真实场景说起
我印象最深的一次,是给一个订单模块加了一个查询条件。本地跑测试,全部通过,CI 上也是绿的,第二天上线,查询接口直接超时。排查下来原因很朴素:测试跑的是开发机上的真实数据库,而生产库上那个字段没有索引,同时我新加的if分支里拼了一个全表扫描的 SQL。测试之所以"绿",是因为测试数据只有三条,扫描三行当然快。
这件事暴露了三个问题,后来我用这套工具链逐个解决:
第一,测试有没有真正跑到我改的那几行代码?不知道。JUnit 只告诉你断言过没过,不告诉你方法体里的else分支是不是从来没进去过。这就是覆盖率工具存在的意义——Cobertura 会告诉你哪一行被执行了、哪个分支被走过了。
第二,测试环境的数据和结构是否可信?不可信。每个人的本地库版本都不一样,表结构靠手工同步,测试结果无法复现。这就是 HSQLDB 内存库的价值——每次测试启动时按实体定义重建一遍表结构,跑完直接销毁,所有人在任何机器上跑出来的结果完全一致。
第三,数据访问层到底有没有被测试覆盖到?大部分项目里,Service 层测试是拿 Mock 糊弄过去的,DAO 层几乎裸奔。JPA 加内存库的组合,让 DAO 层可以用真 SQL 跑起来,而不是靠 Mock 假装它能跑。
三个问题叠加起来,本质上是在回答一句话:你的测试到底是"跑通了",还是"验证过了"。前者只看红绿灯,后者看的是代码路径。Cobertura 在这里的角色不是锦上添花,而是把"我以为测过了"变成"数字上确实测过了"。
1.2 四个组件各自的职责边界,别混着用
很多人第一次接触这套组合,会把四个东西当成一个整体的"测试框架",其实它们的职责边界非常清晰,混用就会出问题。
| 组件 | 职责 | 不该做的事 | 常见误用 |
|---|---|---|---|
| JUnit | 组织测试方法、提供断言、控制生命周期 | 不负责数据准备、不负责覆盖率统计 | 在测试方法里写业务逻辑校验以外的代码 |
| HSQLDB | 提供内存态关系数据库,承载测试期数据 | 不模拟真实库的性能特征、不模拟分库分表 | 拿它做性能压测,或者当作生产库的等价物 |
| JPA | 把实体映射到表、提供持久化 API | 不负责建库、不负责事务边界管理 | 在测试里依赖hbm2ddl之外的手工 DDL |
| Cobertura | 字节码插桩、统计行与分支执行情况、生成报告 | 不提升测试质量、不替代 Code Review | 把它当成质量守门员,只看百分比 |
用一句话概括这四者的关系:JUnit 是测试的组织者,HSQLDB 是测试数据的载体,JPA 是数据访问的通道,Cobertura 是测试效果的度量尺。度量尺量出来的数字再漂亮,如果尺子本身刻度坏了(比如插桩失败但构建没报错),那这个数字一文不值。所以后面我会花不少篇幅讲怎么验证"尺子是不是准的"。
1.3 为什么在有些项目里仍然选 Cobertura 而不是 JaCoCo
这个问题几乎每次技术分享都会被人问。客观地说,如果今天从零开始一个新项目,我会直接上 JaCoCo,不会犹豫。它支持到最新 JDK,用 Java Agent 方式在线插桩,不需要改 classpath,多模块聚合也更成熟。
但现实里我仍然会遇到必须用 Cobertura 的场景,原因通常是这几类:
一是历史构建脚本已经深度绑定。有些老项目的父 POM 里,Cobertura 的check目标挂在verify阶段,配套的 CI 流水线脚本解析的是target/site/cobertura/cobertura.xml里的line-rate和branch-rate属性。换成 JaCoCo,报告路径、XML 结构、字段名全变了,流水线解析逻辑要跟着重写,牵一发动全身。
二是团队已有覆盖率基线和习惯。老项目往往有一份历史覆盖率报告,用来做趋势对比。换工具之后基线要从零开始,短期内数字会大幅波动,容易引起误判。
三是某些老构建环境锁定了低版本 JDK。Cobertura 最稳定的版本配合 JDK 7 字节码工作得很好,而这恰恰和很多遗留系统的现状吻合。这一点既是它的优势,也是它的天花板——后面会详细讲。
下面这张表是我自己做的对比,供你在选型时参考:
| 维度 | Cobertura 2.7 | JaCoCo 0.8.x |
|---|---|---|
| 插桩方式 | 离线修改字节码,生成插桩后的 class 目录 | Java Agent 在线插桩,运行期改写 |
| 最高支持字节码 | Java 7 较为稳妥,Java 8 的 lambda 容易炸 | 跟随 JDK 版本持续更新 |
| 报告路径 | target/site/cobertura | target/site/jacoco |
| 分支覆盖统计 | 支持,但对复杂条件表达式统计较粗 | 支持,对条件组合统计更细致 |
| 构建绑定 | 需要保证测试跑的是插桩后的 class | 通过 argLine 注入 agent,无需改 classpath |
| 多模块聚合 | 支持较弱,需额外处理 | 有成熟的 report-aggregate 目标 |
| 活跃度 | 基本停止更新 | 持续维护 |
所以结论很明确:新项目不要选 Cobertura,老项目在没有迁移窗口期时也不要贸然换。判断依据就一条——现有流水线是否依赖 Cobertura 的产物格式。如果不依赖,尽快迁;如果依赖,先把测试补起来,工具迁移放到单独的技术债迭代里做。
2. 环境搭建与依赖配置的关键细节
2.1 JDK 与构建工具的版本约束,这是第一道坎
Cobertura 最容易劝退人的地方就在版本兼容上。它的核心插桩引擎基于较老的字节码处理库,对 Java 8 引入的invokedynamic指令支持不完整。表现是什么呢?构建时可能不报错,但跑测试的时候抛出一堆莫名其妙的异常,比如Expecting a stackmap frame at branch target,或者直接Unsupported class file major version。
我实测下来比较稳的组合是这样:
- JDK 使用 8,但
maven-compiler-plugin的source和target都设成1.7 - 代码里不要写 lambda 表达式,匿名内部类可以(虽然也有小概率出问题),更稳妥的是普通方法
- 不要把
try-with-resources写成多资源形式,尽量简化 - Maven 3.6 及以上,Surefire 用 2.22 及以上版本
这里要解释一下为什么设成 1.7 而不是别的。Cobertura 2.1.1 内部使用的字节码库版本能完整识别 Java 7 的 class 文件格式(major version 51)。设成 1.7 之后,编译器不会生成 Java 8 专有的指令和属性,插桩就基本不会翻车。代价是你放弃了一些语法糖,但对一个以补测试为主要目标的遗留项目来说,这个代价完全可以接受——反正老代码里本来也没有 lambda。
配置片段如下:
<plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> <version>3.8.1</version> <configuration> <source>1.7</source> <target>1.7</target> <encoding>UTF-8</encoding> </configuration> </plugin>注意:如果你的项目必须使用 Java 8 语法(比如依赖某个新库的 API),那就别硬扛了,直接换 JaCoCo。用降级语法去迁就一个停止维护的工具,长期看是负收益。
2.2 Cobertura 插件的完整配置拆解
Cobertura 的 Maven 插件配置是整个链条里最容易配错的一环,因为它涉及三个阶段的行为:插桩(instrument)、跑测试(test)、生成报告(report/check)。如果只写一个cobertura:cobertura命令,插件会自动串起这三步;但如果配在pom.xml的 execution 里,就要写清楚绑定到哪个生命周期阶段。
我常用的配置是这样:
<plugin> <groupId>org.codehaus.mojo</groupId> <artifactId>cobertura-maven-plugin</artifactId> <version>2.7</version> <configuration> <formats> <format>html</format> <format>xml</format> </formats> <instrumentation> <ignores> <ignore>com/example/dto/**</ignore> <ignore>com/example/vo/**</ignore> </ignores> <excludes> <exclude>**/*Test.class</exclude> <exclude>**/*IT.class</exclude> </excludes> </instrumentation> <check> <haltOnFailure>true</haltOnFailure> <branchRate>60</branchRate> <lineRate>70</lineRate> <totalBranchRate>55</totalBranchRate> <totalLineRate>65</totalLineRate> <packageLineRate>60</packageLineRate> </check> </configuration> <executions> <execution> <id>cobertura-check</id> <phase>verify</phase> <goals> <goal>check</goal> </goals> </execution> </executions> </plugin>几个参数值得单独说清楚,因为它们直接影响你把构建卡死还是放过:
haltOnFailure设为true表示覆盖率不达标直接让构建失败。团队刚开始补测试的时候千万别开,先设成false观察一两周,摸清基线再打开,否则整个 CI 会立刻全线飘红,反而打击积极性。
branchRate和lineRate是增量维度的阈值,Cobertura 的语义稍微有点绕,它实际校验的是本次构建中的类级别指标;totalBranchRate和totalLineRate是项目整体的阈值。实践中我一般把整体阈值设低 10 到 15 个百分点,因为总有那么几个类短时间补不上来,卡太死会把整个构建堵住。
ignores比excludes重要得多。excludes是干脆不参与统计的类(比如测试类本身),ignores是参与编译但不做插桩的类。DTO、VO、枚举、异常类这些纯数据载体如果全算进覆盖率,分母会被迅速撑大,报告数字看起来极其难看,但补起来毫无价值。我一般会把dto、vo、entity、exception、config这几个包直接 ignore 掉。
2.3 插桩原理与"跑错 class"这个隐藏陷阱
Cobertura 的工作方式是在编译后的 class 文件里,对每一个可执行的代码块插入一行计数代码,大致等价于在每行前面加上__CLR.myClass.inc(3);这样的调用,其中 3 是这一行对应的探针编号。测试跑完之后,JVM 退出时把这些计数写进cobertura.ser文件,报告阶段读取这个文件,再对照源码生成 HTML。
关键在于:插桩后的 class 和原始的 class 是两个不同的东西。插桩产物默认输出到target/generated-classes/cobertura目录下。如果你的测试跑的是target/classes里的原始 class,那计数器永远是零,报告会显示"覆盖率 0%"或者干脆报"No class files found"。
这就是为什么官方推荐用mvn cobertura:cobertura一条命令,它内部会临时把插桩目录加到测试的 classpath 前面。如果你在 pom 里自己配了 execution,或者 CI 脚本里分开调用了mvn test和mvn cobertura:report,那就极容易踩这个坑。
提示:单独执行
mvn cobertura:report之前,先确认target/cobertura/cobertura.ser文件存在且大小不为 0。文件不存在说明测试根本没在插桩状态下跑过,报告必然是空的。
还有一个更隐蔽的问题:Surefire 的 fork 设置。Cobertura 依赖 JVM 正常退出来刷新计数文件,如果 Surefire 被配置成不 fork(在同一个 JVM 里跑测试),或者forkCount设成 0,计数文件可能写不出来。我的做法是保持forkCount为 1,必要时加reuseForks=true减少 JVM 启动开销,但绝不用 0。
3. HSQLDB 加 JPA 的测试持久层怎么搭
3.1 内存库的三种运行模式与选型
HSQLDB 提供三种主要的运行形态,选错了会在测试性能和隔离性上吃大亏。
第一种是内存模式,URL 形如jdbc:hsqldb:mem:testdb。数据库完全活在 JVM 的堆内存里,进程结束即消失,不需要任何磁盘 IO,速度最快,是单元测试的默认选择。
第二种是文件模式,URL 形如jdbc:hsqldb:file:/path/to/db。数据落在磁盘上,可以在多个进程间共享,适合做集成测试时观察数据状态,但每次读写都要走磁盘,测试数量一多就明显拖慢构建。
第三种是服务器模式,URL 形如jdbc:hsqldb:hsql://localhost:9001/testdb。需要单独起一个数据库进程,多个测试 JVM 可以共享。适合并行执行的集成测试,但引入了一个外部依赖,本地开发环境要多一步启动操作,我不太推荐在单测里用。
我的建议很直接:单元测试和 DAO 层测试一律用内存模式,只有需要在多个 JVM 之间共享数据状态的特殊场景才考虑服务器模式。判断标准是测试要不要并发访问同一份数据,绝大多数项目用不上。
内存模式还有一个细节要注意:数据库的存活周期跟"最后一个连接何时关闭"有关。默认情况下,所有连接都关掉之后数据库就被销毁了。如果你在测试类里每个方法都新建一次EntityManagerFactory然后关闭,下一个测试方法再建,数据库会被重新创建,表结构也跟着重建,看起来好像没问题,但如果你在静态初始化块里预置了基础数据,数据就会丢。
解决办法是在 JDBC URL 后面加DB_CLOSE_DELAY=-1,让数据库在最后一个连接关闭后仍然保留,直到 JVM 退出:
jdbc:hsqldb:mem:testdb;DB_CLOSE_DELAY=-1;sql.syntax_mys=truesql.syntax_mys=true这个参数也很有用,它让 HSQLDB 兼容一部分 MySQL 语法,比如反引号包裹标识符、LIMIT语句的某些写法。如果被测代码里有原生 SQL(@Query注解或 XML 里的 SQL),这个开关能省掉不少改写工作。
3.2 persistence.xml 的最小可用配置
JPA 的标准配置走META-INF/persistence.xml。测试环境我习惯单独放一份在src/test/resources/META-INF/下,和生产的配置彻底隔离,避免误连生产库这种低级但致命的错误。
<?xml version="1.0" encoding="UTF-8"?> <persistence xmlns="http://java.sun.com/xml/ns/persistence" version="2.1"> <persistence-unit name="testPU" transaction-type="RESOURCE_LOCAL"> <provider>org.hibernate.jpa.HibernatePersistenceProvider</provider> <class>com.example.entity.Order</class> <class>com.example.entity.OrderItem</class> <properties> <property name="javax.persistence.jdbc.driver" value="org.hsqldb.jdbc.JDBCDriver"/> <property name="javax.persistence.jdbc.url" value="jdbc:hsqldb:mem:testdb;DB_CLOSE_DELAY=-1;sql.syntax_mys=true"/> <property name="javax.persistence.jdbc.user" value="SA"/> <property name="javax.persistence.jdbc.password" value=""/> <property name="hibernate.dialect" value="org.hibernate.dialect.HSQLDialect"/> <property name="hibernate.hbm2ddl.auto" value="create-drop"/> <property name="hibernate.show_sql" value="false"/> <property name="hibernate.format_sql" value="false"/> </properties> </persistence-unit> </persistence>逐个说几个容易被忽略的点。
transaction-type选RESOURCE_LOCAL还是JTA,取决于测试里用什么管理事务。用 Spring 的话两者都行,纯 JPA 测试用RESOURCE_LOCAL更简单,因为可以直接调EntityTransaction.begin()和commit(),不需要额外的事务管理器。
hibernate.hbm2ddl.auto设成create-drop是最适合内存库的。启动时根据实体注解生成 DDL,关闭时删表。这样实体的任何改动都会自动反映到表结构上,不需要手工维护 SQL 脚本。但它有个副作用:如果实体之间的映射写错了,错误会延迟到测试运行时才暴露,而且报错信息往往很绕。另外,create-drop生成的 DDL 不一定和生产的 DDL 完全一致(索引、约束、字段长度都可能不同),所以它保证的是"结构可跑",不保证"结构等价"。
hibernate.dialect必须显式指定为HSQLDialect。虽然新版 Hibernate 有自动方言探测,但在测试场景里显式指定能避免一个很隐蔽的问题:自动探测有时会拿到错误的子版本方言,导致生成的分页 SQL 语法不被 HSQLDB 识别。
如果你的项目用的是 Spring Boot,更推荐用application-test.yml加@DataJpaTest的方式,配置项能少写一大半:
spring: datasource: url: jdbc:hsqldb:mem:testdb;DB_CLOSE_DELAY=-1;sql.syntax_mys=true driver-class-name: org.hsqldb.jdbc.JDBCDriver username: SA password: jpa: hibernate: ddl-auto: create-drop database-platform: org.hibernate.dialect.HSQLDialect show-sql: false3.3 建表策略:自动建表与脚本建表怎么选
上面说了hbm2ddl的好处,但它不是唯一方案。另一种做法是在src/test/resources/schema.sql里手写建表语句,配合hibernate.hbm2ddl.auto=none。
两种方式各有适用场景,我列个表对照一下:
| 场景 | 推荐策略 | 理由 |
|---|---|---|
| 实体映射稳定,只想快速跑通数据访问 | create-drop | 零维护,改字段自动生效 |
| 需要验证真实 DDL 的兼容性(比如生产用 Oracle) | 手工 schema.sql | 能提前发现方言差异 |
| 表结构有复杂约束、存储过程、触发器 | 手工 schema.sql | hbm2ddl生成不出来 |
| 测试初始化数据量大 | create-drop 加 data.sql | 结构自动建,数据脚本单独管 |
| 多模块共享同一套实体 | 手工脚本 | 避免各模块生成的结构不一致 |
我自己的习惯是:项目初期用create-drop快速铺开测试,等实体映射稳定下来、测试数量超过一百个之后,逐步迁移到手工脚本。原因是自动建表在大项目里会拖慢启动——Hibernate 每次都要解析所有实体、计算 DDL、执行,实体一多,光启动就要好几秒。手工脚本可以一次执行完,快得多。
4. 测试用例编写与覆盖率实操
4.1 DAO 层测试的标准骨架
写持久层测试最大的误区是每个测试方法都新建一次EntityManagerFactory。EMF 的创建成本极高,包含扫描实体、解析映射、构建元模型、建连接池,单次在几百毫秒到一两秒之间。一百个测试方法每个都建一次,光是 EMF 就要耗掉一两分钟,构建慢到没人愿意跑。
正确做法是用静态字段加@BeforeClass建一次、@AfterClass关一次,每个测试方法里只开事务:
public class OrderDaoTest { private static EntityManagerFactory emf; private EntityManager em; private EntityTransaction tx; @BeforeClass public static void initFactory() { emf = Persistence.createEntityManagerFactory("testPU"); } @AfterClass public static void closeFactory() { if (emf != null && emf.isOpen()) { emf.close(); } } @Before public void beginTx() { em = emf.createEntityManager(); tx = em.getTransaction(); tx.begin(); } @After public void rollbackTx() { if (tx != null && tx.isActive()) { tx.rollback(); } if (em != null && em.isOpen()) { em.close(); } } @Test public void shouldPersistAndFindOrder() { Order order = new Order(); order.setOrderNo("NO-2024-0001"); order.setAmount(new BigDecimal("199.00")); order.setStatus(OrderStatus.CREATED); em.persist(order); em.flush(); em.clear(); Order found = em.find(Order.class, order.getId()); assertNotNull(found); assertEquals("NO-2024-0001", found.getOrderNo()); assertEquals(0, new BigDecimal("199.00").compareTo(found.getAmount())); } }这段骨架里有几个设计意图值得展开。
@After里回滚事务而不是提交,是为了保证测试之间的数据隔离。每个测试方法都在自己的事务里跑,跑完回滚,数据库状态回到干净状态。这样测试方法之间没有执行顺序依赖,随便怎么排列组合结果都一样。如果改成提交,就得在@Before里手工清表,既要维护清表顺序(有外键约束时顺序错了会报错),又容易漏表,长期看是负担。
em.flush()和em.clear()这两行是老手才会写的。flush把一级缓存里的变更推到数据库,clear清空一级缓存。没有这两行,em.find()可能直接从缓存里返回刚才那个对象,SQL 一次都没发出去,测试看起来通过了,实际上根本没验证持久化逻辑。这是持久层测试里最典型的"假绿"。
assertEquals比较BigDecimal要用compareTo,因为equals会连精度(scale)一起比,199.00和199.0在equals下不相等。这个坑我被绊过不止一次。
4.2 覆盖率数字上不去,问题往往出在分支上
很多人看到行覆盖率 80% 就觉得差不多了,一看分支覆盖率只有 40%,立刻懵了。这两个指标的统计口径完全不同,理解清楚才能有针对性地补。
行覆盖率算的是"有多少行代码被执行过"。一个方法只要有测试调用它,方法体里的顺序语句基本都会被算作已执行。
分支覆盖率算的是"有多少个判断路径被走过"。一个if语句有两个分支,只测了条件为真的情况,分支覆盖就是 50%。一个switch有四个case加一个default,测了三个,就是 60%。
举个典型例子。下面这个方法,行覆盖很容易做到 100%,因为总共就三行有效代码:
public BigDecimal calculateDiscount(Order order) { if (order.getAmount().compareTo(new BigDecimal("500")) >= 0) { return order.getAmount().multiply(new BigDecimal("0.9")); } return order.getAmount(); }但只要你的测试只传了一个金额 600 的订单,分支覆盖就只有 50%。要补到 100%,必须再加一个金额 300 的用例,让方法走到return order.getAmount()那一行。
把分支覆盖补上去有几个实操技巧,我按性价比排序:
优先处理if-else和三元表达式。这类分支结构最简单,一个反例就能补上,收益最高。写测试的时候养成习惯:每写一个if,就在测试类里加两个方法,一个条件成立、一个条件不成立。
关注边界值。上面那个例子里,>= 500这个条件,只测 600 和 300 是不够的,499 和 500 这两个边界值才是最容易被写错的地方。边界值测试既是补覆盖率,也是真正在找 bug。
switch的default分支别忘。很多代码的default分支是抛异常,测试里要用@Test(expected = XxxException.class)或者assertThrows明确验证它。Cobertura 会把没走到的default算作未覆盖分支,拉低整体数字。
短路运算符是隐形的分支。a && b在字节码层面会生成两个跳转,Cobertura 会把它算成多个分支。测试时要注意a为 false 时b不执行这条路径,否则这部分分支永远是红的。
异常路径也算分支。try-catch里的catch块如果不写测试触发它,就是未覆盖。我一般会用 Mock 或者构造非法输入来触发,哪怕只是断言异常类型和消息。
4.3 生成报告并正确解读
跑一次完整命令:
mvn clean cobertura:cobertura这条命令会依次执行编译、插桩、跑测试、生成报告。跑完之后打开target/site/cobertura/index.html,能看到按包、按类分层的覆盖率表格。
报告里有几列需要看懂:
| 列名 | 含义 | 怎么看 |
|---|---|---|
| Classes | 类的数量 | 关注百分比,不关注绝对数 |
| Lines | 行覆盖率 | 主指标,反映整体测试广度 |
| Branches | 分支覆盖率 | 主指标,反映测试深度 |
| Complexity | 圈复杂度 | 高复杂度加低覆盖 = 高风险区 |
| Errors | 插桩或执行出错 | 必须为 0,非 0 说明工具链有问题 |
我的习惯是每次打开报告先看Complexity那一列,按降序排。圈复杂度高、覆盖率低的类,就是最该补测试的地方。反过来,一个类复杂度只有 2、覆盖率 0%,补上去对质量提升有限,只是让数字好看,优先级可以放后面。
点进具体的类,能看到源码逐行标色:绿色是已执行,红色是未执行,黄色是部分分支未执行。黄色行最值得细看,因为它往往意味着某个边界条件被漏掉了。
XML 格式的报告target/site/cobertura/cobertura.xml是给流水线解析用的,里面每个<class>节点都带line-rate和branch-rate属性。如果要做增量覆盖率门禁(只卡本次改动涉及的类),就需要解析这个文件,拿类名和git diff的结果做交集。这块逻辑不难,但要注意 XML 里的类名是斜杠分隔的全限定名,比如com/example/service/OrderService,得先做格式转换再比对。
5. 常见问题排查实录
5.1 覆盖率恒为 0 或者报告根本不生成
这是最高频的问题,占我遇到的所有 Cobertura 问题的一半以上。排查顺序我总结成了固定的四步:
第一步,确认 ser 文件是否存在。执行ls -lh target/cobertura/cobertura.ser,如果文件不存在,说明测试根本没在插桩状态下跑,问题出在构建流程上,而不是报告阶段。
第二步,确认测试跑的是插桩后的 class。检查 surefire 的 classpath 配置,看target/generated-classes/cobertura是否排在target/classes前面。如果用了mvn test单独跑,必然跑的是原始 class,这个是最常见的错误。
第三步,确认 Surefire 是否 fork。forkCount为 0 时,测试和 Maven 在同一 JVM 里跑,计数文件可能来不及刷盘。改成 1 再试。
第四步,确认类是否被 ignore 了。检查instrumentation下的ignores配置,路径写的是相对于target/classes的路径,不带target/classes前缀,也不带.class后缀。写成com/example/dto/**是对的,写成target/classes/com/example/dto/**就是错的,后者不会报错,只是悄悄不生效。
还有一种情况是报告生成了,但所有指标都是 0%。这通常是 ser 文件存在但内容是空的——说明插桩执行了,但测试一个都没跑到插桩类。检查一下测试类的包路径是否被excludes误伤。
5.2 HSQLDB 的方言与标识符坑
user lacks privilege or object not found这个异常在 HSQLDB 上出现的频率极高,原因几乎都是标识符大小写的问题。
HSQLDB 默认把未加引号的标识符全部转成大写。如果你的实体里写了@Table(name = "orders"),Hibernate 生成的 DDL 里表名会变成ORDERS。但如果你在某处用了原生 SQL 写select * from orders,HSQLDB 会把它解释成ORDERS,通常能对上。麻烦的是反过来——如果实体名带了引号或者用了@Column(name="\"orderNo\"")这种写法,就会变成大小写敏感,导致查询失败。
我的处理原则是:要么全部交给 Hibernate 生成,测试里不写原生表名;要么在 URL 里加DATABASE_TO_LOWER=TRUE,让 HSQLDB 统一转小写,这样和大多数开发者的直觉一致。
jdbc:hsqldb:mem:testdb;DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE另一个坑是LIMIT和分页。HSQLDB 2.x 支持标准 SQL 的OFFSET ... FETCH,也支持LIMIT,但某些老版本对LIMIT ?, ?这种两个参数的写法不认。如果你的分页是通过 JPA 的setFirstResult/setMaxResults实现的,Hibernate 会按方言生成正确的 SQL,一般没问题;但如果项目里有手写的原生分页 SQL,就要重点测这一块。
5.3 事务与连接泄漏引起的连锁失败
测试跑到某个类突然开始全部失败,报Connection is closed或者Transaction is not active,通常不是测试写错了,而是前面某个测试把连接池耗光了。
原因一般是某个测试方法里EntityManager没关,或者抛异常之后@After没执行到关闭逻辑。JUnit 的@After在测试方法抛异常时仍会执行,但如果是@Before阶段就挂了,@After不会被调用,EMF 里就会残留一个打开的连接。
更隐蔽的是@Transactional测试里的传播行为。Spring 的测试事务默认会在方法结束后回滚,但如果你在测试里调用的 Service 方法标注了REQUIRES_NEW,它会开一个新的独立事务,这个事务不受测试事务控制,提交之后数据就留在库里了。下一个测试如果依赖干净的初始状态,就会失败。
排查这类问题的办法很简单:在@After里加日志,打印当前连接池的活动连接数;跑单个测试类,看数字是否回到基线。如果没回去,就说明有泄漏。
5.4 常见问题速查表
把上面这些和我踩过的其他坑汇总成一张表,遇到问题时可以先扫一眼:
| 现象 | 最可能原因 | 处理方式 |
|---|---|---|
| 插桩时报 stackmap 相关异常 | Java 8 的 lambda 或新字节码指令 | 编译级别降到 1.7,去掉 lambda |
| 覆盖率全 0 | 测试跑的是原始 class | 用mvn cobertura:cobertura一条命令 |
| 报告不生成 | ser 文件缺失或为空 | 检查 forkCount,确认测试正常结束 |
| 表不存在的异常 | 标识符大小写不一致 | URL 加DATABASE_TO_LOWER=TRUE |
| 数据库在测试间被清空 | 内存库随连接关闭而销毁 | URL 加DB_CLOSE_DELAY=-1 |
| 测试方法间互相污染 | 事务提交后未回滚 | @After里统一 rollback |
em.find查到旧对象 | 一级缓存命中 | flush后clear再查 |
| BigDecimal 断言失败 | equals比较了精度 | 改用compareTo |
| 分页查询结果不对 | 方言生成 SQL 与内存库不匹配 | 显式指定HSQLDialect |
| 构建突然变慢 | 每个测试类都新建 EMF | 改成@BeforeClass静态复用 |
| 多模块覆盖率汇总不准 | Cobertura 聚合能力弱 | 各模块单独出报告再手工合并 |
| CI 上全绿但本地红 | 本地跑的是旧 ser 文件 | 执行前先mvn clean |
6. 补充几个只有踩过才知道的细节
上一节说的都是"出了问题怎么修",这一节讲几个"不出问题但能省事"的经验,属于没人写在文档里的东西。
关于cobertura.ser的清理。这个文件默认是追加写入的,如果不做clean,上一次构建的计数会残留下来,导致覆盖率虚高。我见过最夸张的一次,某同事本地 ser 文件积累了半个月的数据,报告显示覆盖率 92%,提交之后 CI 上一跑只有 51%。所以 CI 脚本里mvn clean不能省,本地调试也养成先 clean 的习惯。
关于 EMF 关闭顺序。emf.close()必须在所有 EntityManager 都关闭之后调用,否则 HSQLDB 的连接会因为被强制关闭而报错,虽然不影响测试结果,但日志里一堆异常信息会干扰判断。另外close()之后 EMF 就不可用了,如果用了@BeforeClass复用,务必确保@AfterClass只调一次。
关于测试数据的准备方式。我早期喜欢在@Before里用em.persist一条条插数据,后来发现这种方式在测试方法多起来之后维护成本很高——改一个字段要动一堆测试。现在更推荐两种做法:一是用@Before里统一的 builder 方法构造测试对象,字段有默认值,需要改哪个就覆盖哪个;二是把公共的初始化数据抽到一个DataFixture工具类里,按业务语义命名方法,比如createPaidOrder()、createCancelledOrder(),读测试代码的时候一眼就知道数据是什么状态。
关于 Ignore 配置的维护。ignores里的包路径会随着项目结构调整而失效,失效之后不报错,只是那些类开始参与统计,覆盖率数字会突然掉一大截。我建议把这份配置单独抽成一个 profile,在每个季度做技术债梳理的时候过一遍,确认路径还对得上。或者更省事的做法是反过来,用includes只统计指定的核心包,这样即使包结构调整,未列入的包也不会污染分母。
关于和前后端联调的边界。这套工具链只解决持久层的测试覆盖,它验证不了 SQL 的性能、验证不了复杂查询的执行计划、更验证不了跨服务调用。所以我在团队里推这套东西的时候会明确说清楚:它是回归防线,不是质量保证的全部。核心链路的集成测试该用真实数据库还是得用,内存库只负责快速反馈这一层。
关于迁移时机的判断。如果哪天发现为了迁就 Cobertura 而不得不放弃某个语言特性,或者某个新引入的依赖因为字节码版本问题跑不起来,那基本就意味着该迁了。我自己的判断标准是:当工具开始限制业务代码的写法时,就该换工具了。这个信号比任何版本兼容性表格都更直接。