测试与调试:注解处理器的单元测试、编译产物检查

注解处理器这东西,写的时候挺爽,但一旦跑起来出了问题,调试起来是真头疼。我早期做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,你可以在测试里捕获它的内容做断言。

小技巧:如果你用 Kotlin 写测试,可以用 mockk 库。它对 Kotlin 的协程和空安全支持更好。我个人更偏爱 mockk,因为它的 DSL 写起来更自然。

编译测试:用 google/testing-compile 模拟编译

单元测试只能测处理器内部的逻辑,但真正的编译过程是没法模拟的。比如注解处理器依赖的 ElementsTypes 这些工具类,在单元测试里很难构造。这时候就需要编译测试了。

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()") })
}
注意:编译产物路径可能因 Gradle 插件版本不同而变化。建议用 Project.file() 或者 Gradle 的 layout.buildDirectory 来获取路径,不要硬编码。

调试技巧:断点打在编译期

如果测试还是查不出问题,那就得上断点了。但注解处理器在编译期运行,普通 IDE 的断点不会触发。你需要:

  1. 在 Gradle 任务中配置 --debug-jvm 参数
  2. IDE 里启动 Remote Debug,监听 5005 端口
  3. 在处理器代码里打上断点
  4. 运行编译任务,IDE 就会停在断点上

我记得第一次用这个方式调试时,发现处理器里一个 for 循环的边界条件写错了,导致少生成了一个方法。这种问题光看代码很难发现,但一跑调试就原形毕露。

知识体系图

下面这张图总结了注解处理器测试与调试的核心流程:

注解处理器测试与调试流程 单元测试 编译测试 产物检查 JUnit + Mockito/mockk 模拟 RoundEnvironment 测试 process() 逻辑 google-testing-compile 模拟完整编译过程 检查生成的源文件 读取 build/generated/ 检查 import、类名、方法 验证代码结构正确性 调试手段:Remote Debug + --debug-jvm 在处理器代码中打断点,通过 Gradle 任务触发编译 目标:生成正确、可编译的代码

避坑指南

最后分享几个我踩过的坑:

  • 测试环境与生产环境 JDK 版本不一致:有些 API 在 JDK 8 和 JDK 11 里行为不同。我建议 CI 和本地用同一个 JDK 版本。
  • KSP 和 KAPT 的测试方式不同:KSP 的测试用 KSPCompiler,KAPT 用 Compiler.javac()。别搞混了。
  • 生成的代码不要依赖处理器内部状态:处理器每次编译可能被调用多次,生成的代码应该是幂等的。我见过有人把计数器放在处理器里,结果第二次编译时计数器没重置,生成了重复的类。

嗯,测试注解处理器确实比测试普通代码麻烦一些。但一旦你把这套流程跑通了,后面开发新功能时就会特别有底气。毕竟,编译期出错的成本,比运行时高太多了

核心要点:单元测试测逻辑,编译测试测流程,产物检查测结果。三者缺一不可。
公众号:蓝海资料掘金营,微信 deep3321