前面我们马不停蹄地实现了 Javelin 框架的许多基础功能。从 HTTP 路由、IoC 容器、注解扫描到数据库访问层,像是在快速奔跑、不断构建着新的地基。但到了某个时刻,我在想是不是应该停下来,回头看看。就像赵雷在《静下来》中唱的那样:
我想应该静下来走一段路
我想应该静下来看一本书
我只想静下来做这些事
...
我只想静下来去反省自己
当我在 IDE 中一行行堆砌代码的时候,突然哼起这首歌,意识到,是时候慢下来——看看自己已经写了什么,有哪些内容真正被验证过,哪些又仅停留在“自信”层面。这篇文章正是在这种状态下写出来的,它或许谈不上多全面的反思,但却记录了我作为框架开发者,在某个时刻静下心来去梳理测试体系的过程。它更像是一份实践记录,一份对“测试意识”的回归笔记。
我们为什么要做单元测试?在这个自建的 Java 框架中,又该如何落地?
为什么要做单元测试?
单元测试是保障代码质量和持续演进能力的基石。尤其对于框架类项目来说,没有测试就没有安全地“改动”。Javelin 在不断完善功能的同时,如果没有测试作为护栏,每一次功能迭代都可能带来雪崩式风险。
📌 原因 1:防止回归
当你的框架被多个业务模块引用,任何一次调整都可能像蝴蝶效应一样影响下游模块。单元测试就像是自动化的“防线”:一旦行为偏离预期,立即发出警报。
📌 原因 2:确保逻辑一致性
你写的每一个公共方法、每一个输入输出契约,是否在各种边界条件下都表现正确?手动验证只能覆盖有限场景,而单元测试可以系统、可重复地校验行为。
📌 原因 3:促进架构解耦
一个类如果很难被测试,往往意味着它耦合过重。测试驱动设计反过来可以推动我们写出更清晰、更模块化的代码。
📌 原因 4:文档即测试
单元测试本质上也是“代码的使用说明书”。别人阅读你的测试用例,比阅读文档更能快速理解接口怎么用、预期行为是什么。
📌 原因 5:提高开发信心和效率
当你在一个已有完善单元测试的项目上工作时,每次修改代码都能立即获得验证反馈。你不需要担心“我改了这个会不会影响那个”,只要测试全绿,就可以安心发布。
基本原则和规范
为了让测试既有效又具备长期维护性,我们在编写 Javelin 的测试时遵循了以下几个核心原则:
✅ 原则 1:快速执行
测试应该运行快速,控制在毫秒或秒级,避免依赖真实网络或数据库等慢资源。
✅ 原则 2:完全自动化
测试应该一键运行,不依赖人工介入或配置切换,可用于 CI/CD 持续集成场景。
✅ 原则 3:只测试单一功能
一个测试用例应聚焦一个功能点,确保测试失败时能快速定位问题源头,避免“一炸炸全家”。
✅ 原则 4:避免测试间互相依赖
每个测试都应具备“自给自足性”,不会依赖其他测试先运行成功。
✅ 原则 5:断言清晰明确
使用 assertEquals
、assertThrows
等断言 API 明确表达预期行为,减少测试意图歧义。
✅ 原则 6:Mock 外部依赖
凡是框架外部(如数据库、文件系统、网络请求)一律 mock 掉,测试只聚焦本类的业务逻辑。
✅ 原则 7:保持可读性
测试代码应像示例代码一样易读,变量命名清晰,测试结构一致,可作为“使用手册”供参考。
通过遵循这些原则,我们能确保测试本身具备可维护性、可演进性,并成为代码质量的重要护栏。
如何在 Javelin 中做单元测试?
Javelin 涉及很多对外依赖,例如 JDBC、连接池、数据源管理、ORM 映射等,因此测试方式需要结合 Mock 技术、代理机制、覆盖率工具来系统搭建。由于对 Java 生态的不熟悉,技术选型基本上只能通过网络上各种文章的对比与社区经验总结,逐步探索出一套适合自己项目的测试组合方式。
✅ 使用 JUnit 5 + Mockito
我们选择了现代 Java 测试组合:
- JUnit 5:模块化、注解丰富、支持 Lambda、兼容 Gradle
- Mockito:业界主流 Mock 框架,能轻松隔离外部依赖
plugins {
id 'java-library'
id 'jacoco'
}
dependencies {
testImplementation 'org.junit.jupiter:junit-jupiter-api:5.10.0'
testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.10.0'
testImplementation 'org.mockito:mockito-core:5.10.0'
}
✅ 对静态依赖使用 Proxy 技术隔离
例如 DbConfigProvider.getAppDbConfig(...)
这类静态方法无法直接被 mock。我们通过引入 DbConnManagerProxy
类,将原始逻辑包装,并注入 mock 结果,以达到“替身测试”的目的。这种方式兼顾不修改生产代码又能可测的平衡。
✅ Mock 掉数据库相关依赖
我们通过 Mockito 模拟 Connection
、DataSource
、DbConfig
等对象,避免测试中实际连数据库,同时可以验证连接是否被正确获取、是否按期望关闭。
✅ 集成 JaCoCo 生成覆盖率报告
我们使用 Gradle 的 jacoco
插件自动生成测试覆盖率报告:
jacocoTestReport {
dependsOn test
reports {
html.required.set(true)
}
}
运行 ./gradlew test jacocoTestReport
后,打开:
build/reports/jacoco/test/html/index.html
即可看到每个类、每个方法的覆盖率信息。
JaCoCo 还能输出 XML 报告供 SonarQube 或 GitHub Actions 使用,后续我们也将集成 CI 中的覆盖率检查。
实践案例:测试 DbContext
@Test
void testClose_shouldCloseConnection() throws Exception {
Connection mockConnection = mock(Connection.class);
DataSource mockDataSource = mock(DataSource.class);
when(mockDataSource.getConnection()).thenReturn(mockConnection);
DbContext dbContext = new DbContext(mockDataSource);
dbContext.openConnection();
dbContext.close();
verify(mockConnection).close();
}
该测试用例验证 DbContext
是否在 .close()
时能如期关闭连接。
你也可以进一步测试连接未初始化时的行为,或反复打开关闭是否存在连接泄露风险。
我们还可以测试 .getConnection()
是否懒加载、是否缓存连接、是否线程安全等高级特性。
进一步的测试实践
Javelin 中的测试不仅仅覆盖正向路径,还包括:
- 异常分支:例如数据库连接失败时是否能抛出自定义异常?
- 缓存逻辑:配置缓存是否命中?配置变更是否生效?
- 事务机制:未来加入事务后,我们将通过 ThreadLocal 控制多操作是否共享连接,并断言事务是否提交/回滚。
- 映射行为:
BeanPropertyRowMapper
是否能正确映射字段与 JavaBean 属性?是否支持 null 值?字段名大小写不一致是否能处理? - 链式查询 DSL:测试 where/andWhere/orderBy 等语法是否能正确拼接 SQL,是否支持参数绑定和分页功能。
拓展:TDD
在实践中我们发现,要写出一个高质量、边界明确、断言清晰、可维护的测试用例,往往比直接实现某个功能要花费更多精力。例如,为了验证一个 SQL 构造器能正确拼接分页语句,我们要写出 mock 数据源、预期参数、模拟连接返回、构造 ResultSet 的行为等大量前置条件。这也正是许多团队虽然强调测试,但却往往难以落地的原因之一。
TDD(Test-Driven Development,测试驱动开发)是一种以测试为设计导向的软件开发方法论。它的核心思想是:先写测试,再写实现,最后重构。
TDD 并不仅仅是一种测试方式,它更是一种编程思维。提前设计好测试的各种场景,再进行真是业务代码的实现。让我们在编写代码时,更关注如何让测试通过,而不是在实现功能后考虑如何测试。TDD 给我们的回报是长远的:它让我们思考“我真正要实现的是什么”,让代码变得更可用、更易测、更稳定。虽然覆盖率数字本身并不能代表一切,但它背后的那份“可验证的信心”,才是 TDD 真正想传达的价值。
在 Javelin 的后续开发和重构过程中,我们逐渐尝试引入 TDD 的流程:
-
写一个失败的测试:思考我们想要实现的功能是什么,先写一个对应的测试用例(它一定会失败,因为实现还不存在)。
-
快速实现使测试通过:只实现刚好能让测试通过的最小逻辑。
-
重构代码结构:在不改变测试结果的前提下重构代码,让其更简洁、更可维护。
通过 TDD,我们获得了几个关键收益:
- 代码设计更聚焦目标;
- 测试用例成为开发文档;
- 实现不偏离预期,测试成为开发的“驱动轮”。
例如在实现 CPQuery.toList()
方法时,我们先写出期望的查询行为和数据结构,然后再去补上内部的 SQL 执行和 Bean 映射,整个开发过程始终围绕“测试先行”展开。
虽然 TDD 并不适用于所有模块(例如复杂初始化流程或与外部服务强耦合的部分),但在 Javelin 中,TDD 非常适合用来实现数据访问、实体映射、查询构造器等核心功能。
总结
在构建微型框架的过程中,Javelin 没有忽视测试体系的建立。通过合理使用 Mock 技术、代理替换、覆盖率报告、JDK Proxy 等机制,我们在功能演进的同时,也保证了框架的稳定性和可维护性。
我们不仅要把功能“做出来”,更要保证它“跑得稳、用得住”。测试不是累赘,而是一种安全感的来源。
单元测试就像是写代码的人留给“未来自己”的礼物,也像是给使用者的承诺:我测试过,我负责。
我们将在后续继续测试更多组件,比如分页查询、事务回滚、实体自动识别、异常处理等,完善每一个关键模块,持续打磨出一个优雅、可测、可维护的 Java 微型框架。
静下来,不只是为了放慢脚步,更是为了走得更远。
由于篇幅原因,示例中的代码仅展示了部分关键实现细节,完整代码请参考GitHub仓库。