金氧

你的单元测试有多稳定?提升自动测试质量的最佳实践

我们超过10K的单元测试大部分都是用JAVA的JUnit编写,并且用gradle自动构建工具运行。当我们添加越多的测试用例,就越频繁地遇到单元测试执行不稳定的问题。新添加的测试代码影响了现存的测试的执行。我们的”失败测试“(failed test)标准在它开始增加前一直表现地很不错。显然我们应该去抱怨那些糟糕的程序代码。但经过仔细的分析,我们发现造成不稳定的测试结果的真正原因。大多数由新测试造成的问题都是由于那些测试用例对测试环境作了一些不利的影响,因此也影响了其他测试的执行。

在这篇文章中,我会展示我们是怎么找出这些特定的失败测试的根因,并且由此得出的对测试环境友好的单元测试设计的最佳实践。

这一切都因为缺少单元测试时间

上个星期,我们发现一组测试没有毫无预兆就失败了。我自愿参加分析。

一组验证ThreadPool实现的重要且必须行为的单元测试。特别是,定时任务必须在特定异常抛出后继续运行。为了测试它,必须要有另外一个定时任务,抛出特定的异常。然后测试会在第一次因为异常失败后等待第二次执行。

在某些机器上运行这些测试,它们会在重新运行时超时。尽管有各种各样的异常处理器可以记录异常,但没有任何输出。只有这样一条消息输出:

Exception: java.lang.NullPointerException thrown from the UncaughtExceptionHandler in thread “pool-unit-test-thread-1″

然后,在eclipse中运行时,这种情况永远都不可能重现,它只会出现在我们使用gradle来跑这些test的时候。

下一步:我配置gradle打开调试端口,然后使用eclipse连接以确定原因。这才发现NullPointerException是在gradle的某处代码中抛出。我下载了源代码,发现System.getProperty(“line.separator”)返回null,并且被取消引用。

有了这些信息,我检查代码,并且迅速发现另外一个校验不同平台上的字符串格式的测试在修改line.separtor属性时有不良影响。在测试完成后调用的System.clearProperty(“line.separator”),它无意中把该属性设置为null。在这种情况下,当下个测试运行时,由于它使用System.setOut重定向到控制台输出,日志信息会被写出到控制台,gradle代码会被调用。这种顺序的调用会导致gradle在获取line.separator属性值是抛出NullPointerException。注意,执行的顺序很重要,因此在ThreadPool测试时只是偶尔会出现。

通常一系列的处理器都可以捕获到它,但由于它们也写出到控制台,当它们遇到NullPointerException时,会向上抛出。

这个错误的快速的解决方式是去除clearProperty这个调用,而换用上一个line.separator属性的值。但我们是否可以做到更好呢?

单元测试的副作用

上面的部分错误是本来并且应该由测试自动化去处理的,JVM通常会重用同一个项目内的对象。因此理想情况下的单元测试应该对测试环境没有副作用,可以避免类似的错误。

测试环境包括各种可以影响之后执行的测试的资源,如在文件系统中创建文件或者上面的情况,修改java系统属性。对于文件类的,Junit提供了TemporaryFolder规则用于创建临时文件,所以我们这里开发了一个属性的类似机制。

解决方案:单元测试中恰当使用系统属性

通常情况下,假设我们要开发一个测试用例,需要设置Java系统变量为一个特定值,可能会如下:

Test.java

@Before
public void setup() {
  System.setProperty("prop", "true");
}
@Test
// use that prop.

但当其他在其之后运行的测试都运行在同一个JVM下,这个属性值还是会保留被设的值,并且会影响在后面运行的测试用例行为。所以,让我们来改进一下:

Test.java

private String oldValue;
@Before
public void setup() {
  // setProperty returns the old value of that property.
  oldValue = System.setProperty("prop", "true");
}
 
@After
public void teardown() {
  System.setProperty("prop", oldValue);
}
@Test
// use that prop..

看起来挺不错吧,不是吗?但还是存在一个问题:如果属性值没有设置,在setup中的setProperty返回null,而teardown会抛出一个NullPointerException。所以正确的使系统属性维持不变的单元测试应该是:

Test.java

private String oldValue;
@Before
public void setup() {
  // setProperty returns the old value of that property.
  oldValue = System.setProperty("prop", "true");
}
 
@After
public void teardown() {
  if( oldValue == null ) {
    System.clearProperty("prop");
  } else {
    System.setProperty("prop", oldValue);
  }
}
@Test
// Use that property..

这会有点啰嗦..基于Java 7的try-with-resources 语句的帮助类实现了同样的功能:

Test.java

private ScopedProperty property;
@Before
public void setup() {
  property = new ScopedProperty("prop", "true");
}
@After
public void teardown() {
  // Does same things like above.
  property.close();
}
@Test
// use that prop..

或者:

Test.java

@Test
public void test() {
  try(ScopedProperty prop = new ScopedProperty("prop", "true")) {
    // use that prop..
  }
}

当然,最好还是直接实现Junit规则,在这种情况下你不需要写@After。例如:

Test.java

@ClassRule
public static ScopedPropertyRule prop = new ScopedPropertyRule("prop", "true");
@Test
public void test1() {
 // use that prop..
}
@Test
public void test2() {
// use that prop..}

属性在@BeforeClass中进行设置,但会在@AfterClass中恢复成原始的状态。

结论:无副作用的测试可以提升自动化测试质量

要避免由于执行顺序引起的测试错误,单元测试必须做到没有副作用。现实世界的例子中证明这个话题的关联性。更深入这个练习,我们开发了一些用于系统属性的帮助类。相同的原理同样适用于其他环境对象,例如线程属性,Java安全管理器和类似ScopedProperty和ScopedPropertyRule之类的类,这些都可以很容易的实现。当我们在不断努力提升自动测试测试质量时,我对其他最佳实践保持兴趣。给我们留言,说一下你在开发团队里面的工作。(感谢Reinhold Füreder对Junit规则的引入!)

附录

ScopedProperty.java

/**
  * A helper to switch a system property value and restore the previous one.
  *
  * When used in try-with-resources, restores the values automatically.
  */
public class ScopedProperty implements AutoCloseable {
 
    private final String key;
    private final String oldValue;
 
  /**
     *
     * @param key The System.setProperty key
     * @param value The System.setProperty value to switch to.
     */
    public ScopedProperty(final String key, final String value) {
        this.key = key;
        oldValue = System.setProperty(key, value);
    }
 
    @Override
    public void close() {
        // Can't use setProperty(key, null) -> Throws NullPointerException.
        if( oldValue == null ) {
            // Previously there was no entry.
            System.clearProperty(key);
        } else {
            System.setProperty(key, oldValue);
        }
    }
}

这里的ScopedProperty实现了AutoCloseable来完成try-with-ressouces语句

ScopedPropertyRule.java

/**
 * A JUnit test rule, which changes a property value within a test
 * and restores the original one afterwards.
 * See {@link ScopedProperty}.
 */
public class ScopedPropertyRule extends ExternalResource {
 
    private final String key;
    private final String value;
    private ScopedProperty scopedProperty;
 
    public ScopedPropertyRule(final String key, final String value) {
        this.key =key;
        this.value = value;
    }
 
    @Override
    protected void before() throws Throwable {
        scopedProperty = new ScopedProperty(key, value);
    }
 
    @Override
    protected void after() {
        scopedProperty.close();
    }
}

这个类使用了ExternalResource来实现Junit测试规则

原文链接: compuware

« Java Web 框架开发基础 使用 Golang 操作 Linux Namespaces »