TestNG 并发运行相关的核心概念

翻译自:http://beust.com/weblog/2009/11/28/hard-core-multicore-with-testng/

限于翻译水平有限,有兴趣的同学可以直接看原文。翻译上如果有什么不妥之处,还麻烦指正,感谢!


---------------------------------------------------正文的分割线------------------------------------------------------------

TestNG 并发运行相关的核心概念_第1张图片

近期我实现了TestNG中的一个新特性,该特性有趣地将图论和并发结合在了一起。


问题描述

TestNG允许你声明测试方法之间的依赖性关系,这是一个简单的例子:

@Test
public void a1() {}
@Test(dependsOnMethods = "a1")
public void b1() {}
@Test
public void x() {}
@Test
public void y() {}

在这个例子中,b1()直到a1()成功结束之后才会运行。如果a1()失败了,b1()会被标注为跳过。在这篇文章中,我称a1()b1()方法为依赖方法,而x()以及y()为独立方法。


当你想让这四个方法以并行的方式运行时,事情就变的有趣了。在你指明这些方法应该在一个拥有三个线程的线程池中运行的时候,TestNG仍需要保持方法a1()b1()之间的顺序。TestNG通过将所有存在依赖关系方法放在同一个线程中运行来满足要求,这样做既能够保证它们的运行不会重叠,同时也保障了它们的运行顺序也被严格遵循了。


因此,目前的算法是简单的:

  • 将所有的测试方法分为两个类别:独立方法,依赖方法。
  • 独立方法会被放在一个线程池中,然后由Executor来运行,一个方法对应一个worker,这样做能够保证最大的并行度。
  • 依赖性方法会被排序,然后由一个只含有一个线程的Executor来运行。

以上就是工作了超过5年的调度算法。它做的不错,但是并不是最佳的。


依赖性方法这个概念是TestNG中十分受欢迎的一个特性,特别是受到类似Selenium这样的Web应用测试框架的青睐,对web应用中页面的测试十分依赖操作的先后顺序。这类测试通常包含了大量的依赖性方法,这意味使用当前的调度算法很难利用任何的并行。


比如,考虑下面的例子:

TestNG 并发运行相关的核心概念_第2张图片


因为四个方法都是依赖性的,无论线程池的大小,它们都会被放在相同的线程中运行。然而很明显的是,a1()b1()应该被放到一个线程中,而a2()b2()则应该被放到与前面不同的一个线程中。


但是为什么不考虑的更多一些,去想想如果我们不能使用主线程池(译注:即需要我们自己设计相应的Executor),但是仍然需要遵循它们运行顺序,会如何来实现。这个想法让我更仔细的了解了目前JDK中可用的并发工具,具体而言,就是Executors。


我的第一个问题是,向Executor中添加workers而不让添加的workers立即运行是否可能,但是我意识到这个想法和Executors的原则相违背了,所以我放弃了这个想法。(译注:这个想法其实就是后面的“执行计划”的实现细节,即在启动Executor之前设计好所有的workers,因为“执行计划”的创建是不靠谱的,因此这里也不可行了,当然,和Executors的原则相悖又是另外一个佐证了)


另外一个想法是,在启动Executor的时候,只启动一部分workers,然后在Executor运行的时候向其中添加更多的workers,这样做看起来是合法的(或者至少是,没有被明显禁止的)。查看了现有的资料,在我看来Executors一般不会修改属于它们自己的workers。它们被初始化之后,外部的调用者能够通过调用execute()方法而向其中添加workers(译注:后文的动态性在实现细节上的体现,在Executor执行的过程中,对workers进行添加)


此时,解决办法已经非常清楚了,但是在进入细节之前,我们需要更进一步的了解排序。(译注:即如何给依赖性方法进行排序)


拓扑排序

在开篇的例子中,我介绍了TestNG会在执行测试方法之前对它们进行排序,但是我并没有解释到底如何排序。实际上,我们需要一个和你熟悉的那些排序算法稍有不同的算法来进行排序。


回头看第一个例子,显然有不止一种正确的执行顺序:

  • a1() b1() x() y()
  • x() a1() b1() y()
  • y() a1() x() b1()

简而言之,任何将a1()的执行放在b1()之前的顺序都是合法的。我们在这里做的实际上是对一些不可比较的元素进行排序。换言之,如果我随机地挑选两个方法fg,然后我让你来比较它们,你的答案要么是f必须在g之前运行,或者是g必须在f之前运行,或者是不能比较(比如,在上面的例子中,让你来比较a1x)


这种排序就叫做拓扑排序。这个链接中的内容详尽的介绍了拓扑排序的内容,但是如果你比较懒,知道有两种算法用来实现拓扑排序也就足够了。(译注:一种是基于Kahn提出的方法,还有一种基于DFS的方法,实际上如果你想了解这两种方式,还是需要去看那个链接^_^)


让我们来看拓扑排序在一个简单例子上的执行情况。

下图描述的是一些测试方法和它们之间是如何互相依赖的。绿色的方法是独立的方法:它们不依赖于任何其他的方法。箭头表示依赖关系,点划线的箭头则表示已经被满足的依赖关系。最后,灰色的节点代表已经执行完毕的方法。

TestNG 并发运行相关的核心概念_第3张图片

第一轮迭代,我们有四个独立方法。这四个方法已经准备好被执行了。

目前的结果:{ a1, a2, x, y }


TestNG 并发运行相关的核心概念_第4张图片

这四个方法执行完毕之后,会让两个新的方法变为独立状态,即b1b2,在下一轮的执行中,它们作为候选方法。注意对于方法d,它的一个依赖关系已经被满足了(a1),但是d仍然依赖于方法b1的执行,所以它还不是独立方法。

目前的结果:{ a1, a2, x,y, b1, b2 }


TestNG 并发运行相关的核心概念_第5张图片

b2b1执行结束之后,又有另外三个方法变成独立方法了。


TestNG 并发运行相关的核心概念_第6张图片

最后三个方法执行完毕之后,运行结束。

最终的结果:{ a1, a2, x, y, b1, b2, c1, c2, d }

注意以上并不是唯一合法的拓扑排序。你可以在满足依赖关系的前提下,任意的调整方法的执行顺序。比如,将上面最终结果中的a1a2调换为a2a1也是正确的。


以上是一个非常静态和理论化的例子。在TestNG中则表现的更加动态,当一个方法结束之后,整个运行环境都需要被重新评估。这个算法的另外一个重要方面在于,在所有的独立方法准备好被执行的时候,它们都需要被添加到线程池中,这就意味着ExecutorService即使在运行其他的workers的时候,也需要将这些准备好的方法对应的workers也加入其中。(译注:即需要能够支持动态添加新的workers)


比如,让我们回到下面的状态:

TestNG 并发运行相关的核心概念_第7张图片

在这个阶段,我们有两个方法被加入到了线程池中,它们应该被两个不同的线程运行:b1b2。然后,根据哪个方法首先完成,我们会得到两种不同的情况:


TestNG 并发运行相关的核心概念_第8张图片

b1先完成,使c1d变成独立方法。


TestNG 并发运行相关的核心概念_第9张图片

b2先完成,不会让任何方法变成独立的。


一种新的Executor

在早期, TestNG 中的方法执行模型是高度动态的:运行什么方法,以及何时运行它,都是在随着测试的进行而决定的。在测试运行的之前创建一个“测试计划”是我考虑过的几种改进措施中的一个。一个测试计划意味着执行引擎会寻找测试类中所有带有 TestNG 注解的方法,然后根据这些注解信息构造出一个执行计划,该计划描述了所有需要被执行的方法。这个执行计划可以交给一个 runner ,然后由它负责具体的运行。


理解以上的场景之后(译注:即上面图例中说明的,方法执行的顺序不同会影响后续的执行顺序),我发觉关于“测试计划”的想法注定是要失败的。如果考虑TestNG动态的一面,那么指望通过在初始化阶段检查所有的测试类,然后得到一份执行计划,是不可能的,因为正如我们在之前看到的,方法运行的顺序也取决于其依赖方法执行结束的顺序。一份测试计划只会让TestNG更加静态,然而我们需要的恰恰是静态的反面:我们希望TestNG在调度方法上比现在更加动态。


有效的解决这个问题的唯一途径是:每当一个测试方法完成之后,重新评估整个执行流程。幸运的是,Executors能够定义一个回调方法,每当一个worker完成任务之后会调用它,因此把重新评估的逻辑放在回调方法里就再完美不过了。我的下一个问题是,当一个Executor正处于运行状态的时候,向其中添加workers是否合法(答案是:合法)


以下是新Executor的概览,Executor的构造方法中接受一个含有测试方法的graph对象,然后就围绕着下面两个方法运行:

/**
* Create one worker per node and execute them.
*/
private void runNodes(Set<ITestNGMethod> nodes) {
  List<IMethodWorker> runnables = m_factory.createWorkers(m_xmlTest, nodes);
  for (IMethodWorker r : runnables) {
    m_activeRunnables.add(r);
    setStatus(r, Status.RUNNING);
    try {
      execute(r);
    }
    catch(Exception ex) {
      // ...
    }
  }
}

第二部分是在一个测试方法运行结束后重新评估下一步需要执行的测试方法(译注:即新产生的独立方法)

@Override
public void afterExecute(Runnable r, Throwable t) {
  m_activeRunnables.remove(r);
  setStatus(r, Status.FINISHED);
  synchronized(m_graph) {
    if (m_graph.getNodeCount() == m_graph.getNodeCountWithStatus(Status.FINISHED)) {
      shutdown();
    } else {
      Set<ITestNGMethod> freeNodes = m_graph.getFreeNodes();
      runNodes(freeNodes);
    }
  }
}

当一个worker完成之后,Executor会更新graph的状态。然后它检查是否已经运行了所有的方法,如果还没有,它会要求graph给出最新的独立节点,然后调度运行这些独立节点。


结束讨论

以上就是新实现的 TestNG 调度引擎的一个大体描述。我忽略了一些 TestNG 中具体的实现细节,目的是想将焦点放在一般性的概念上。但是整体而言,实现这么一个新的引擎被证明是相当直接的,这得感谢 TestNG 的分层架构。


有了这个新的实现,TestNG能够给予测试最大化的并发度,一些Selenium的用户也反映他们的测试获得了巨大的提高(时间消耗上从一个小时降低到了十分钟)


当我使用这个新的引擎来跑一些测试的时候,很少的测试会失败,而且这些失败的也是我想让它们失败的(比如验证依赖方法在相同的线程中运行,这正是在新的引擎中进行修正的功能(译注:新的引擎中就是要避免过度的顺序执行,即依赖方法不见得会在同一个线程中执行了))。类似地,我添加了一些新的测试用来验证在新的引擎中依赖方法和独立方法也会共享线程池了,这个功能实现起来不复杂,因为已经存在很充分的支持了。


新的引擎在TestNG 5.11中可用。


你可能感兴趣的:(exception,算法,测试,Graph,selenium,引擎)