前阵子帮人看一个跑了快十年的老服务,测试类一共七个,断言清一色assertNotNull,覆盖率报告还停在某个早就没人维护的目录里。我想给这套东西加上覆盖率卡控,结果发现工程里既没有 Spring,也没有内存数据库,持久层测试全靠一个共享的测试库,跑一次要四十多分钟,跑完还经常互相污染。后来我把这套链子重新捋了一遍:JUnit 管测试骨架,HSQLDB 内存库把持久层测试从共享库上摘下来,JPA 负责实体映射,Cobertura 负责在测试跑完之后告诉我到底覆盖了多少行、多少分支。这篇文章就聊这套组合——Cobertura、JUnit、HSQLDB、JPA 如何各司其职,把测试覆盖率这件事真正落地。
如果你手上有一个中等规模的 Java 持久层工程,测试跑得慢、数据库状态不可控、覆盖率数字没人看,或者你只是想知道 Cobertura 这个老牌工具在今天还能不能撑住场面,下面这些内容应该能直接用。我会把配置、参数、坑和排查链路都摊开讲,代码可以直接抄。
1. 这套四人组各自解决什么问题
先把职责切清楚,不然后面配置起来会互相打架。这四个东西不是平级的:JUnit 是执行框架,HSQLDB 是测试替身,JPA 是被测对象的一部分,Cobertura 是观测手段。搞混了顺序,就会出现"覆盖率统计的是测试代码"这种离谱情况。
1.1 覆盖率数字背后的三段式流程
Cobertura 的工作方式跟很多人想象的不一样。它不是运行时挂个探针,而是分三步走:编译后插桩、运行时记录、结束后生成报告。
第一步,Cobertura 读取target/classes下编译好的.class文件,用 ASM 在每个方法体里插入计数器语句——每执行到一行,就把对应的布尔数组位置置位。插桩后的类会被写到target/generated-classes/cobertura目录,同时类上会多出几个静态字段和一个私有初始化方法,还会实现一个标记接口,这是判断"这个类插过桩没有"最直接的特征。
第二步,测试跑起来的时候,被插桩的类在执行时会往内存里累积数据,JVM 退出前把这些数据序列化到当前工作目录下的cobertura.ser文件。这个文件名很关键,后面排查问题时经常要用到它。
第三步,cobertura-report拿着cobertura.ser和源码目录做匹配,按源码行生成 HTML 或 XML 报告。这也是为什么 Cobertura 必须知道源码在哪——它记录的是行号,报告得落到具体的源码行上才能看。
理解这三段之后你就会明白一件事:如果插桩没生效,cobertura.ser里就是空的,报告会显示 0%,而不是报错。很多人第一次遇到"报告全是 0"就慌了,其实多半是 classpath 里用的是target/classes里没插桩的原版类,而不是插桩后的版本。这个顺序在 Maven 里由插件绑定的生命周期阶段决定,cobertura:instrument必须在test之前,cobertura:report在test之后,绑错了就白干。
1.2 四个组件各自的职责边界
我把它们的分工整理成一张表,配配置的时候对着看不容易乱:
| 组件 | 在测试链路中的位置 | 负责什么 | 常见误用 |
|---|---|---|---|
| JUnit | 执行层 | 组织测试方法、生命周期回调、断言 | 在@Before里做重活,导致失败定位困难 |
| HSQLDB | 数据层 | 提供真实 SQL 引擎和内存表结构 | 当成"假数据库",于是只测 CRUD 不测约束 |
| JPA | 被测对象 | 实体映射、JPQL 生成、事务与一级缓存 | 被 mock 掉,导致根本没测到映射和查询 |
| Cobertura | 观测层 | 统计行覆盖与分支覆盖 | 用它去统计实体类,数字被稀释得没意义 |
这里最值得掰扯的是为什么不 mock JPA。持久层的价值恰恰在于"对象和 SQL 之间的翻译是否正确":字段类型映射对了没有、@OneToMany的抓取策略是不是 N+1、JPQL 拼出来的 SQL 在目标方言下能不能跑、唯一约束和级联删除是不是按预期生效。这些全部依赖真实的 SQL 执行引擎。mock 掉EntityManager之后,你验证的是"我调用了persist方法",而不是"这行数据真的进了表",测试看着绿,上线照样炸。
HSQLDB 在这里的角色就是"真库的替身":它有真的 SQL 解析器、真的约束检查、真的事务语义,只不过数据放在内存里,进程一退就没了。这个特性让它比共享测试库干净,比 mock 真实,是持久层测试里性价比最高的一个选择。
2. HSQLDB 内存库接上 JPA 的几条硬规则
内存数据库听起来简单,配起来坑不少。我见过最常见的一种故障:测试类单独跑没问题,整个模块一起跑就报"表不存在"。这不是 JPA 的问题,是 HSQLDB 的库生命周期没管住。
2.1 jdbc:hsqldb:mem 的 URL 参数不能随手写
最朴素的写法是jdbc:hsqldb:mem:testdb,这样确实能起来,但它有个默认行为:当最后一个连接关闭时,整个内存库会被销毁,里面所有表和数据一起没了。如果你的工程用了连接池(Druid、HikariCP 之类),池子在空闲时可能把连接全部回收,下一次拿连接时库已经空了,于是报user lacks privilege or table not found或者invalid schema name。
解决办法是在 URL 后面加参数:
jdbc:hsqldb:mem:testdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE;sql.syntax_mys=trueDB_CLOSE_DELAY=-1的意思是"库在最后一个连接关闭后继续保留,直到 JVM 退出",这一条基本上能解决 90% 的"表莫名其妙消失"问题。DB_CLOSE_ON_EXIT=FALSE是让 HSQLDB 不要注册 JVM 关闭钩子去 dump 数据,测试场景下没必要。sql.syntax_mys=true打开 MySQL 兼容语法开关,如果你的生产库是 MySQL,很多LIMIT、反引号、AUTO_INCREMENT之类的写法在 HSQLDB 原生模式下会报语法错误,加上这个开关能少改不少 SQL。
还有一个容易忽略的点:内存库的 URL 里数据库名就是隔离单位。mem:testdb_a和mem:testdb_b是两个完全独立的库。这个特性后面用来做测试隔离非常好用。
2.2 persistence.xml 里的三行配置决定成败
JPA 侧的核心配置就三件事:选哪家实现、用哪个方言、建表策略怎么定。一份能直接用的persistence.xml大概长这样:
<persistence xmlns="http://java.sun.com/xml/ns/persistence" version="2.0"> <persistence-unit name="testPU" transaction-type="RESOURCE_LOCAL"> <provider>org.hibernate.jpa.HibernatePersistenceProvider</provider> <class>com.example.domain.Order</class> <class>com.example.domain.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="true"/> </properties> </persistence-unit> </persistence>hibernate.hbm2ddl.auto这一项值得单独说。测试环境用create-drop,好处是每次EntityManagerFactory创建时自动建表,销毁时自动删表,完全不用管 DDL 脚本。但create-drop有个隐性代价:建表依据是实体上的注解,如果你的生产库 DDL 是另一套(比如 DBA 手工维护的),那么测试通过不代表生产能跑。这种情况下我更推荐validate,配合一份从生产库导出的 DDL 脚本在@BeforeClass里执行,先把表结构对齐,再让 Hibernate 校验。代价是配置麻烦,收益是能提前发现"实体和真实表结构不一致"这类上线才会暴露的问题。
hibernate.dialect我建议显式写死,不要靠自动探测。自动探测在某些类加载顺序下会走到默认方言,导致生成的 SQL 在 HSQLDB 上跑不通,而报错信息又很绕。
2.3 库的粒度:每个测试类一个还是一次一个
隔离粒度直接决定测试稳定性和执行速度,三种常见方案我列在下面:
| 粒度 | 实现方式 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 每 JVM 一个库 | 固定 URLmem:testdb | 启动最快,只建一次表 | 测试间互相污染,顺序敏感 | 很少用,除非测试全是只读 |
| 每测试类一个库 | URL 里拼类名mem:db_${类名} | 隔离好,建表次数可接受 | 表结构要重复创建 | 大多数持久层测试 |
| 每测试方法一个库 | 方法级建EntityManagerFactory | 完全隔离 | 极慢,EMF 创建本身就重 | 只对状态强相关的用例 |
我自己的默认选择是每个测试类一个库,每个测试方法一个事务。类级隔离靠 URL 里拼唯一名实现,方法级隔离靠事务回滚实现。具体做法是在@BeforeClass里用数据库名 + 时间戳拼出唯一 URL,避免并行跑测试时两个类撞到同一个库名;在@Before里begin(),@After里rollback(),这样每个测试方法结束后数据库自动回到初始状态,不用手工清理。
注意:如果测试里需要验证"事务提交后的行为"(比如触发器、
@Version乐观锁递增),回滚方案就不适用了,得改成在方法末尾显式清理数据,或者接受一定程度的污染并保证测试无顺序依赖。
3. JUnit 侧:事务边界和数据准备
Cobertura 的覆盖率再好看,前提是测试本身立得住。持久层测试最容易出问题的地方不是断言写错,而是事务和缓存把错误藏起来了。
3.1 不依赖容器的 EntityManager 管理模板
没有 Spring 的工程里,EntityManager得自己管。一份我用了很多次、几乎不用改的骨架:
public abstract class PersistenceTestSupport { protected static EntityManagerFactory emf; protected EntityManager em; protected EntityTransaction tx; @BeforeClass public static void bootEmf() { String dbName = "testdb_" + System.nanoTime(); Map<String, String> overrides = new HashMap<>(); overrides.put("javax.persistence.jdbc.url", "jdbc:hsqldb:mem:" + dbName + ";DB_CLOSE_DELAY=-1"); emf = Persistence.createEntityManagerFactory("testPU", overrides); } @Before public void openTx() { em = emf.createEntityManager(); tx = em.getTransaction(); tx.begin(); } @After public void rollbackTx() { if (tx.isActive()) { tx.rollback(); } em.close(); } @AfterClass public static void shutdown() { if (emf != null && emf.isOpen()) { emf.close(); } } }三个细节值得强调。第一,createEntityManagerFactory的第二个参数是属性覆盖,用它可以动态改 URL,比维护多份persistence.xml省事得多。第二,每个测试方法新建一个EntityManager而不是复用静态实例,因为EntityManager有一级缓存,复用会导致上一个测试里的实体还在缓存里,find直接返回旧对象,测试结果失真。第三,@AfterClass里显式close()一定要写,否则 HSQLDB 的库和 Hibernate 的后台线程会一直挂着,跑完整个测试套件后 JVM 不退出,CI 上表现为"构建卡住"。
3.2 造数据的三条路:SQL 脚本、DBUnit、构造器
测试数据的准备方式直接影响可维护性,我试过三种,各有适用面:
| 方式 | 写法 | 优点 | 缺点 |
|---|---|---|---|
| SQL 脚本 | 在@Before里em.createNativeQuery("insert ...").executeUpdate() | 直观、可控、不引入额外依赖 | 数据多了脚本很长 |
| DBUnit | XML/CSV 数据集 +DatabaseOperation.CLEAN_INSERT | 结构化管理,适合大批量 | 与 JPA 的缓存交互有坑,版本兼容麻烦 |
| 构造器/工厂方法 | 用 Java 代码new实体再persist | 类型安全、重构友好 | 依赖实体的 setter 和必填字段 |
我现在的默认选择是小规模数据用构造器,中等规模用 SQL 脚本,同时需要"全表清空后重灌"的场景才考虑 DBUnit。构造器的优势是 IDE 能重构、编译期就能发现字段改名;SQL 脚本的优势是不经过 JPA 的缓存和生命周期回调,可以精确构造"脏数据"来测边界情况,比如插入一条status列值不在枚举范围内的记录,看应用层怎么处理。
DBUnit 我持保留态度:它的CLEAN_INSERT会按外键顺序删表,如果你的 schema 里有自引用外键或者循环依赖,删表顺序会报错,得手工配置DatabaseConfig关掉外键检查。这个配置在不同版本里 API 还不一样,维护成本不低。
3.3 断言落在数据库状态上而不是对象引用上
这一条是我踩过的坑里印象最深的。早期写测试的时候我喜欢这样断言:
Order saved = em.find(Order.class, id); assertEquals("PAID", saved.getStatus());问题在于,如果saved就是刚才persist的那个对象实例,find会直接命中一级缓存返回同一个引用,assertEquals永远为真——哪怕数据库里什么都没写进去。这种测试给你的是虚假的安全感。
正确的做法是切断缓存再查:
em.flush(); em.clear(); // 清空一级缓存,强制下一次查询走数据库 Order reloaded = em.find(Order.class, id); assertEquals(OrderStatus.PAID, reloaded.getStatus()); assertEquals(2, reloaded.getItems().size());flush()把内存里的变更推到数据库,clear()清掉缓存,再查就是从表里读了。如果这条链路能跑通,说明映射、SQL、事务全对。
对于集合关联,还要额外注意懒加载。reloaded.getItems().size()在事务内可以正常触发懒加载,但如果事务已经结束,会抛LazyInitializationException。这本身也是一种有价值的验证——你可以在测试里主动断言"这个方法在事务外被调用会抛异常",把懒加载的边界固定下来,避免上线后在某处踩到。
4. Cobertura 在 Maven 里的四段接线
配置 Cobertura 的关键是理解它在 Maven 生命周期里插在哪几个点:instrument在compile之后、test之前;test阶段跑 JUnit;report在test之后;check在verify阶段做阈值校验。四段顺序错一段,整条链路就断。
4.1 插件坐标与 JDK 版本的红线
先放一份可以直接用的配置:
<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/domain/*</ignore> </ignores> <excludes> <exclude>com/example/domain/**/*.class</exclude> <exclude>com/example/generated/**/*.class</exclude> </excludes> </instrumentation> <check> <haltOnFailure>true</haltOnFailure> <totalLineRate>0.60</totalLineRate> <totalBranchRate>0.50</totalBranchRate> </check> </configuration> <executions> <execution> <id>cobertura-check</id> <phase>verify</phase> <goals> <goal>check</goal> </goals> </execution> </executions> </plugin>这里必须说一条红线:Cobertura 2.1.1 这一代产品对 JDK 版本极其敏感。它内部依赖的字节码库版本偏老,在 JDK 8 上跑基本没问题,但到了 JDK 9 及以上,模块化系统的类文件格式变化会让插桩直接抛错,报的通常是Unsupported class file major version或者Cannot process class。如果你的工程要升 JDK,第一件事就是评估 Cobertura 能不能活下来——大概率活不下来。
我的建议是:只在 JDK 8 环境下使用 Cobertura,构建脚本里显式锁定编译器版本,别让开发机上的高版本 JDK 混进来。如果确实需要在高版本 JDK 上做覆盖率统计,就不要硬扛,直接换 JaCoCo,后面第 6 节我会给对比。
4.2 exclude 与 ignore:让数字有参考价值
Cobertura 的配置里有两个长得很像、作用完全不同的东西,很多人第一次配会搞混:
ignores:告诉 Cobertura不要统计这个类里的某些代码,但类本身还是会被插桩,方法还会执行。主要用来排除自动生成的equals/hashCode、日志初始化、常量定义这类"写了也不测"的代码。excludes:告诉 Cobertura根本不用管这些 class 文件,既不插桩也不统计。用于排除实体类、DTO、生成的代码、配置类。
用错了会产生很微妙的结果。比如把实体类写进ignores而不是excludes,类还是被插桩了,但里面的 getter/setter 不计入统计,于是你会看到"类被覆盖了,但行数统计很奇怪"这种现象。
哪些该排除,我的经验是这样的:
| 类型 | 处理方式 | 理由 |
|---|---|---|
| JPA 实体类 | excludes | 主要是 getter/setter,测试它们没有意义,还会大幅稀释覆盖率 |
| 生成的代码(查询对象、Mapper) | excludes | 不是人写的,不归测试负责 |
| 纯常量类、枚举 | excludes | 无可执行逻辑 |
| 日志、监控切面 | ignores | 存在但不需要断言 |
| 业务 Service | 都不排除 | 核心逻辑,必须覆盖 |
| 异常类 | excludes | 通常只有构造器 |
提示:实体类全排除是有代价的。如果实体上有
@PrePersist回调、计算字段、自定义的equals,这些逻辑就测不到了。我的折中做法是:实体类整体排除,但把带逻辑的回调抽到独立的监听器类里,监听器类纳入统计。
4.3 报告怎么读:line-rate、branch-rate 和那个容易误解的 complexity
打开 Cobertura 的 HTML 报告,每个包、每个类都有三组数字,含义分别是:
| 指标 | 含义 | 常见误读 |
|---|---|---|
| line-rate | 被至少执行一次的可执行行占比 | 只是"碰到过",不代表断言验证过 |
| branch-rate | if/switch/短路运算的分支被走过的比例 | 分母是分支点数,不是代码行数 |
| complexity | 圈复杂度,衡量方法里独立路径的数量 | 它跟覆盖率无关,是代码复杂度指标 |
很多人把complexity当成"覆盖难度"看待,其实它是 McCabe 圈复杂度,反映的是方法里有多少条独立路径。一个方法complexity是 12,意味着理论上需要 12 条路径才能覆盖完整,而branch-rate只统计二元分支。所以你会看到"分支覆盖率 100%,但复杂度很高"的情况——那是因为switch有多个 case,每个 case 是一个分支,但路径组合数是另一回事。
我读报告的习惯是先看包级的branch-rate,把低于 40% 的包挑出来,再点进去看具体是哪些类的哪些方法。行覆盖率容易靠"跑一遍主流程"刷上去,分支覆盖率才是真金白银。
5. 插桩带来的副作用与排查链路
Cobertura 的插桩是有副作用的,因为它在字节码层面动了你的类。这些副作用在正常路径上通常不显现,一旦出问题,报错信息往往跟根因隔着十万八千里。
5.1 fork 与 cobertura.ser:多模块拼接的真相
cobertura.ser文件默认落在进程的工作目录下。这个"工作目录"三个字是关键:Surefire 默认会 fork 一个新的 JVM 跑测试,fork 出来的 JVM 工作目录是模块根目录,所以数据文件落在模块根下。如果你的surefire配置里设了forkCount=2或者reuseForks=false,就会同时存在多个 JVM,每个 JVM 各自写各自的cobertura.ser,最后只有一个能留下来——后写的覆盖先写的,覆盖率数据凭空少一半。
排查这个问题的链路我一般这样走:
- 看报告里的类数量对不对。如果报告里只有一部分模块或一部分类,先怀疑数据文件没合并。
- 在模块根目录下
ls -la cobertura*.ser。正常情况下应该只有一个;如果有多个带后缀的,说明 fork 了多次。 - 检查
surefire的forkCount和reuseForks配置。默认配置下多数工程是单 fork,问题不大;一旦为了加速调成了多 fork,就必须处理合并。 - 多模块工程检查是否配了
aggregate。父 POM 上执行cobertura:report -Daggregate=true才能把子模块的数据汇总,否则只出最后一个模块的报告。
我在一个多模块工程里遇到过最诡异的一次:报告显示某个模块覆盖率 0%,但这个模块的测试明明是绿的、数据文件也存在。最后发现是子模块的cobertura.ser被父模块的构建覆盖了,因为父模块也跑了一次test,工作目录相同。解决方式是给每个模块的数据文件路径显式指定:
<configuration> <dataFile>${project.build.directory}/cobertura.ser</dataFile> </configuration>指定到各自的target目录下,互不干扰。
5.2 顺序错位导致的 ClassCastException 定位步骤
另一类经典故障是 classpath 顺序问题。Cobertura 插桩后的类里会多实现一个标记接口,如果你在测试中用了某些反射、序列化或者动态代理框架,它们看到多出来的字段和方法可能会懵,抛出ClassCastException或NoSuchMethodError。
排查这类问题要按固定顺序走,别乱试:
第一步,确认实际加载的是哪个类。在测试里打印SomeClass.class.getProtectionDomain().getCodeSource().getLocation(),看看是不是target/generated-classes/cobertura。如果指向target/classes,说明插桩后的目录没被加到 classpath 前面,插桩等于没做。
第二步,反编译一个类看看。javap -p -c target/generated-classes/cobertura/.../Xxx.class,如果类里出现了几个额外的静态字段、一个私有初始化方法、以及新增的接口实现,说明插桩成功;如果没有,说明instrument根本没跑或者跑了别的目录。
第三步,检查插件绑定。mvn help:effective-pom里搜cobertura,看instrument目标绑在哪个 phase。它必须早于test,通常是process-classes或compile。如果谁把它绑到了verify,那测试跑的时候类还没插桩,报告自然是空的。
第四步,检查是否跟其他字节码工具打架。如果工程里同时用了静态分析、字节码增强、AOP 编织之类的工具,它们对 class 文件的操作顺序会互相影响。经验上,让 Cobertura 尽量晚于其他编织工具执行,因为它要统计的是"最终形态"的代码。
5.3 lambda、匿名类和实体类造成的统计失真
Cobertura 这一代工具对 Java 8 的语言特性支持是有欠缺的。lambda表达式在字节码层面被编译成合成方法(lambda$xxx$0),Cobertura 处理这些合成方法时行号映射容易错位,表现为"明明这行执行了,报告里还是红的",或者"某几行连成一片全红"。这不是你的测试没跑到,是统计工具的局限。
switch表达式的分支统计也有类似问题。我用下来的经验值是:在大量使用 lambda 的模块里,Cobertura 的 line-rate 通常比 JaCoCo 低 5 到 10 个百分点,差值主要就来自这类合成方法。
匿名内部类则相反——Cobertura 会把它们当作独立的类单独统计,于是你的报告里会冒出一堆XxxServiceImpl$1、XxxServiceImpl$2。这些类通常只有几行代码,却各自占一个统计条目,把包级的数字拉得很难看。处理方式是在excludes里加通配:com/example/**/*$*.class,把内部类整体排掉。
实体类前面说过了,这里再补一句量化感受:一个典型的中等规模工程,实体类的 getter/setter 能占全部可执行行的 15% 到 25%。如果不排除,你看到的覆盖率会显著低于真实业务逻辑的覆盖水平,团队很容易因此放弃覆盖率卡控。排除实体类不是作弊,是让指标反映它该反映的东西。
6. 阈值卡控与收尾策略
覆盖率的价值在于"能卡住人",不在于"能画个好看的图"。这一节讲怎么让这个数字真正起作用。
6.1 check 目标的阈值定多少不挨骂
cobertura:check是整条链路里最有用的一个目标,它在构建时校验覆盖率,达不到就失败。配置前面已经给过了,关键是阈值怎么定。
我的做法是分三步走,每步之间隔一个迭代:
第一步,先不卡阈值,只跑报告,看看当前的真实水平在哪里。很多团队凭感觉认为"我们覆盖率应该挺高的",实际跑出来可能只有 30%。
第二步,把阈值定在当前值减 5 个百分点的位置,先让构建能过。目的是把基线固定住,防止有人提交代码时把覆盖率往下拉。
第三步,每个迭代把阈值上调 3 到 5 个百分点,直到达到团队认可的目标值(业务模块 60% 到 70% 是比较现实的)。
这套做法的重点是阈值只升不降,并且要在 CI 上强制执行。本地开发可以跳过,但合并前必须过。
haltOnFailure=true意味着不达标直接构建失败,而不是打警告。这个选项要不要开,取决于团队文化。我的建议是开,因为"警告"在构建日志里没人看。
6.2 遗留代码豁免与增量覆盖思路
老工程里最现实的矛盾是:整体覆盖率只有 20%,你说要卡 60%,那这个构建永远红着,大家第一件事就是把插件注释掉。
理性的做法是做范围切分。具体的配置手法是给cobertura:check加includes,只卡新增的模块或包:
<check> <haltOnFailure>true</haltOnFailure> <includes> <include>com/example/order/**/*.class</include> <include>com/example/payment/**/*.class</include> </includes> <totalLineRate>0.65</totalLineRate> </check>这样老代码不在统计范围内,新代码必须达标。随着新模块积累,整体覆盖率会自然往上走,老代码该重构的时候再逐步纳入。
另一个思路是按类卡而不是按总量卡。Cobertura 的check支持classLineRate和classBranchRate,可以要求"每个类至少 50% 行覆盖"。这个策略比总量卡控更狠,能防止某个大类写得很差但被其他类平均掉。代价是重构期会频繁失败,所以我只在核心模块上用。
6.3 什么时候该换 JaCoCo
Cobertura 已经很久没有实质性更新了,这一点必须有清醒认识。下面这张表是我实际迁移时总结的对比:
| 维度 | Cobertura | JaCoCo |
|---|---|---|
| JDK 支持 | JDK 8 基本可用,9+ 基本不可用 | 跟随 JDK 版本持续更新 |
| 插桩方式 | 编译后离线插桩,改 class 文件 | 默认运行期通过 agent 插桩,不改磁盘文件 |
| 对 lambda 的统计 | 行号映射易错位 | 支持较好 |
| 分支统计粒度 | 二元分支 | 支持行、分支、指令、圈复杂度多维 |
| 报告合并 | 手工处理.ser文件 | 内置report-aggregate目标 |
| 构建侵入性 | 高(classpath 顺序敏感) | 低(只加一行 argLine) |
判断标准很简单:如果工程还在 JDK 8 上、构建链路稳定、没人愿意动插件,Cobertura 可以继续用;如果要升 JDK 或者正在新建项目,直接上 JaCoCo,别在 Cobertura 上投入时间。
迁移的坑主要在argLine上。JaCoCo 通过-javaagent挂载,需要把参数塞进 Surefire 的argLine;如果你的 POM 里已经有别的插件往argLine里写东西(比如某些内存参数、编码参数),两者会互相覆盖。标准做法是用@{argLine}占位符配合propertyName配置,把参数拼接起来,而不是硬编码。
我用这一整套组合的真实体会是:覆盖率工具本身从来不是难点,难点是让测试跑得干净、跑得快、结果可复现。HSQLDB 内存库解决的是"干净",事务回滚解决的是"快",每个测试类独立库名解决的是"可复现"。这三件事做扎实之后,Cobertura 只是最后一层观测,挂上去就能用,几乎不用调。反过来,如果测试之间互相污染、跑一次要半小时,那覆盖率报告再漂亮也只是个数字,没人会看第二眼。
最后分享一个我一直在用的小技巧:把cobertura.ser和 HTML 报告目录一起加进 CI 的产物归档里,但只保留最近 30 次。这样当某次提交覆盖率突然掉了 8 个百分点,你可以直接对比上一次的报告,定位到具体是哪个类变红了,比翻提交记录快得多。