编写第一个JUnit测试

10

我阅读了官方的JUnit文档,其中包含许多示例,但(像许多其他东西一样)我已经启动了Eclipse并编写了我的第一个JUnit测试,并且我在一些基本的设计/概念问题上遇到了困难。

所以如果我的WidgetUnitTest正在测试一个名为Widget的目标,我假设我需要创建大量用于测试方法的Widget。 我应该在WidgetUnitTest构造函数中构建这些Widget,还是在setUp()方法中构建? Widget与测试方法应该是1:1的比率,还是最好实践尽可能地重复使用Widget

最后,断言/失败和测试方法之间应存在多少细微差别? 一个纯粹主义者可能会认为,在一个测试方法中仅应存在1个断言,但是根据这种范式,如果Widget有一个名为getBuzz()的getter,我将会得到20个不同的getBuzz()测试方法名称。

@Test
public void testGetBuzzWhenFooIsNullAndFizzIsNonNegative() { ... }

与测试多种场景和主持多种断言的方法相反:

@Test
public void testGetBuzz() { ... }

感谢JUnit大师们的任何见解!

5个回答

18

模式

有趣的问题。首先 - 我在IDE中配置的终极测试模式:

@Test
public void shouldDoSomethingWhenSomeEventOccurs() throws Exception
{
    //given

    //when

    //then
}

我总是以这段代码为起点(聪明人称之为BDD)。

  • given中,我放置了每个测试唯一的测试设置。

  • when理想情况下只有一行——你正在测试的东西。

  • then应该包含断言。

我不是一个单一断言的支持者,但是你应该只测试行为的单个方面。例如,如果方法应该返回某些内容并且还有一些副作用,请使用相同的givenwhen部分创建两个测试。

此外,测试模式包括throws Exception。这是为了处理Java中令人讨厌的受检异常。如果您测试会引发异常的代码,则不会被编译器干扰。当然,如果测试引发异常,则失败。

设置

测试设置非常重要。一方面,提取公共代码并将其放置在setup()/@Before方法中是合理的。然而,请注意,当阅读测试(可读性是单元测试中最重要的价值!)时,很容易忽略在测试用例开头某个地方悬挂的设置代码。因此,相关的测试设置(例如,您可以以不同的方式创建小部件)应该放在测试方法中,但是基础结构(设置常见的模拟对象、启动嵌入式测试数据库等)应该被提取。再次提高可读性。

另外,您知道JUnit会为每个测试案例类创建新的实例吗?因此,即使您在构造函数中创建了CUT(待测类),构造函数也会在每次测试之前调用。有点烦人。

粒度

首先给测试命名,并思考您要测试的用例或功能,永远不要从以下角度考虑:

这是一个Foo类,具有bar()buzz()方法,因此我创建了FooTest,其中包括testBar()testBuzz()。天哪,我需要测试bar()中的两个执行路径——所以我们创建testBar1()testBar2()

shouldTurnOffEngineWhenOutOfFuel()是好的,testEngine17()是不好的。

命名

testGetBuzzWhenFooIsNullAndFizzIsNonNegative这个名称告诉我们有关测试的什么?我知道它测试了什么,但为什么?而且你不认为细节过于亲密吗?如何使用:

@Test shouldReturnDisabledBuzzWhenFooNotProvidedAndFizzNotNegative`

它既以有意义的方式描述输入,也描述您的意图(假设禁用buzz是某种buzz状态/类型)。此外,请注意,我们不再硬编码getBuzz()方法名称和Foonull契约(而是说:当Foo未提供时)。如果将来用null对象模式替换null怎么办?

还不要害怕20个不同的getBuzz()测试方法。相反,考虑测试的20种不同用例。但是,如果测试用例类变得太大了(因为它通常比被测试的类大得多),请将其拆分成几个测试用例。再次强调:FooHappyPathTestFooBogusInputFooCornerCases很好,Foo1TestFoo2Test则不好。

可读性

力争使用简短且具有描述性的名称。在giventhen中少写几行代码即可。创建构建器和内部DSL,提取方法,编写自定义匹配器和断言。测试应比生产代码更易读。不要过度mock。

我发现先编写一系列空的命名良好的测试用例方法很有用。然后我回到第一个测试用例。如果我仍然明白我应该在什么条件下进行测试,那么我同时构建类API实现测试。然后我实现这个API。聪明人称其为TDD(见下文)。

推荐阅读:


太棒了!我完全同意其中的所有内容。下一步是讨论模拟 :) - Guillaume
@Tomasz - 你是SO的新冠军。这是我在这里收到的最荒谬而又回答得非常好的问题。真棒,真棒的答案。谢谢。 - IAmYourFaja
@Tomasz - 为什么你的模式方法会抛出异常?这不是在断言中处理了吗,还是我漏掉了什么? - IAmYourFaja
@AdamTannon:我更新了我的答案,以解决您对“异常”方面的担忧。 - Tomasz Nurkiewicz

1

与其测试方法,不如专注于测试行为。问自己一个问题:“小部件应该做什么?”然后编写一项测试以确认答案。例如: “小部件应该抖动”。

public void setUp() throws Exception {
   myWidget = new Widget();
}

public void testAWidgetShouldFidget() throws Exception {
  myWidget.fidget();
}

编译代码时出现“未定义方法fidget”的错误,请修复错误,重新编译测试并重复此过程。接下来,询问每个行为的结果应该是什么,在我们的情况下,fidget会导致什么结果?也许有一些可观察的输出,比如一个新的二维坐标位置。在这种情况下,我们的小部件将被假定处于给定位置,当它抖动时,它的位置会以某种方式改变。
public void setUp() throws Exception {
   //Given a widget
   myWidget = new Widget();
   //And it's original position
   Point initialWidgetPosition = widget.position();
}


public void testAWidgetShouldFidget() throws Exception {
  myWidget.fidget();
}

public void testAWidgetPositionShouldChangeWhenItFidgets() throws Exception {
  myWidget.fidget();
  assertNotEquals(initialWidgetPosition, widget.position());
}

有人可能会反对两个测试都测试同样的焦躁行为,但是将焦躁行为单独分离出来,而不考虑它如何影响widget.position()是有道理的。如果一个行为失败了,单一测试将能够准确定位失败的原因。此外,重要的是要说明这种行为可以作为规范的实现来单独进行测试(你有程序规范吗?),这表明你需要一个焦躁的小部件。最终,关键在于将程序规范实现为代码,以便通过接口展示你已经完成了规范,并展示用户如何与你的产品交互。这本质上就是TDD的工作方式。任何试图解决错误或测试产品的尝试通常会导致令人沮丧的无意义辩论,例如使用哪个框架、覆盖率水平以及套件的粒度大小等问题。每个测试用例都应该是将你的规范分解成组件的练习,从而可以开始使用Given/When/Then进行表述。Given {某些应用状态或前提条件} When {调用某个行为} Then {断言某些可观察输出}。

1
在你的设置方法中,你需要创建一个被测试类的新实例。你想让每个测试都能独立执行,而不必担心另一个先前测试中被测试对象中任何不需要的状态。
我建议为需要测试的每个方案/行为/逻辑流程单独进行测试,而不是在getBuzz()中进行一次庞大的测试。你希望每个测试都有一个专注于验证getBuzz()中想要验证的内容的目的。

谢谢您的回复,Kristian - 但是像我提供的例子中那样,测试方法采用极长、繁琐的名称是很典型的吗? - IAmYourFaja
长命名可以,但可能不完全像您的示例。我们尝试按照它们正在测试的内容进行命名,例如testGetBuzzWhenNoNetworkConnection()。 - Kristian
1
测试的主要目的之一(不是最重要的,但很接近)是为您的类提供文档:不要担心冗长的名称,最重要的是它们准确地说明了测试内容。 - Guillaume

0

我完全赞同Tomasz Nurkiewicz的回答,所以我会说,而不是重复他说过的一切。

再补充几点:

不要忘记测试错误情况。你可以考虑像这样的事情:

@Test
public void throwExceptionWhenConditionOneExist() {
    // setup
    // ...
    try {
       classUnderTest.doSomething(conditionOne);
       Assert.fail("should have thrown exception");
    } catch (IllegalArgumentException expected) {
       Assert.assertEquals("this is the expected error message", expected.getMessage());
    } 
}

此外,在考虑被测试的类的设计之前,开始编写测试对于您具有巨大的价值。如果您是单元测试的初学者,我无法强调同时学习这种技术的重要性(这称为TDD,即测试驱动开发),其步骤如下:
  • 思考您的用户需求有哪些用例
  • 为其编写基本的第一个测试
  • 使其编译(通过创建所需的类,包括您要测试的类等)
  • 运行它:它应该失败
  • 现在实现将使其通过的被测试类的功能(并且仅限于此
  • 使用新需求进行清洗和重复

当所有需求都通过测试时,您就完成了。您永远不会在生产代码中编写任何没有先进行测试的内容(除了记录代码以及其他不太重要的内容)。

TDD在生成高质量代码、不过度工程化需求以及确保您具有100%的功能覆盖率(而不是通常毫无意义的行覆盖率)方面非常有价值。它需要改变您考虑编码的方式,这就是为什么同时学习测试技术的价值所在。一旦掌握,它将变得自然。

下一步是研究Mocking策略 :)
祝你测试愉快。

0
首先,在每个测试之前和之后,setUp和tearDown方法将被调用,因此如果您需要在每个测试中使用对象,则setUp方法应该创建这些对象,并且可以在测试本身中完成特定于测试的事情。
其次,您可以自行决定如何测试程序。显然,您可以为程序中的每种可能情况编写一个测试,并最终得到每种方法的无数个测试。或者,您可以仅为每种方法编写一个测试,以检查每种可能的情况。我建议两种方式之间的混合使用。您真的不需要为琐碎的getter/setter编写测试,但是仅为一个方法编写一个测试可能会导致测试失败时的混乱。您应该决定哪些方法值得测试,以及哪些场景值得测试。但原则上,每种情况都应该有自己的测试。
大多数情况下,我的测试代码覆盖率达到80%至90%。

@Test注解表示正在使用JUnit 4。因此,除非它们带有“Before”和“After”注解(它们可以被命名为任何名称,尽管setUp和tearDown是标准名称),否则将不会调用setUp和tearDown。 - Guillaume

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接