使用 mock 目标 :最后一节包括三种模式,它们使用模拟真实建议目标的“mock 目标”类,可以在不将方面集成到真正目标的条件下测试联结点匹配和建议行为。Highlighter 方面
为了展示这个编目中的样式,我使用了一个实现突出显示搜索术语(即在搜索结果中突出显示用户的查询术语)的方面。我实现了与在上一例子中展示的方面非常相像的一个方面。该系统要在结果汇总页、细节页和应用程序的其他一些地方中突出显示术语。我在本文中展示的这个例子只横切一个类,但是原理是一样的。清单 1 包含 Highlighter 方面的一个实现:
清单 1. Highlighter 定义了突出显示行为public aspect Highlighter{ /* ITDs to manage highlighted words */ private Collection<String> Highlightable.highlightedWords; public Collection<String> Highlightable.getHighlightedWords() { return highlightedWords; } public void Highlightable.setHighlightedWords(Collection<String> highlightedWords){ this.highlightedWords = highlightedWords; } public pointcut highlightedTextProperties() : ( execution(public String getProduct()) || execution(public String getTitle()) || execution(public String getSummary()) ); String around(Highlightable highlightable) : highlightedTextProperties() && this(highlightable) { String highlighted = proceed(highlightable); for (String word : highlightable.getHighlightedWords()) { Pattern pattern = patternForWord(word); Matcher matcher = pattern.matcher(highlighted); highlighted = matcher.replaceAll("<span class= \"bold\">$0</span>"); } return highlighted; } private Pattern patternForWord(String word) { return Pattern.compile("\\b\\Q" + word + "\\E\\b", Pattern.CASE_INSENSITIVE); } }Highlighter 方面捕获联结点的返回值并换成突出显示的版本。它根据存储在 Highlightable 接口中一个类型间字段中的突出显示术语清单选择要突出显示的术语。可以对任何需要表现突出显示行为的类使用 Highlightable 接口,既可以使用在类声明中,也可以使用 declare parents 语句。
在这个例子的初始版本中,我选用一个非常简单的切点。在本文的后面,我将重写这个切点以展示一些测试模式。
I. 测试集成的单元
针对 :横切功能和规范概述 :如在介绍中说明的,使用方面很容易进行集成测试。这个模式非常简单:就像行为没有实现方面那样为系统编写一个测试。换句话说,将对象放到一起、设置状态、调用方法,然后验证结果。关键是编写一个当方面行为错误或者没有应用到希望它应用的联结点处时会失败的测试。如果方面会影响多个联结点,那么就选择几个代表例子。
例子:Highlighter 的集成测试
在清单 2 中要注意的是,这个测试的操作就像对没有使用方面的应用程序一样。它将对象放到一样、设置状态、调用方法并验证结果。
清单 2. 对 Highlighter 的集成测试public class HighlightSearchResultsIntegrationTest extends TestCase { Collection<String> words; private SearchResult result; public void setUp() throws Exception { super.setUp(); words = new ArrayList<String>(); words.add("big"); words.add("grrr"); result = new SearchResult(); result.setTitle("I am a big bear!"); result.setSummary("grrr growl!"); result.setHighlightedWords(words); } public void testHighlighting() { String expected = "I am a <span class=\"bold\">big</span> bear!"; assertEquals(expected, result.getTitle()); expected = "<span class=\"bold\">grrr</span> growl!"; assertEquals(expected, result.getSummary()); }}
优缺点
不管是否使用 AOP,集成测试的代价和优点是类似的。不管哪种情况,主要的好处是验证代码的高层目标(换句话说,正确突出显示标题和结束语)。在进行大的重构时它会提供帮助。它还会找出当组件交互时才会出现的问题。
不过,只进行集成测试会带来一些问题。如果 HighlightSearchResultsIntegrationTest 失败,那么这可能是因为建议逻辑或者所涉及的其他类(如 SearchResult)有错误,而使方面根本没有运行。事实上,我在开发这个集成测试例子时就遇到了这种情况。我花了 20 分钟试图搞清楚为什么方面没有运行,最后发现在正则表达式中有一个暗藏的问题!
集成测试还需要更复杂的设置和断言,这使它们比分别测试单独的方面更难编写。并且很难用集成测试模拟代码需要正确处理的所有临界情况。
横切数个类的行为给集成测试带来了一个特定问题。假定对应用程序中的所有类有统一的异常处理。我们不想对每一个类测试这个新行为。相反,希望选择一个代表性的例子。但是如果选择了特定的 域类(比如 Customer 类),并测试了它的错误处理方面,那么就会有模糊测试目的的可能性。测试是验证 Customer 的行为还是验证应用程序的错误处理呢?
II. 使用可视化工具
关于测试广泛分布的横切关注点的一个难题是它会报告太多的联结点。执行并检查所有的匹配是个大麻烦。(另一方面,意外加入不需要的联结点会更糟糕)。相应地,下面两个模式展现了使用在 AJDT 这样的工具中提供的人工检测横切视图补充正常测试的好处。(在撰写本文时,AspectJ 与 AJDT 结合提供了大多数可视化支持,不过,JBoss AOP 和 JBoss IDE 等其他组合同样提供了很好的可视化工具。)
模式 1. 可视化地检查横切
针对 :横切规范概述 :在开发方面时使用 AJDT 的 cross-references 视图查看它要建议哪些联结点。人工验证清单是否完整,并且不包含应忽略的联结点。
例子:找出不需要的匹配
假定要突出显示标题、产品和搜索结果的汇总。不用像在 清单 1 中那样枚举每一个方法,可以说明想要找的是一个更健壮的切点。(关于健壮的切点的更多内容,请参阅 参考资料 中 Adrian Colyer 的 blog。)下面的切点看来是抓住了原来的想法: public pointcut highlightedTextProperties() :( execution(public String get*()) && ! execution(public * Highlightable.*(..)));
不过,在用 AJDT 的 cross-references 视图检查切点时,会看到图 2 所示的内容:
图 2. AJDT cross-references 视图中四个建议的联结点
注意有一个多余的匹配:SearchResult.getWebsite()。您知道这个 Website 不应该突出显示,因此重新编写这个切点以排除不需要的匹配。
优缺点
使用 AJDT 的 cross-references 视图检查横切规范有三个主要的好处。首先,cross-references 视图可以在开发方面时马上给出反馈。其次,它使您可以容易地发现难于测试的结果。(要编写验证 getWebsite()没有 突出显示的测试,需要猜出 getWebsite() 可能会出错,或者检查 SearchResult 中每一个 String getter。越不容易出的错误,就越难很好地测试。)第三,自动生成的视图可以验证正确情况,在代码中验证它们是很麻烦的。例如,如果搜索 highlighter 需要影响 20 个联结点,那么检查 cross-references 视图比为每一个联结点编写测试更容易。
使用视图验证的主要缺点是不能自动检查。它需要程序员的自律。匆忙的程序员可能看过图 2,却没有发现问题。(下一个模式展示了对这个问题的部分解决方案。)另一个问题是横切视图只显示了基于静态联结点 shadow 的匹配。换句话说,如果有依赖于运行时检查的切点,如 cflow() 或者 if(),那么 cross-references 视图不能肯定地说联结点会在运行时匹配,只能说看来如此。
模式 2. 检查随横切比较工具改变
针对 :横切规范概述 :利用 AJDT 的横切比较功能在重构之前或者其他代码改变前保存项目的横切图。在完成改变后保存另一个图。(还可以每晚保存一个图以便比较。)在横切比较工具中比较这些图,以发现受方面影响的联结点所出现的不希望的改变。注意在撰写本文时,只有 AJDT 提供横切比较工具。
例子:改写一个切点
假定要改正上一个例子中表现出的问题,决定修改切点以使用 Java 5 注释,如下所示:
public pointcut highlightedTextProperties() : execution(@Highlighted public String Highlightable+.*())
然后在源代码中适当位置上添加注释,例如:;@Highlighted public String getTitle() { return title; }
下一步是比较在改变前后所抓取的项目快照,并得到如图 3 所示的结果。如您所见,重构消除了 getWebsite() 的建议匹配,但是也消除了 getSummary() 的匹配。(它看上去就像没有添加上注释。)
图 3. 在横切比较工具中显示的改变结果
优缺点
这项技术实际上是对上一项技术的优化。通过只显示改变,横切比较工具可以帮助防止信息盲点。同时,cross-references 视图要求选择需要分析的建议或者类,而横切比较工具使您可以检查整个项目的改变。
缺点是横切比较工具在方面影响多个联结点时会不好用。考虑一个记录所有公共方法的日志。这样一个方面在哪怕一天的开发后也会增加十来个新改变,使得查看其他更重要的改变变得困难了。在真实世界中,横切比较工具可以有很多配置,对某些方面的改变发出警报,而忽略与其他方面有关的改变。
III. 使用委派
方面可以并且通常用普通对象实现横切行为。可以利用这种关注点的分离分别测试横切规范及它们的行为。下面两个模式展示如何使用委派和 mock 对象检查方面的这两个部分。
模式 1. 测试委派的建议逻辑
针对 :横切功能概述 :如果还没有做的话,可以将一些或者全部建议逻辑委派给其他可以直接测试的类。(如果愿意的话,还可以将行为委派给方面的公共方法。)
例子:将突出显示逻辑转移到其他类
要更好地在隔离状态下测试突出显示逻辑,可以将它转移到一个专门的工具类中:private HighlightUtil highlightUtil = new CssHighlightUtil(); public void setHighlightUtil(HighlightUtil highlightUtil){ this.highlightUtil = highlightUtil; } String around(Highlightable highlightable) : highlightedTextProperties() && this(highlightable) { String result = proceed(highlightable); return highlightUtil.highlight(result, highlightable.getHighlightedWords()); }
通过抽取突出显示逻辑,可以编写调用 HighlightUtil 类的方法的单元测试。
优缺点
这项技术使得在域逻辑中产生边缘用例更容易了。它还有助于隔离问题,如果 helper 类的测试失败,就会知道是它而不是方面有问题。最后,委派逻辑通常会得到更干净的关注点分离。在这个例子中,通过将文字突出显示逻辑抽取到其它类,它变成系统其他部分可以独立于这个方面使用的一项操作。从而使方面获得了使用不同的突出显示策略的灵活性(HTML 的 CSS 突出显示、纯文本的全部大写突出显示等等)。
不利的一面是,这种技术在逻辑难于抽取时就无能为力了。例如,最好让简单的逻辑留在原处。同时,一些方面将状态存储到本地或者它们建议的类的 ITD 中。状态存储通常构成了方面逻辑的签名部分,它并不总能干净地转移到 helper 类中。
模式 2. 使用模拟对象记录建议触发
针对 :横切规范和功能概述 :这项技术补充了前一项技术。如果将建议行为抽取到另一个类中,那么就可以用一个 mock 对象替代 helper 类对象,并验证建议是否在正确的联结点上触发。还可以验证建议将正确的上下文传递给了 helper 类,不管是直接用建议参数还是用之前存储的状态。
注: 如果需要对 mock 对象的介绍,请参阅 参考资料。
例子:用一个 mock HighlightUtil 测试 Highlighter 方面
我们已经看到了方面如何委派到另一个类中以处理实际的文字突出显示。这使得在测试中向方面注入不同的 highlighter 实现成为可能。清单 3 中的代码利用 JMock 库做到了这一点。(请参阅 参考资料。)
清单 3. 用 JMock 测试来自方面的调用public class DelegatedHighlightingUnitTest extends MockObjectTestCase { Collection<String> words; private HighlightUtil original; private SearchResult result; private Mock mockUtil; public void setUp() throws Exception { super.setUp(); setUpMockHighlightUtil(); words = Collections.singleton("big"); result = new SearchResult(); result.setTitle("I am a big bear!"); result.setHighlightedWords(words); } private void setUpMockHighlightUtil() { original = HighlightResults.aspectOf().getHighlightUtil(); mockUtil = mock(HighlightUtil.class); HighlightResults.aspectOf().setHighlightUtil((HighlightUtil)mockUtil.proxy()); } public void testHighlightUtilAppliedToTitleOfSearchResult() { mockUtil.expects(once()) .method("highlight") .with(eq("I am a big bear!"), eq(words)); result.getTitle(); }}
setUp() 方法实例化 mock 对象并将它注入到方面中。测试方法告诉 mock 等待对名为 “highlight” 的方法的调用,这个方法有两个参数:getTitle() 的返回值和在 SearchResult 中存储的单词清单。设置了期望后,测试调用 getTitle() 方法,它应当触发方面并产生预期的对 mock 的调用。如果 mock 没有收到调用,那么它就会在销毁时自动使测试失败。
注意 setUp() 方法存储了到原来 HighlightUtil 的引用。这是因为方面像大多数对象一样,是单元素(singleton)的。因此,销毁时撤销 mock 注入的影响很重要,否则,mock 会持续留在方面中并影响其他测试。这个方面的正确销毁如下所示: @Override protected void tearDown() throws Exception { try { HighlightResults.aspectOf().setHighlightUtil(original); } finally { super.tearDown(); } }
优缺点
这个模式对前一个模式做了补充,只是它测试方面的横切规范和上下文处理而不是横切行为。因为不用检查方面的输出的间接副作用,所以可以更容易地产生联结点匹配和上下文传递行为中的临界用例。
重要的是要认识到委派逻辑的优缺点,用 mock 进行测试对于使用对象或方面的技术都是类似的。在这两种情况下,都是分离关注点,然后以更隔离的方法验证每一个关注点。
对于注入 mock 来说,有一个特定于方面的问题。如果使用单元素方面(默认的),那么对于方面的字段所做的所有改变,如用 mock 替换一个字段,在测试结束时都必须撤销。(否则,mock 会挂起并可能影响系统的其他部分。)这种销毁逻辑很难实现和记忆。编写一个测试清理方面,自动在每次测试后像在例子中那样重新设置方面从概念上来说是简单的,但是其细节超出了本文的范围。
IV. 使用 mock 目标
在最后一节中,我介绍了我自己发明的、用于描述在编写方面测试时用到的一种测试 helper 类类型的术语:mock 目标。在方面之前的世界中,一个 mock 对象 表示一个(手写或者动态生成)的类,它模仿要测试的一些类的协作器。与此类似,mock 目标 是一个模仿要测试的一些方面的合法建议目标的类。
为了创建 mock 目标,编写一个与生产中的建议有某些相似结构或者行为的类。例如,如果对于由 getter 返回的文字的突出显示感兴趣,可以编写下面这样的一个 mock 目标://an inner class of the enclosing test casepublic class HighlightMockTarget implements Highlightable { public String getSomeString() { return "I am a big bear!"; }}
然后,编写测试用例以验证方面正确地与目标交互,如清单 4 所示:
清单 4. 与 mock 目标交互以测试建议public void setUp() throws Exception { super.setUp(); setUpMockHighlightUtil(); words = Collections.singleton("big"); mockTarget = new HighlightMockTarget(); mockTarget.setHighlightedWords(words);}//mock setup/tearDown omittedpublic void testHighlighting() { mockUtil.expects(once()) .method("highlight") .with(eq("I am a big bear!"), eq(words)) .will(returnValue("highlighted text")); String shouldBeHighlighted = mockTarget.getSomeString(); assertEquals(shouldBeHighlighted, "highlighted text");}
注意在这个例子中,我结合了 mock 目标和 mock 对象(如在 第 III 节,模式 2 中所描述的)。mock 目标为下面三种技术提供了基础。
模式 1. 通过扩展一个抽象方面并提供一个切点来测试建议
针对 :横切功能概述 :Prework :如果有必要,重新编写方面,将它分为一个抽象方面以及一个扩展它并具体化一个或者多个切点的具体方面。
有了抽象方面后,在测试类中创建一个 mock 目标。创建一个扩展了抽象方面的测试方面。让测试方面提供明确针对 mock 目标的切点。这个测试通过查找建议的已知副作用或者使用一个 mock 对象来验证方面中的建议是否成功。
示例:扩展 AbstractHighlighter
假定已经编写了 上一节中的测试代码。为了使测试通过,必须将 Highlighter 方面分解为一个抽象方面和一个子方面,如下所示:
public abstract aspect AbstractHighlighter { public abstract pointcut highlightedTextProperties(); //... aspect continues}public aspect HighlightResults extends AbstractHighlighter { public pointcut highlightedTextProperties() : ( //...define pointcut as before );}下一步,用一个只用于测试案例的方面扩展 AbstractHighlighter 方面。下面我将它展示为测试案例的一个静态内部方面:private static aspect HighlightsTestClass extends AbstractHighlighter { public pointcut highlightedTextProperties() : execution(public String HighlightMockTarget.*(..));}
这个方面通过选择 mock 目标上所有的方法执行具体化了 highlightedTextProperties 切点。
优缺点
显然,这种测试过程是一种人造的情况。对一个假的对象测试假的方面。不过,这只是表明测试的不是真正的切点。仍然可以验证建议和抽象方面所指定的 ITD 代码。在例子中,测试验证建议正确地编组了来自 ITD 的数据以及原来联结点的返回值、将它传递给一个工具类并返回新的结果。这涉及了相当多的行为。使用一个 mock 目标还使测试更清晰了,因为测试的读者不必阅读真正目标的行为以及方面的行为。这种测试在为方面库编写单元测试时特别有用,因为只有到了方面加入到具体的应用程序中以后才会有真实的目标。
如果将方面分解以利用这种模式的好处,那么您可能使它更具可扩展性。比如,如果系统的新部分需要参与突出显示行为,那么它们可以扩展抽象的方面并定义覆盖新情况的切点。这样,抽象方面就与它所建议的系统解耦了。
模式 2. 测试与 mock 目标匹配的切点
针对 :横切规范和功能概述 :这项技术与上一技术密切相关。这次不是扩展一个抽象类,而是编写 mock 目标,以使它匹配要测试的方面上的一个切点。可以通过检查方面是否建议了 mock 目标来测试切点是否正确。如果要测试的切点过度专门化,那么可能需要重新编写它,使得 mock 目标可以更容易地“预定”建议。
示例:基于一个标志接口测试切点
不是使突出显示方面成为抽象的,而是改写切点使它匹配 Highlightable 接口上的方法执行:
public pointcut highlightedTextProperties() : execution(public String Highlightable+.get*());
这种宽泛的切点匹配 Highlightable 上的所有 String getter。因为切点不枚举特定的类,它已经匹配了 mock 目标上的 getSomeString() 方法。测试的其余部分保持不变。
变化:使用一个注释
还可以编写切点以部分根据 Java 5.0 元数据进行匹配。例如,下面修改后的切点匹配用 ;@Highlighted 注释修饰的方法执行:
public pointcut HighlightedTextProperties() : execution(@Highlighted public String Highlightable+.*());//you can apply the annotation in the source, or using the declare-annotation formdeclare @method : public String SearchResult+.getTitle(..) : @Highlighted;declare @method : public String SearchResult+.getProduct(..) : @Highlighted;可以通过添加注释到其 getSomeString() 方法,使 mock 目标匹配新的切点:;@Highlighted public String getSomeString() { return "I am a big bear!"; }
优缺点
这项技术还明确地分离了对方面行为与目标应用程序的行为的测试,使测试变为更独立。如果切点还没有编写为容纳 mock 目标,那么应当通过重新编写它们得到一个耦合更松散的方面。通过使方面足够一般化,可以影响测试类中的 mock 目标,还会保证它可以容易地让真实类参与方面的行为。
模式 3. 验证更复杂的切点(一个特殊情况)
针对 :横切规范和功能概述 :上一个 mock 目标是简单的,但是也可以将 mock 目标编写为模拟复杂的联结点(如 cflow())或者要影响的一系列联结点。
例子:模拟 cflow
假定希望对于下载的报告关闭突出显示。可以加入一个 highlightExceptions切点以排除由 ReportGenerator 调用的任何 getter,如下所示:public pointcut highlightedTextProperties() : execution(public String Highlightable+.get*()) && !highlightExceptions(); public pointcut highlightExceptions() : cflow(execution(* ReportGenerator+.*(..)));
然后可以编写一个 mock ReportGenerator,它调用 HighlightMockTarget 以测试没有进行突出显示:private class MockGenerator implements ReportGenerator { public void write(OutputStream stream) throws IOException { mockTarget.getSomeString(); }}public void testNoHighlight() throws Exception { mockUtil.expects(never()).method("highlight"); MockGenerator accessor = new MockGenerator(); accessor.write(null);}
不过,可以想像为更复杂的匹配情况(例如,somePointcut() && ! cflowbelow(somePointcut()))创建一个类似的 mock 目标。可视化工具不能给出关于使用运行时检查的切点(如 cflow())的匹配的详细信息。用几个代表性的 mock 目标检查这种切点是值得的。
结束语
当我看到未测试的代码时,就觉得厌烦。没有好的测试集的代码通常有很多问题,难于进行有信任度的改变,并且难以重构。不过,如果用方面实现横切行为,那么就有了测试(并理解)应用程序的横切关注点的新方法。
测试方面与测试对象很相似。这两种测试都需要将行为分解为可以单独测试的组件。一个要掌握的关键概念是横切关注点分为两个区域。首先是横切规范,它要回答的是关注点影响的是程序的哪些部分。其次是功能,它回答的是这些点上会发生什么。如果只使用对象,那么这两个区域是交叉的,因为关注点在应用程序中是纠缠在一起的。不过,使用了方面后,可以以一个领域为目标或者同时分别以两个领域为目标。
将方面编写为可测试的,得到的设计好处与通过重构面向对象的代码来实现可测试性所得到的好处相似。例如,如果将建议的正文转移到一个可独立测试的类中,那么就可以分析其行为而不用理解它横切应用程序的方式。如果修改切点以使它们更能被 mock 目标访问,也就使它们更可被系统中的非测试部分访问。不管是哪种情况,都提高了系统整体的灵活性和可插入性。
不久之前,我听到了一个流传的说法,说面向方面的程序不能测试。尽管这个谣传基本上已经消失,我仍然认为它是一个挑战。我希望本文表明不仅可以对方面进行测试,而且在测试横切时,使用了方面后会好得多。
致谢
Ron Bodkin、Wes Isberg、Gregor Kiczales 和 Patrick Chanezon 对本文贡献良多,他们审阅了初稿并提供了有益的意见和修改。