JUnit 新实例
2004 年 8 月 24 日
我经常收到一些关于 JUnit 测试框架中一个设计选择的问题 - 决定为每个测试方法运行创建一个新对象。足以保证一个简短的 bliki 条目。(然而,我几乎觉得有必要指出,我关于 JUnit 的写作并不意味着我认为其他形式的测试不重要。有很多有用的测试活动,尽管 JUnit 及其同类产品对其中许多活动很有价值,但它并不是万能的解决方案。有关更多关于测试的博客,我建议您查看 Brett Pettichord、Brian Marick 和 James Bach 的博客。您也不应该假设我关于 xUnit 测试的写作暗示了重构、用例或牙线的重要性。)
考虑以下简单的 Java 测试类
import junit.framework.*; import java.util.*; public class Tester extends TestCase { public Tester(String name) {super(name);} private List list = new ArrayList(); public void testFirst() { list.add("one"); assertEquals(1, list.size()); } public void testSecond() { assertEquals(0, list.size()); } }
有些人可能没有意识到这一点,但这两个测试都通过了 - 并且无论以何种顺序运行都会通过。这是因为要运行此 JUnit,它会创建两个 Tester 实例,每个 testXXX 方法一个。因此,list 字段在每次测试方法运行时都会被重新初始化。现在有些人认为这是一个 JUnit 中的错误,但事实并非如此 - 这是一个经过深思熟虑的设计决策。(有关此类内容的更多信息,请关注 Kent 的新书。)
JUnit 的基本设计起源于 Kent Beck 在 Smalltalk 中构建的测试框架。(实际上,称之为框架有点名不副实 - Kent 从未将其作为框架发布。他更希望人们自己构建它,因为这只需要一到两个小时 - 这样他们就不会害怕在想要改变时改变它。)JUnit 中的一个关键原则就是隔离 - 也就是说,任何测试都不应该做任何会导致其他测试失败的事情。
隔离提供了几个优势。
- 任何测试组合都可以以任何顺序运行,并获得相同的结果。
- 您永远不会遇到这种情况:您试图弄清楚为什么一个测试失败了,而原因是另一个测试的编写方式。
- 如果一个测试失败,您不必担心它会留下导致其他测试失败的残留物。这有助于防止隐藏真实错误的级联错误。
现在 JUnit 提供了其他支持隔离的机制 - 特别是setUp
和tearDown
方法,它们在每个测试方法的开始和结束时运行。要将此用于我的简单示例,您需要执行以下操作。
public void setUp() { list = new ArrayList(); }
大多数情况下,您不需要使用tearDown
,因为setUp
可以执行您需要的任何重新初始化。
您可以通过将所有状态都放在局部变量中而不使用字段来隔离您的测试方法。但是,这意味着在每个测试中都要复制您的setUp
代码 - 而且您知道我有多么讨厌 重复。
JUnit 方法的批评者认为,既然您有setUp
和tearDown
,您就不需要每次都使用一个新对象。您只需确保在这些方法中重新初始化所有字段即可。JUnit 方法的支持者认为这可能是真的,但许多人会在字段中进行初始化,而且您不妨提供这种更大的隔离程度。毕竟,框架设计的一个重要部分是让做正确的事情(隔离)变得容易,而让做会导致问题的事情变得困难(但并非不可能)。毕竟,这样做有什么成本呢?
关于 JUnit 方法成本的主要论点是基于创建的额外对象,包括 JUnit 测试用例以及在设置和字段初始化器中创建的所有其他对象。大多数情况下,我认为这种论点是站不住脚的。人们对创建大量对象有很多恐惧,但大多数情况下 这是没有道理的 - 它基于一个过时的关于对象分配和收集工作方式的心理模型。当然,在某些环境中,对象创建可能是一个问题,而 Java 在早期也是其中之一。但是,现代 Java 可以几乎没有开销地创建对象,它不再是一个问题。(在 Smalltalk 中,这种情况已经存在很长时间了,这就是为什么 Kent 和 Erich 不担心它。)所以,大多数情况下,不要担心创建对象。
也就是说,大多数情况下并不意味着总是如此。一个很好的例子是您不想频繁创建的数据库连接。这确实有意义,但将其共享到一个测试用例类中的所有测试方法中是不够的 - 您需要将其共享到更多地方。一种廉价且糟糕的方法是使用静态变量。通常,明智的做法是避免使用静态变量,但它们在测试运行的上下文中通常是可以的 - 尽管我仍然更喜欢避开它们。JUnit 实际上提供了一种非常灵活的机制来共享测试夹具对象 - TestSetup 装饰器。这允许您为任何测试套件设置一些通用状态,这为您提供了更多关于跨测试组共享状态的灵活性 - 比仅跨单个测试用例类中的方法共享状态要多得多。
也许 TestSetup 最大的问题是,找到有关它的信息非常困难,以至于我几乎期望在文档中看到“当心豹子”。而且周围确实有一只豹子 - 如果你使用 TestSetup,你就会破坏隔离,而破坏隔离往往会导致难以发现的错误。除非你真的、真的需要它,否则不要使用它。(但如果你确实需要它,这个论坛帖子 提供了一些关于使用它的提示,J.B. Rainsberger 的 新书 也是如此。)
(所有这些可能会让你想知道为什么每个测试方法不在它自己的类中。事实上,JUnit 的最早形式确实是这样做的,使用一个内部类,该内部类继承了带有夹具的测试用例。虽然这是一个更明显的设计,但它使编写测试变得更加困难。因此,他们选择了更模糊的 可插拔选择器 模式的使用。)
对 JUnit 方法的第二个反对意见是它不直观 - 因为它使用的实现机制很难理解。我对此表示同情,可插拔选择器模式并不为人所知,而使用不熟悉模式的设计风格往往让人感到不舒服。总的来说,我喜欢 JUnit 方法,因为我认为隔离和易于编写测试的优势超过了深奥的实现。
但好人不同意我的观点。Cedric Beust 的 TestNG 并没有这样做,也许更令人惊讶的是,流行的 NUnit 实现也没有这样做(尽管 Jim 现在后悔了这个决定)。以下 NUnit 测试会导致失败。
[TestFixture] public class ServerTester { private IList list = new ArrayList(); [Test] public void first() { list.Add(1); Assert.AreEqual(1, list.Count); } [Test] public void second() { Assert.AreEqual(0, list.Count); } }
如果您使用的是以这种方式工作的框架,我强烈建议您在设置方法中初始化所有实例变量。这样,您就可以保持测试的隔离,并避免一些导致脱发的调试。
我碰巧不同意重用测试用例实例 - 但我认为做出这个决定的人并没有单一的智商,也没有什么复杂的金融谋杀计划,或者正在对他们的下半身进行一些奇怪的行为。他们对设计权衡做出了不同的选择 - 我认为,当我们能够在软件设计的流动性方面心存敬意地表达不同意见时,生活会变得更好。