提出问题,解答问题!这才是理解代码设计的正确方法

上一篇我们通过调用关系,梳理出了TestRunner调用核心模型的流程。

本篇是《如何高效阅读源码》专题的第十一篇,我们来回答流程梳理中遇到的一些问题,思考为什么要这么设计。

上一篇我们提出了几个问题:

  • 为什么使用Statement类?作用是什么?

  • RunNotifier如何进行监听的?

  • classBlock方法中,if判断里的逻辑是干什么用的呢?看方法名好像和BeforeClass、AfterClass注解有关系,它是怎么处理的呢?

  • 为什么要用Statement封装一层来执行测试?所有的方法都在ParentRunner类里面,直接调用不就好了吗?

  • runChildren方法中为什么这里要构建一个Runnable来执行呢?

本节将来回答这些问题。

Statement的作用

其实,如果你熟悉设计模式,你应该能立刻认出来,Statement实现的是个命令模式。

而命令模式的作用是什么呢?将一个请求封装为一个对象,从而使你可用不同的请求对客户进行参数化;对请求排队或记录请求日志,以及支持可取消的操作。
这也就是Statement的作用。Statement通过一个统一的对象来执行不同的测试行为
那为什么要用命令模式呢?

我们回想一下,我们在执行测试的时候,每个测试的执行流程是不是并不完全相同?有的时候我们执行单个测试方法,而有的时候我们可能执行一个测试类。同时每个测试方法的执行流程也不完全相同,有的方法有Before方法或After方法要执行,而有的没有;有的方法有BeforeClass和BeforeClass要执行,其它方法则没有。这些不同的行为都是通过Statement来封装。

那Statement具体是怎么封装呢?我们回过头来看classBlock方法:

首先childrenInvoker方法(见下图)直接构建了一个Statement的匿名实现类,来包装执行类里所有符合条件的测试方法。
接着通过if判定里的四个方法添加额外的执行逻辑。

 

 

限于篇幅,我们就只看第一个withBeforeClasses方法,看这个名字,我们应该能猜到这个方法是为了处理被BeforeClass注解的方法的。

在看它的实现之前,我们可以想想,如果是我们来实现的话?我们该怎么实现呢?或者我们可以换个问法,有没有什么方式能够保证一个方法在另一个方法之前执行?你有没有什么思路呢?(在向下看之前,最好自己先思考一下)

比如,我们可以使用一个Statement包装类,也就是使用装饰模式。在执行这个Statement之前,先执行BeforeClass;或者我们也可以使用组合模式,构造一个父Statement,BeforeClass和原来的Statement作为叶子节点,不过此处要注意顺序。

现在我们来看看JUnit里面是怎么实现的呢?

首先,通过TestClass对象的getAnnotatedMethods方法找到所有有BeforeClass注解的方法。如果没有对应注解的方法就直接返回原Statement,否则就构建一个RunBefores对象返回,很明显这个RunBefores也是Statement的子类。

我们来看这个RunBefores类是如何实现来保证具有BeforeClass注解的方法先于Test注解的方法执行的。

注意evaluate方法,首先先遍历执行了BeforeClass注解的方法,然后执行了测试方法Statement对象的evaluate方法。
显而易见,这里使用的是装饰模式。

装饰模式:动态地给一个对象添加一些额外的职责

也就是说,JUnit通过装饰模式动态的给测试方法添加了额外的职责。相信其它的方法不需要看你也能大概知道是怎么实现的了吧?

RunNotifier的作用

RunNotifier的实现就更加的显而易见了:观察者模式!

观察者模式:定义对象间的一种一对多的依赖关系,当一个对象的状态发生改变时,所有依赖于它的对象都得到通知并被自动更新。

使用观察者模式的作用就是解耦测试的执行与测试结果的展示。我们来看一下JUnit具体是怎么做的。

我们接着上面的childrenInvoker方法往下看,childrenInvoker里构建了一个Statement,实际调用的是ParentRunner里的runChildren方法。

这里为什么要用线程来执行呢?其实理由很简单,这里执行的是一个个的测试方法,每个测试方法之间是没有关系的,所以这里使用线程,可以提高测试的执行效率。

注意:虽然每个测试方法是独立的,但是结果Listener是公用的,那这里就涉及到了竞争问题。JUnit是如何保证线程安全的呢?这里留给大家去源码里查找答案。提个醒,先了解下CopyOnWriteArrayList

线程方法里执行的是runChild方法,而这是一个抽象方法,由子类实现。它有两个实现类,一个是BlockJUnit4ClassRunner类,一个是Suite类,很明显Suite是用来执行一批测试方法的。这个关系是组合模式的套路!Suite和BlockJunit4ClassRunner之间使用的肯定是组合模式。如果不信可自行验证,这里就不再梳理了。

我们这里直接看BlockJUnit4ClassRunner的runChild方法。

注意其中的methodBlock方法,回想一下上面的classBlock方法,能猜出来这里的逻辑吗?
最后到runLeaf方法,也就是最终执行的方法,我们来看notifier具体做了哪些工作。

这里相当于对statement执行的开始、结束和报错阶段进行了监听,调用了不同的方法。而这些方法,最终委托到了注册到TestNotifier的TestListener了。比如addFailure方法,最终调用的是TestListener的testFailure方法。

现在我们只要看一看哪些类继承了TestListener类,就能知道测试结果有哪些处理方式了。(后文会从此处将整个执行流程串联起来
我们以最简单的TextListener为例。

 

 

可以看到,这里只是简单的将其输出到命令行。
如果你想要其它的结果处理方式,你只需要编写一个类实现TestListener类即可。
我们反过来想一下,如果不使用监听器模式,这里的测试执行和结果处理是否就耦合到了一起,且没有扩展性呢?

总结

在本文中,为了回答上文提出来的问题,我们对核心代码流程进行了代码级别的梳理,并理解了为什么要这么设计,如果不这么设计又会带来什么问题。

同时,你应该也体会到了,熟悉设计模式能极大的提高阅读源码的效率。假设你不了解设计模式,你就需要先理出类之间的关系,比如上面的RunNotifier和TestListener之间的关系,然后思考为什么要这么设计,同时可以到网上找资料确认这两者的关系,以学习观察者模式。当下次再看到类似结构的时候,能快速的理清逻辑。

下文我们将结合Spring来梳理JUnit的Runner,完成整个测试流程的最后一块拼图。

posted @ 2022-05-14 10:19  一瑜一琂  阅读(384)  评论(0编辑  收藏  举报