转载

JUnit 中的设计模式

JUnit 中的设计模式

前面几篇文章中, 我们分别从江湖故事、 棋类使用类比 和小范围的实例中, 看到了设计模式的价值和使用。不过瘾?有没有成名的框架在设计模式使用上有很好的范例? 答案是肯定的, JUnit就是其中一个。

本篇文章中,从最外围的需求入手,步步为营,看到设计高手是怎么地匹配到合适的设计模式,从而体现在JUnit框架中, 最终解决众多程序员的单测难题,为软件质量保驾护航。

JUnit 中的设计模式

绪论

早期文章(参见Test Infected: Programmers Love Writing Tests, Java Report, July 1998, Volume 3, Number 7)中,我们介绍了怎么使用Junit这个简单的框架来写可重复的测试类。本文中,我们将掀开盖子往下看看,Junit自己是怎么实现的。

我们仔细研究了JUnit框架,反思我们当时是怎么设计的。在不同层面上, 我们发现了多个经验教训。本篇文章中, 和盘托出这些设计思路,其中将使用对应的代码来展示Junit设计技巧。

整篇文章结果是这样的。先介绍Junit框架的目标,随后我们看下Junit框架的设计及实现。描述设计方案时 使用了设计模式的术语(是不是有些惊喜?),具体实现时使用伪代码。最后结尾时,我们给出Junit框架开发过程中的一些选择性的思考。

目标

Junit的目标是什么?

首先,我们需要看下一个开发假设。如果一个程序功能没有自动化测试,我们可以认为它不能用。相比于现在大多数人的认为,这个说法还是很安全的,大多数开发认为,如果自己觉得 程序 功能可用,那么这个功能就是可以运行的,且永远可以运行。

从这个角度看,当开发写完实现代码后,工作还没完,也需要写出测试类,让写些测试自己证明这些功能是正常的。不过,现实中,每一个人又都很忙,有很多很多事要去做,我们没有那么多时间来使用测试细致地验证每一个执行逻辑。我已经有那么业务代码要写了,怎么还让我写测试代码?

针对这个尖锐的问题, 所以当务之急是搞一个框架出来,我们的程序员可以在一线希望的感召下实际地写出测试代码。这个框架要使用大家共知的思维方式,这样咱程度员不用再学习新东西,除了最核心的功能外,别的什么都不要。

如果上面的内容是测试工作的全部,我们只需要在debugger看写一个表达式手工验证下就Ok了。不过,这样的测试还远远不够。只是告诉我程序现在能工作,不能说明一切。咱写完后,可能一分钟后别人也会加入新的代码,情况怎样?五年后,你已经离职了,代码会怎样?

这样,测试的第二个任务是,写出的测试类要经得住时间的考验。除了当时写代码的人外,也能运行测试程序,并从测试结果中得出合理的判断。也需要把多人写的测试代码很方便地组合起来,而不是自己写的测试代码只能自己运行。

最后,我们也很希望使用已有的测试类创建新的,毕竟不能浪费咱前面的投入。

Junit的设计

关于设计思路的描述方式,我们借鉴”Patterns Generate Architectures”中引入的套路。其核心思想是,从先空白开始,抛出问题,再使用适合的设计模式化解,这样问题逐个解决,整个软件的架构也浮现出来。

入门类 TestCase

首先,得使用一个对象表达出测试中的基本概念,即TestCase。刚开始时,这些概念只有程序员的脑子里,可能有多个实际验证方式:

  • 使用System.out.print打印某些变量的结果,再肉眼观察。

  • 在debugger中写表达式判断下。

  • 针对眼下要测试的代码,短平快地写一个小脚本,用完后不再维护。

如果我们希望测试方便执行的话,需要使用具体的对象固化下来。这个对象封装了要测试的东西(刚开始时这些内容只在隐隐约约地在开发者脑袋中)后,使之具体化,随着时间推移测试目标也还能保存下来。同时我们也注意到,现在面向对象编程使用的很普遍,所以最终决定使用对象来封装测试可以跟现有的面向对象编程保持一致,从而更招人待见(最起码不那么强人所难)。

针对上面问题,Command模式很对口味。Command模式的设计意图是这样的,”把请求关联的属性封装成一个对象,这样我们可以对请求排队或记录日志….”.Command模式教我们使用对象把要操作的事封装起来,并给这个对象一个可执行的方法。下面是TestCase类的代码

JUnit 中的设计模式

因为我们期望这个类通过继承方式重用,我们把它声明成”public abstract”.现在可先忽略这个类实现了Test接口。就当前的设计因素来看, 我们可以简单地把TestCase看成一个独立的类。

每一个TestCase在创建时都有名字,这样测试失败后,可以方便地通过名字视别是哪个测试挂了。

JUnit 中的设计模式

为了记录Junit的设计演进过程,我们使用图的方式记录架构快照。记录方式也很简洁。对涉及的类,使用阴影框里对应的设计模式标注出来。当设计模式中类的角色很清晰时,只显示模式的名字。下图展示了描述TestCase情况。

JUnit 中的设计模式

Blanks to fill in- run()

下面要解决的问题是,给程序员一个方便地方,来存放脚手架代码(fixture code)和具体的测试代码。上面我们把TestCase定义成抽象的,意在说明程序员需要通过继承方式来重用TestCase。不过,如果TestCase中只有一个fName属性而没有可操作的方法时,不能满足咱在前面提到的第一个设计目标,即测试类写起来更方便轻松。

幸好,所有的测试都有一个通用的套路,提前搭建好测试环境(fixture),基于这个测试环境运行测试用例,检查测试结果,最后清空测试环境(fixture)。这样也就意味着,每一个测试用例都是基于一个崭新的测试环境运行的,某个测试用例的结果也不能影响别的测试用例的结果。这个问题对应的设计目标是,最大化地使用已有测试代码。

设计模式中的模板方法,可以很好地解决这个问题。模板方面的意图是这样的,”针对某个操作的具体步骤,在父类中定义执行顺序,具体怎么执行留给子类定义。模板方法允许子类可以重新定义具体步骤的实现,而不能改变这些步骤的顺序”。正好儿。我们希望程序员分开考虑怎么实现搭建测试环境的逻辑(set up和tear down方法)、怎么实现具体的测试用例。而整体的执行顺序对所有的测试来说, 都保持不变。

下面是模板方法的具体定义:

JUnit 中的设计模式

默认实现都是空的。

JUnit 中的设计模式

因为setUp和tearDown两个方法是用来overridden的,只能在Junit框架中调用,我们把这两个方法声明成protected。下面是第二版本的架构快照图。

JUnit 中的设计模式

利用TestResult,收集结果

如果众多个TestCase集中运行,我们怎么其中某一个的TestCase的运行结果?测试运行完后, 我们需要记录下,也就是涉及到多个测试用例的验证结果,哪些是运行成功了, 哪些运行失败。

如果只运行一个测试用例的话,我们可以在TestCase对象中设置一个标识,用于记录当前测试用例是否验证成功。不过,这个预想不成立。这样,我们就需要记录测试用例的运行结果,特别是关注那些失败的测试用例。

在Smalltalk Best Practice Patterns中有提到一个称为Collecting Parameter的模式, 可以解决当前问题。在我们需要收集多个方法的运行结果时,我们可以在这些被收集结果的方法中再添加一个参数,这个参数用于收集多个方法运行时的参数。这里,我们创建一个对象,TestResult,来收集测试用例的结果。

JUnit 中的设计模式

TestResult的最简单版本里, 只有一个数字,用来记录测试用例的个数。为了使用它,我们往TestCase.run()方法中添加一个入参,在测试用例运行时, 通知TestResult:

JUnit 中的设计模式

TestResult实现中, 再跟踪记录测试用例的运行个数:

JUnit 中的设计模式

这里TestResult的startTest方法,我们使用synchronized修饰,这样在多线程运行时,可以安全地收集结果。关于TestResult最后要说的是,我们需要TestCase对外提供简洁的方法,我们创建了新的没有入参的run()方法,在这个方法时, 新创建了自己的TestResult:

JUnit 中的设计模式

最终架构快照如下图所示:

JUnit 中的设计模式

如果所有测试用例问题运行正确的话, 我们根本不需要运行这些用例。测试用例只有在运行失败时(特别是没有按我们期望的方式失败时)才有意义。需要再细分的是, 测试用例可以按我们期望的方式失败,如计算得出一个坏结果;也可能会某种特别的方式失败,如运行时数组越界。不管测试用例失败的方式怎样,我们都想让它影响剩余的测试用例。

Junit框架中有区分failures和errors。Failures发生时是会有所预期的, 也会有使用assertions来检查。而Errors是未预期的问题,如ArrayIndexOutOfBoundsException。Failures就用AssertionFailedError表达的。为了区分非期望的问题,Failures是使用下图中1所示的方式捕获的, 而下图中2的方式捕获其它所有的异常,从而确保剩余测试用例能正常进行。

JUnit 中的设计模式

Junit中的AssertionFailedError是靠TestCase中的assert方法触发的。Junit内置了一整套assert方法, 以适应不同的应用场景。下面是最简单一个:

JUnit 中的设计模式

Junit不期望Junit的Client来捕获AssertionFailedError,而是在模板方法的TestCase.run()内部捕获。这里,我们将 AssertionFailedError类从JDK中的Error继承而来。

JUnit 中的设计模式

在TestResult中收集errors的代码如下:

JUnit 中的设计模式

上面提到的TestFailure是框架内部的一个小帮助类,使用它把失败的测试用例和运行时触发出来的异常关联起来,方便后续的测试用例结果汇报。

JUnit 中的设计模式

正统的使用collecting parameter模式的样子是,往每一个执行方法中传进collecting parameter。如果我们严格使用这种方式的话, 每一个测试方法会被这个额外的参数污染。使用捕获异常设计方案的话, 我们可巧妙地避免这个方法污染。在具体的测试用例方法(或被调用到的Helper方法)中,我们不用关心TestResult的存在。下面是Junit自带的MoneyTest,它演示,测试用例方法中,不需要关心TestResult。

JUnit 中的设计模式

Junit中自带了多个TestResult的实现。默认的实现是只记录测试用例的运行中failures和errors的个数,并收集结果。TextTestResult,除默认实现外, 还使用文本形式把结果呈现出来。最后,UITestResult会有图形界面的Junit Runner中使用, 以方便地更新图形界面中的显示效果。

TestResult是Junit框架中的一个扩展点。客户端可以据自己的使用场景定义新的类, 如 HTMLTestResult会把测试结果使用HTML方式呈现。

没用傻傻地使用子类继承,再谈TestCase

前面我们使用命令模式来封装表达测试用例。命令模式依赖特定的入口方法(在TestCase中叫run方法)为触发它执行。通过个整齐划一的方法, 在客户端可以很方便地触发不同实现。

我们需要一个通用的方法来触发所用的单元测试类。不过,单元测试用例在同一个类中使用不一样的方法定义。这样的定义可要有效地非必须类的增多。一个特定的测试类里, 可以定义多个不同的方法,每一个方法都可以实现特定的测试用例逻辑。每个测试用例都有一个含义贴切的命名,如testMoneyEquals或testMoneyAdd。这样的测试用例方法跟命令模式里单一的入口调用冲突了。同一个命令类的多个实例需要使用不一样的方法触发。这样,我们接下来的问题是,让这些定义(命名)形形色色的测试用例方法,使用统一的调用入口。

带着这个问题,再看所有设计模式的意图时,Adapter设计模式进入视野。Adapter的意图是这样的”把类的接口转换成客户端期望的调用方式”。这看起来很般配嘛。Adapter有多种实现方式,一种是定义一个Adapter类,它使用继承来转换成客户端期望的调用方式。如为了将testMoneyEquals方法适配成runTest方法, 我们定义一个MoneyTest子类,其中 override runTest方法,在这个方法中转调用testMoneyEquals。如下所示:

JUnit 中的设计模式

使用上面的方法,需要我们对每一个测试用例方法都有特定的继承子类。这个工作量对程序员来说是相当大的工作负担。这也跟Junit的设计目标相违背,即尽可能简单方便地添加测试用例。另外,每一个测试用例方法都创建一个新类,会让类数量膨胀的无法收拾。即便是只有一个方法的测试类也不值得使用这样的方式,它会搞乱测试类的命名。

Java提供了匿名内部机制,使用它我们可以方便地解决上面提到的类命名问题。使用匿名内部类,我们可以创建一个Adapter类,而不需要给它起名字,如下所示:

JUnit 中的设计模式

跟全量继承相比,上面的方法可以轻便很多。使用此方法,还保留了可贵的编译检查机制。针对此问题,Smalltalk Best Practice Patterns还有另一种解决方式,pluggable behavior。简要来说是,通过参数化,让一个类执行不一样的逻辑。

最简单实现pluggable behavior的方式是Pluggable Selector。Pluggable Selector的思想是,使用一个实例变量来持有一个Smalltalk的方法选择器。这种想法不单单是Smalltalk自己有。在Java中也可以实现。Java中,没有方法选择器的现成概念。不过,使用Java反射API,我们可以方便地按String指定的名称来调用对应的方法。下面我们使用Java反射机制来实现pluggable selector。随便说下,尽管大多数业务场景下, 不建议使用Java反射,不过现在为了实现一个基础设施框架,我们很有必要祭出Java反射这个大杀器。

Junit给客户端提供了 pluggable selector和匿名内部类两种方式来实现Adapter。这里,我们使用pluggable selector作为默认实现。本例中, 测试用例的命名需要跟方法名保持一致。我们使用下面的方式来达到Adapter的效果。先找到Method对象,找到后,再传参触发这个方法执行。由于我们的测试方法定义中没有入参,入参是一个空数组。

JUnit 中的设计模式

JDK1.1的反射机制中, 只能调用public的方法。出于这个考虑,我们在定义测试用例方法时,需要声明成public的,否则会抛出NoSuchMethodException异常。

这样添加了Adapter和Pluggable Selector后,我们得到下面的架构快照。

JUnit 中的设计模式

咱不关心是一个还是多个,TestSuite

为了对整个系统质量有足够的信心, 我们需要运行大量的测试用例。到目前为止,Junit可以运行单个的测试用例,并使用TestResult来生成测试报告。下一个挑战是,再扩展下,让Junit可以运行多个不一样的测试。面对这个问题,如果调用方不必区分测试是一个还是多个时, 咱就可以轻松解决了。针对这个问题,流行的解决方式是,Composite模式。Composite模式的设计意图是这样的”将多个对象组合成树型结构,来表达part-whole层级关系。使用Composite,客户端可以不做任何区分地对待单个对象和多个被组合起来的对象”。其中的part-whole层级关系是我们最关心的。我们想实现套娃一样的层级关系。

Composite模式有下面的这些参与者:

  • Component:定义了被调用的接口。

  • Composite:实现Component定义的接口,内部持有一组Component实现。

  • Leaf:代表Composite中具体的Component实现。

遵循这样的理念,我们可以定义一个抽象类,此抽象类中定义了通用的接口方法,这个方法在单一和组合的对象中都有实现。这里最核心的目标是定义一个接口。Java中, 实现Composite模式时,我们优先选择定义接口,而不是抽象方法。使用接口, 可以避免每个测试类都要继承特定父类。接下来要做的,就是让所有的测试类都实现这个方法。下面我们使用接口来看看Composite模式在Junit中的实现。

JUnit 中的设计模式

TestCase对应Composite中的Leaf,需要实现上面定义的接口。

接下来,我们看Composite模式中的Composite怎么定义出来。我们将此命名为TesSuite。在TestSuite中, 使用Vector持有所有的测试。

JUnit 中的设计模式

TestSuite的run方法里,再委托给Vector中的每一个测试类。

JUnit 中的设计模式

JUnit 中的设计模式

最后,客户端可以调用addTest方法往suite中添加测试,

JUnit 中的设计模式

留意下,上面代码中, 怎么达到只依赖Test接口的。由于TestSuite和TestCase都遵循Test接口定义,我们可以递归地组合多层suite。程序员可以创建自己的TestSuites。而在调用时, 我们可以创建一个新的TestSuite收集所有的suites,打包执行。

下面是具体的例子:

JUnit 中的设计模式

这种定义可以很好执行,不过,需要我们再手动地添加所有的测试类。这种方法还是傻乎乎的。咱们每写一个新的测试类,都不能忘了使用静态的suite方法添加到Composite中, 否则新加的这个测试类就不能执行。后来我们给TestSuite新加了简便的构造方法,这个构造方法的入参是被测试的类。目的是自动提取定义的测试方法,并创建一个Suite来把它们包起来。测试方法需要遵循一个简单的约定:方法命令以test开始,且不能有参数。在新的构造方法中,是使用反射获得所有测试方法的。借助这个新的设计后, 上面的代码精简成下面的样子:

JUnit 中的设计模式

在想定制测试范围时,原始的方法还很有用。

汇总一下

至此,我们快到到末尾了。下图汇总了刚才前面详细解释的设计模式。

JUnit 中的设计模式

留意上图中TestCase,它也是框架的核心抽象,涉及到四个模式。Pictures of mature object designs show this same “pattern density”. The star of the design has a rich set of relationships with the supporting players.

还有另一种解决Junit中使用设计模式的方式。下图中,我们可以按个看到每个设计模式的实际效果:借助Command模式引入了TestCase类,模板方法模式引入了run方法,等等。

JUnit 中的设计模式

本文译自:http://junit.sourceforge.net/doc/cookstour/cookstour.htm

---------

往期推荐

  1. "雪人"令狐冲听音辨形与模式

  2. 设计模式有什么用?

  3. 设计模式价值的代码实战

  4. 从冠状病毒肺炎抢救中的钟南山院士看IT架构师的成长方向

~~~~~~~~~~~~~

JUnit 中的设计模式

长按二维码,关注公众号

一起推进电商业务信息化

原文  https://mp.weixin.qq.com/s/nKpEB0_Wmb96lCFiH14WYw
正文到此结束
Loading...