Rain 应用测试
rain-test 在 JUnit 5 中建立 Rain AppClassloader 和完整应用上下文,使 DI、Loader、Event、Job、Hook 等能力按接近生产启动的方式运行。
添加依赖
dependencies {
testImplementation("com.IceCreamQAQ.Rain:rain-test:1.0.0-DEV12")
}
tasks.test {
useJUnitPlatform()
}rain-test 传递依赖 Application 和 JUnit Jupiter API,并通过 META-INF/services/org.junit.platform.engine.TestEngine 注册 rain.test.RainTestEngine。
最小测试
@RainTest
class UserServiceTest(
private val userService: UserService,
) {
@Test
fun createsUser() {
val user = userService.create("Rain")
assertEquals("Rain", user.name)
}
}@RainTest 同时注册 RainTestExtension。Extension 启动应用,再要求 DiContext 创建测试类实例,因此构造器和字段注入均可使用。
@RainTest
class FieldInjectionTest {
@Inject
lateinit var service: UserService
}构造器注入更容易看出测试依赖,也便于不启动 Rain 的纯单元测试复用。
测试启动过程
JUnit Platform 发现测试
↓
RainTestEngine 创建 AppClassloader
↓
标记 @RainTest 的描述符替换为 AppClassloader 中的 Class/Method
↓
RainTestExtension 调用 TestApplicationStarter
↓
注册 EnchantManager 与 HookImpl
↓
Application.start()
↓
从 DiContext 获取测试类 Bean
↓
Jupiter 执行测试方法为什么 TestEngine 和 Extension 都需要?
- Engine 确保测试类本身由 AppClassloader 重新加载,字节码增强可生效。
- Extension 确保测试实例来自 Rain DI,而不是 Jupiter 默认构造器。
只写一个普通 JUnit Extension 不能解决“测试类已被系统类加载器加载”的问题。
测试配置
使用测试资源目录:
src/test/resources/conf/
├─ application.properties
└─ test/
└─ application.properties基础配置:
rain.scanPackages=com.example通过系统属性固定模式:
tasks.test {
systemProperty("rain.runMode", "test")
}然后把数据库、端口、外部服务地址放在 conf/test 覆盖中。不要让测试默认连接生产资源。
测试 Event
@RainTest
class EventTest(
private val eventBus: EventBus,
private val recorder: OrderEventRecorder,
) {
@Test
fun recordsOrder() {
eventBus.post(OrderCreated(1))
assertEquals(listOf(1L), recorder.ids)
}
}EventBus 是同步的,普通监听器断言不需要等待。监听器自行异步化时,测试必须使用可控 dispatcher、Latch 或超时等待。
测试 Job
@RainTest
@CronTab
class JobTest {
@Volatile
var ran = false
@Cron("50ms")
fun tick() {
ran = true
}
@Test
fun runsJob() = runBlocking {
withTimeout(2_000) {
while (!ran) delay(10)
}
}
}使用超时而不是固定长 sleep,失败更快且避免无限等待。Job 线程在 Application shutdown 时关闭;当前测试生命周期是否每类关闭应用需要结合版本验证。
测试 Hook 与事务
Hook 测试必须验证目标类确实从 AppClassloader 加载:
@Test
fun usesRainClassloader() {
assertTrue(service.javaClass.classLoader is AppClassloader)
}事务测试应使用真实测试数据库并同时验证:
- 正常返回时提交。
- 抛异常时回滚。
- Catch 后吞异常时是否按预期提交。
- 多次运行是否遗留 ThreadLocal/EntityManager 状态。
不要用 mock EntityManager 就声称声明式事务已正确工作;需要数据库可观察状态作为证据。
普通单元测试与 RainTest 的边界
不是所有测试都应标 @RainTest:
class PriceCalculatorTest {
private val calculator = PriceCalculator(FakeRateProvider())
@Test
fun calculates() = Unit
}推荐:
- 纯函数、领域对象、手工可构造服务:普通 JUnit。
- 需要 DI 装配、Loader 扫描、Event/Job/Hook:
@RainTest。 - 数据库、SmartWeb Server:
@RainTest+ 外部资源/端到端测试。
RainTest 启动完整应用,速度和隔离性都不如单元测试。
测试类扫描
测试类也必须位于 rain.scanPackages,因为 Extension 最终通过 context.getBean(testClass) 创建实例。测试包不在扫描范围时,仍可能按需创建具体类,但依赖的监听器、Job、Controller 等扫描组件不会自动注册。
建议显式加入生产根包和测试 fixture 包:
rain.scanPackages=[com.example.app, com.example.testfixture]当前实现限制
源码显示以下边界:
RainTestExtension初始化时立即启动 Application,没有公开按测试类定制启动参数的 API。beforeTestExecution当前为空。- Extension 没有显式调用
Application.stop();长期测试进程中要关注线程与资源释放。 - Engine 对 Jupiter 内部 descriptor 类型有直接依赖,升级 JUnit Platform 版本可能产生兼容问题。
- TestApplicationStarter 中 Kotlin ByInject Transformer 注册被注释;委托注入是否与生产一致必须针对当前版本验证。
- 并行执行多个 RainTest 可能共享/竞争线程上下文类加载器和外部资源,不建议默认开启。
Gradle 故障排查
找不到测试
确认:
useJUnitPlatform()已开启。rain-test位于 test runtime classpath。- TestEngine 服务文件没有被打包排除。
- 没有用版本冲突的 JUnit Engine 覆盖 Rain 声明版本。
ClassCastException:类名相同
通常是测试方法、参数或依赖对象分别由系统类加载器和 AppClassloader 加载。不要在静态初始化中提前引用需要增强的应用类,并检查测试框架插件是否缓存 Class。
测试结束进程不退出
检查 Job、Web Server、数据库池和自建线程是否由 ApplicationService.stop 管理。当前 rain-test 关闭行为有限,必要时在测试套件级别显式清理外部资源。
测试分层建议
大量普通 JUnit 单元测试
↓
少量 @RainTest 模块集成测试
↓
SmartWeb HTTP / SmartAccess 数据库端到端测试Rain 的简单实现让集成测试能覆盖真实 Loader 和增强路径;也正因为大量行为在启动期发生,只有单元测试无法证明框架集成正确。