JUnit+HSQLDB+Cobertura:老系统持久层覆盖率实战

发布时间:2026/11/3 4:28:37
JUnit+HSQLDB+Cobertura:老系统持久层覆盖率实战 近几年接手过几个跑了五六年的老后台系统最让人头疼的从来不是代码写得多烂而是没人敢动——改一行跑一遍 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之外的手工 DDLCobertura字节码插桩、统计行与分支执行情况、生成报告不提升测试质量、不替代 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.7JaCoCo 0.8.x插桩方式离线修改字节码生成插桩后的 class 目录Java Agent 在线插桩运行期改写最高支持字节码Java 7 较为稳妥Java 8 的 lambda 容易炸跟随 JDK 版本持续更新报告路径target/site/coberturatarget/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 groupIdorg.apache.maven.plugins/groupId artifactIdmaven-compiler-plugin/artifactId version3.8.1/version configuration source1.7/source target1.7/target encodingUTF-8/encoding /configuration /plugin注意如果你的项目必须使用 Java 8 语法比如依赖某个新库的 API那就别硬扛了直接换 JaCoCo。用降级语法去迁就一个停止维护的工具长期看是负收益。2.2 Cobertura 插件的完整配置拆解Cobertura 的 Maven 插件配置是整个链条里最容易配错的一环因为它涉及三个阶段的行为插桩instrument、跑测试test、生成报告report/check。如果只写一个cobertura:cobertura命令插件会自动串起这三步但如果配在pom.xml的 execution 里就要写清楚绑定到哪个生命周期阶段。我常用的配置是这样plugin groupIdorg.codehaus.mojo/groupId artifactIdcobertura-maven-plugin/artifactId version2.7/version configuration formats formathtml/format formatxml/format /formats instrumentation ignores ignorecom/example/dto/**/ignore ignorecom/example/vo/**/ignore /ignores excludes exclude**/*Test.class/exclude exclude**/*IT.class/exclude /excludes /instrumentation check haltOnFailuretrue/haltOnFailure branchRate60/branchRate lineRate70/lineRate totalBranchRate55/totalBranchRate totalLineRate65/totalLineRate packageLineRate60/packageLineRate /check /configuration executions execution idcobertura-check/id phaseverify/phase goals goalcheck/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必要时加reuseForkstrue减少 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_mystruesql.syntax_mystrue这个参数也很有用它让 HSQLDB 兼容一部分 MySQL 语法比如反引号包裹标识符、LIMIT语句的某些写法。如果被测代码里有原生 SQLQuery注解或 XML 里的 SQL这个开关能省掉不少改写工作。3.2 persistence.xml 的最小可用配置JPA 的标准配置走META-INF/persistence.xml。测试环境我习惯单独放一份在src/test/resources/META-INF/下和生产的配置彻底隔离避免误连生产库这种低级但致命的错误。?xml version1.0 encodingUTF-8? persistence xmlnshttp://java.sun.com/xml/ns/persistence version2.1 persistence-unit nametestPU transaction-typeRESOURCE_LOCAL providerorg.hibernate.jpa.HibernatePersistenceProvider/provider classcom.example.entity.Order/class classcom.example.entity.OrderItem/class properties property namejavax.persistence.jdbc.driver valueorg.hsqldb.jdbc.JDBCDriver/ property namejavax.persistence.jdbc.url valuejdbc:hsqldb:mem:testdb;DB_CLOSE_DELAY-1;sql.syntax_mystrue/ property namejavax.persistence.jdbc.user valueSA/ property namejavax.persistence.jdbc.password value/ property namehibernate.dialect valueorg.hibernate.dialect.HSQLDialect/ property namehibernate.hbm2ddl.auto valuecreate-drop/ property namehibernate.show_sql valuefalse/ property namehibernate.format_sql valuefalse/ /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_mystrue 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.autonone。两种方式各有适用场景我列个表对照一下场景推荐策略理由实体映射稳定只想快速跑通数据访问create-drop零维护改字段自动生效需要验证真实 DDL 的兼容性比如生产用 Oracle手工 schema.sql能提前发现方言差异表结构有复杂约束、存储过程、触发器手工 schema.sqlhbm2ddl生成不出来测试初始化数据量大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 ordersHSQLDB 会把它解释成ORDERS通常能对上。麻烦的是反过来——如果实体名带了引号或者用了Column(name\orderNo\)这种写法就会变成大小写敏感导致查询失败。我的处理原则是要么全部交给 Hibernate 生成测试里不写原生表名要么在 URL 里加DATABASE_TO_LOWERTRUE让 HSQLDB 统一转小写这样和大多数开发者的直觉一致。jdbc:hsqldb:mem:testdb;DB_CLOSE_DELAY-1;DATABASE_TO_LOWERTRUE另一个坑是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_LOWERTRUE数据库在测试间被清空内存库随连接关闭而销毁URL 加DB_CLOSE_DELAY-1测试方法间互相污染事务提交后未回滚After里统一 rollbackem.find查到旧对象一级缓存命中flush后clear再查BigDecimal 断言失败equals比较了精度改用compareTo分页查询结果不对方言生成 SQL 与内存库不匹配显式指定HSQLDialect构建突然变慢每个测试类都新建 EMF改成BeforeClass静态复用多模块覆盖率汇总不准Cobertura 聚合能力弱各模块单独出报告再手工合并CI 上全绿但本地红本地跑的是旧 ser 文件执行前先mvn clean6. 补充几个只有踩过才知道的细节上一节说的都是出了问题怎么修这一节讲几个不出问题但能省事的经验属于没人写在文档里的东西。关于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 而不得不放弃某个语言特性或者某个新引入的依赖因为字节码版本问题跑不起来那基本就意味着该迁了。我自己的判断标准是当工具开始限制业务代码的写法时就该换工具了。这个信号比任何版本兼容性表格都更直接。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询