问题恰恰在于编译器外部环境的不可用性。如果没有它的环境,测试注释处理器将是一个失败的原因。
由于未知的原因,探讨注释处理器的主题似乎在开发人员中引起了一些原始的恐惧。人们倾向于将注释处理与只有最熟练的地下巫师才能执行的边界巫术和法术联系起来。不一定要那样。注释处理不必是藏在床底下的大型吓人怪兽。
毫无疑问,注释处理_确实_存在问题,但是解决这些问题的方法也存在。特别突出的一个问题是对注释处理器进行单元测试的困难。一组JUnit 5扩展Elementary解决了一个问题。
此注释处理Thingamajig是什么?
对于未启动的用户,注释处理器类似于编译器插件。像它的名字一样,它可以由编译器调用以_处理_注释,即@Nullable在编译期间。所述过程涵盖了极其广泛和模糊的范围。从简单的值验证到功能完善的可插拔类型系统(如checker-framework),应有尽有。一个简单的@Builder注释生成器,用于通过像Dagger这样的代码生成来进行全面的依赖注入。
在Java 9之后,它位于java.compiler模块内部。注释处理器中包含Elements和TypeMirrors的寓意域,Java语言的抽象语法树(AST)表示形式以及Javaland中的反射框架的对应形式。Elements表示语法构造,例如方法,数组等,而TypeMirrors表示类型,例如引用类型(类)和基元,但是我们离题了。
为什么这么难?
那么,什么使测试注释处理如此困难呢?我们认为,有关注释处理环境的所有内容。我们并不是在说环境是某种邪恶的怪诞事物,实际上它的设计出奇地令人惊讶。问题恰恰在于编译器外部环境的不可用性。如果没有它的环境,测试注释处理器将是一个失败的原因。
一款好酒的游戏在需要注释处理环境的注释处理器中为每个方法调用拍摄镜头。
几乎所有内容都需要如上所述的注释处理环境。
在这个路口,我们有四种解决方案来克服这种泡菜:
不要为单元测试而烦恼。
等待某事,任何事情都会发生。
模拟/重新实现注释处理环境。
将注释处理环境从编译器中走出来。
长话短说,我们最终成为走私者。
Smuggler’s Discovery
拖网时,我们发现了Google的编译测试项目,这是一个埋藏在GitHub项目范围内的隐藏宝石。通过一些巧妙的技巧,该项目设法为单元测试提供了一个注释处理环境,尽管有些乏味和有限。探索该项目时,很明显这不是我们所希望的灵丹妙药。该项目受到一些我们无法忍受的限制:
仅支持JUnit4。注释处理环境仅可通过JUnit规则使用,而JUnit 5不再支持该功能。我们使用JUnit 5的时间最长,并且不打算在不久的将来降级。
用于注释处理环境的实用程序受到限制。它_可以工作_,但更符合人体工程学。
在测试中无法遍历一个Element或TypeMirror多个编译文件。这对于将编译后的文件用作测试用例至关重要。
注释处理环境的范围限制。注释处理环境仅限于测试方法的范围。这很不方便,因为无法在多个测试之间共享测试状态的初始化。此外,该设计使其自身具有意外的行为。
这并不是说项目很_糟糕_,只是我们的目标有所不同。实际上,Elementary的某些部分是基于编译测试的。顾名思义,编译测试专注于测试代码的编译,而不是注释处理。那不是我们的目标。我们的目标是简化注解处理器的单元测试。因此,健康的剂量后,“__握住我的啤酒”,并没有发明这里综合征,基本项目的构想。
Elementary, My Dear Watson
以编译测试为基础,我们着手实现将Basic变为现实。从一尘不染开始,我们就有了做出决定的自由,否则这些决定会激起愤怒的暴民与干草叉和火把:
仅支持Java 11及更高版本。Java 9中的模块系统对jdk.compiler模块和ClassLoaders进行了一些重大更改。我们不想处理。
仅支持JUnit5。我们不希望支持我们不使用的等效JUnit 4。
我们使用Chimera代码生成工具的经验告诉我们,注释处理器的测试属于经典的黑盒和白盒测试类别。对于小型和/或简单的批注处理器,针对示例Java源文件在编译器中调用批注处理器更为有效。随着注释处理器的复杂性和大小的增加,针对示例文件运行注释处理器的收益将逐渐减少。隔离和测试各个逻辑组件将不再那么乏味。两种不同的类别具有两组完全不同的要求。
Box of Fun Things
黑盒测试注释处理器_可能_很有趣。它_并不_必须设置,拆除和配置无数。JavacExtension至少没有相应地。对于每个测试,JavacExtension使用给定的注释处理器编译一组测试用例。然后,将编译结果集中到测试方法中以进行后续声明。所有配置均通过注释处理,而无需其他设置或拆卸。
“他们说眼见为实,所以让我们继续看下去吧。”
我们虚构的注释处理器非常简单。它所做的只是检查带有注释的元素是否@Case也是字符串字段。如果元素不是字符串或变量,则会显示一条错误消息。既然_这么_简单,只需对我们的注释处理器进行黑盒测试就足够了。
测试我们虚构的注释处理器也不太困难。我们要做的就是在测试类上添加一些注释,创建一些测试用例,检查编译结果,然后瞧!大功告成!
让我们分解一下代码片段。
通过使用注释测试类@Options,我们可以指定在编译测试用例时使用的编译器标志。在此摘要中,-Werror指示将所有警告视为错误。
要指定编译器要调用的注释处理器,我们可以使用注释测试类@Processors。正确猜测此片段中的哪个注释处理器没有任何奖励。
通过用@Classpath或注释测试类,可以包含测试用例以进行编译@Inline。可以使用classpath包含Java源文件,@Classpath而内部字符串@Inline可以转换为嵌入式源文件进行编译。在此片段中,两个ValidCase和InvalidCase都包括在内以进行编译。
注释的范围与目标的范围相关。如果对测试类进行了注释,则该注释将应用于该类中的所有测试方法。同样,测试方法的注释将仅应用于该方法。
Results表示编译结果。我们可以指定Results作为测试方法的参数来获取编译结果。在这个片段中,process_string_field(…)将获得的结果ValidCase.java,同时process_int_field(…)将收到两个结果ValidCase.java和InvalidCase.java。
Pandora’s Box
这是事情变得真正有趣的地方。白盒测试并不像调用注释处理器那样简单,因为测试试图证明的可能性是无限的。在黑盒测试中,我们只需要证明已知注释处理器针对固定数量文件的编译结果符合特定条件即可。相反,在白盒测试中,我们不知道为什么,什么以及如何测试组件。我们能做的最好的事情就是使注解处理环境可以在测试类内部访问。
“允许类范围的注释处理环境并不难,编译测试已经做到了。”
最初我们也有同样的感觉,男孩是我们错了。尽管编译测试确实提供了注释处理环境,但它仅限于测试方法的范围。无法在方法之外访问所述环境意味着重复且冗长的初始化代码,这很麻烦。可悲的是,我们不能仅仅调整编译测试的技巧,因为它发现与我们的目标不兼容。
编译测试背后的秘密实际上很简单。每个测试方法都由JUnit规则拦截,并包装在注释处理器中,该处理器在处理过程中会调用该方法。该测试随后在JUnit规则调用的编译器内部执行。不幸的是,在这种技术中,注释处理环境仅在测试方法时可用。由于JUnit生命周期的限制,无法调整技术来拦截测试实例的创建并将测试实例注入到注释处理器中。
后来在绘图板上花费了大量时间,我们成功创建了ToolsExtension。此扩展利用了以下事实:测试实例仅需要访问注释处理环境。测试不需要在注释处理器中执行。一旦确定了这一点,我们的技巧就是在创建每个测试实例之前,在守护程序线程上运行带有阻塞注释处理器的编译器。通过将编译暂停在处理器内部,可以使环境可被主线程上的测试实例访问。仅在执行完所有测试之后,编译才会恢复。
这是绘制效果不佳的MS Paint图,说明了整个过程。
让我们假设,由于我们在虚幻的盒子中描述的虚构处理器的范围和大小不断增长,它被重构为多个组件,其中一个组件检查元素是否像原始注释处理器一样是字符串变量。
使用ToolsExtension来测试注释处理器会产生以下代码片段:
让我们分解一下代码片段:
通过用注释类,@Inline我们可以指定一个内联Java源文件,其中ToolsExtension包括要进行编译的文件。
可以通过将Tools类或依赖项注入到测试类的构造函数或测试方法中来访问批注处理环境。在这种情况下,我们TypeMirrors使用上的静态方法访问当前值Tools。
在深入解释都@Case和Cases将在下面的章节中提供。目前,它只是用于在已编译文件中查找元素的机制。
案例案例
随着的完成ToolsExtension,我们成功地将注释处理环境从编译器中走私了出来。然而,难题中的最后一块仍然存在。我们如何创建这些元素来测试我们的代码?该jdk.compiler模块不提供创建元素的方法。虽然Element可以模拟,但远非开发人员友好。初始化不仅冗长,笨拙而且令人费解,而且很难保证模拟元素的行为与其实际对应的行为匹配。我们也不能寻求compile = test作为指导,因为它没有提供类似的信息。
经过很多头痛之后,我们设法找到了丢失的那一块。让我们让编译器将用惯用Java编写的测试用例转换成适合我们的元素。这样,我们避免了元素初始化周围的混乱,并且生成的代码更容易理解。为此,我们需要某种方式从编译器中获取元素。在进一步完善概念之后,我们最终开发了Cases类和相应的@Case注释。
返回潘多拉魔盒(Pandora’s Box)的代码段,让我们对其进行更详细的分析。
通过@Case在Java源文件中注释一个测试用例,我们可以从中获取其对应的元素Cases。A@Case也可以包含标签以简化检索。
通过Cases,我们可以通过案例的标签或索引来获取元素。通过依赖注入,我们可以在此代码片段中获得CasesviaTools.cases()或like的实例。
想法墓地
如本文开头所述,我们探索了其他一些最终导致失败的途径。我们认为它们足够有趣,可以在以下各节中进行讨论。由于该解决方案的不切实际和不可接受的折衷,大多数人最终被搁置了。
不测试注释处理器无疑是一个糟糕的选择。仅仅因为测试它们很困难并不能给我们跳过它的自由。如果我们选择走简单路线,问题只会随着时间的流逝而恶化。此外,大多数注释处理器通常执行代码生成和静态类型分析。两者都很难解决。
那些等待的人来了。但是,为之奋斗的人们会遇到更好的事情。”
如果JEP 119:javax.lang.model实现JDK 8附带了Core Reflection支持,我们非常怀疑Elementary是否会被构思出来。它通过提供标准实现来解决在编译器外部访问注释处理环境的问题。可悲的是,它被搁置了,未来的努力似乎停滞了。由于没有任何可等待的内容,因此对单元测试注解处理器进行观望的方法将是不可行的。
比测试注释处理更困难的问题是尝试模拟/重新实现注释处理环境。由于元素代表Java语言的AST,因此我们需要与语言规范保持紧密联系,以确保模拟/重新实现的元素的行为不会偏离原始元素。老实说,这使得测试注释处理器看起来像迪士尼的童话,即使是十英尺长的杆子,我们也不想碰它。确实存在一些现有的重新实现,但是似乎已经被抛弃了多年。最后,归结为导致我们放弃这一途径的弊大于利。
最后的想法
我们已经结束了简化注释处理器测试的旅程。回顾过去,这对Elementary来说是绝对的爆炸。如何采用该项目仍有待观察。但是,如果有的话,我希望本文鼓励您开始使用批注处理器。
总而言之,Elementary 引入了:
在JavacExtension对黑箱测试和测试简单的注解处理器。
一个类范围的注释处理环境,用于使用注释的测试类ToolsExtension。
从编译器到测试类获取元素的实用程序。