测试与调试:注解处理器的单元测试、编译产物检查
注解处理器这东西,写的时候挺爽,但一旦跑起来出了问题,调试起来是真头疼。我早期做APT开发时,经常遇到「明明代码生成逻辑没问题,但编译就是报错」的情况。后来我学乖了——写测试。今天我们就聊聊怎么给注解处理器做单元测试,以及如何检查编译产物。
为什么注解处理器需要测试?
你想想看,注解处理器是在编译期运行的。它不像运行时代码,出错了你能看log、能断点。APT出错了,你只能看到一堆看不懂的编译错误。说白了,测试就是你的安全网。
我个人习惯把测试分成两类:
- 单元测试:测试处理器内部的逻辑,比如注解解析、代码生成模板拼接
- 编译测试:模拟完整的编译过程,检查生成的Java/Kotlin文件是否正确
单元测试:用JUnit测试处理器逻辑
注解处理器本质上也是一个类。它的核心方法 process() 是可以被单独调用的。我一般会这样做:
class MyProcessorTest {
private lateinit var processor: MyProcessor
@Before
fun setup() {
processor = MyProcessor()
}
@Test
fun `test process with valid annotation`() {
// 构造一个模拟的 RoundEnvironment
val elements = mutableSetOf<Element>()
// ... 添加模拟的 Element
val roundEnv = mock<RoundEnvironment> {
on { getElementsAnnotatedWith(MyAnnotation::class.java) } doReturn elements
}
val result = processor.process(setOf(MyAnnotation::class.java.`asType`()), roundEnv)
assertTrue(result)
// 检查生成的代码文件
val generatedFile = processor.filer.createResource(...)
assertTrue(generatedFile.toUri().toString().contains("MyGeneratedClass"))
}
}
这里有个坑——Filer 和 Messager 是编译期接口,在单元测试里直接调用会抛异常。我的做法是用 Mockito 把它们 mock 掉。嗯,这里要注意:Filer.createResource() 返回的是 JavaFileObject,你可以在测试里捕获它的内容做断言。
mockk 库。它对 Kotlin 的协程和空安全支持更好。我个人更偏爱 mockk,因为它的 DSL 写起来更自然。
编译测试:用 google/testing-compile 模拟编译
单元测试只能测处理器内部的逻辑,但真正的编译过程是没法模拟的。比如注解处理器依赖的 Elements、Types 这些工具类,在单元测试里很难构造。这时候就需要编译测试了。
Google 提供了一个库:google-testing-compile。它可以在 JVM 上启动一个完整的 Java 编译器,跑你的注解处理器,然后检查生成的源文件。
@Test
fun `test full compilation`() {
val source = JavaFileObjects.forSourceString(
"com.example.TestClass",
"""
package com.example;
@MyAnnotation
public class TestClass {
private String name;
}
"""
)
val result = Compiler.javac()
.withProcessors(MyProcessor())
.compile(source)
// 编译应该成功
assertTrue(result.isSuccessful)
// 检查生成的源文件
val generatedSource = result.generatedSourceFiles()
.find { it.name == "TestClass_Generated.java" }
assertNotNull(generatedSource)
val content = generatedSource?.getCharContent(true)
assertTrue(content?.contains("public class TestClass_Generated") ?: false)
}
我曾经在一个项目里遇到过这种情况:处理器生成的代码在本地编译没问题,但 CI 上就报错。后来发现是 CI 的 JDK 版本不同,导致 Elements.getPackageOf() 行为不一致。用编译测试后,这种问题一抓一个准。
编译产物检查:生成的代码到底长什么样?
测试通过了,不代表生成的代码就是对的。我建议你直接看编译产物。注解处理器生成的源文件默认在 build/generated/source/apt/ 目录下(KSP 在 build/generated/ksp/)。
你可以写一个测试,直接读取这些文件做断言:
@Test
fun `check generated file structure`() {
val generatedDir = File("build/generated/source/apt/debug/com/example/")
val generatedFile = File(generatedDir, "TestClass_Generated.java")
assertTrue(generatedFile.exists())
val lines = generatedFile.readLines()
// 检查 import 语句
assertTrue(lines.any { it.contains("import com.example.TestClass;") })
// 检查类名
assertTrue(lines.any { it.contains("public class TestClass_Generated") })
// 检查方法签名
assertTrue(lines.any { it.contains("public void doSomething()") })
}
Project.file() 或者 Gradle 的 layout.buildDirectory 来获取路径,不要硬编码。
调试技巧:断点打在编译期
如果测试还是查不出问题,那就得上断点了。但注解处理器在编译期运行,普通 IDE 的断点不会触发。你需要:
- 在 Gradle 任务中配置
--debug-jvm参数 - IDE 里启动 Remote Debug,监听 5005 端口
- 在处理器代码里打上断点
- 运行编译任务,IDE 就会停在断点上
我记得第一次用这个方式调试时,发现处理器里一个 for 循环的边界条件写错了,导致少生成了一个方法。这种问题光看代码很难发现,但一跑调试就原形毕露。
知识体系图
下面这张图总结了注解处理器测试与调试的核心流程:
避坑指南
最后分享几个我踩过的坑:
- 测试环境与生产环境 JDK 版本不一致:有些 API 在 JDK 8 和 JDK 11 里行为不同。我建议 CI 和本地用同一个 JDK 版本。
- KSP 和 KAPT 的测试方式不同:KSP 的测试用
KSPCompiler,KAPT 用Compiler.javac()。别搞混了。 - 生成的代码不要依赖处理器内部状态:处理器每次编译可能被调用多次,生成的代码应该是幂等的。我见过有人把计数器放在处理器里,结果第二次编译时计数器没重置,生成了重复的类。
嗯,测试注解处理器确实比测试普通代码麻烦一些。但一旦你把这套流程跑通了,后面开发新功能时就会特别有底气。毕竟,编译期出错的成本,比运行时高太多了。