原创 | 使用JUnit、AssertJ和Mockito编写单元测试和实践TDD (十三)编写测试-生命周期方法

1,308 阅读8分钟

这一节我们讲讲通过"生命周期方法"消除上一节的两个测试方法之间拥有大量的重复代码。

重要性:★★★★★

XUnit测试模式中有一个四阶段测试模式setup - exercise - verify - teardown


编写的每个测试基本都包含顺序执行的四个阶段:

  1. 测试准备(fixture setup)阶段

    在这个阶段中,准备测试所依赖的外部环境(例如被测类要读写文件,就先在文件系统中创建这个文件,必要时还写入特定的内容),创建被测类(System Under Test,简称SUT)的实例,设置被测类的内部状态,注入它的外部依赖(一般用测试替身替代),等等等等。一句话:为测试的执行满足各种内外条件,使被测系统(SUT)和环境达到一个确定的状态(称为Fixture)。

  2. 执行测试(exercise SUT)阶段

    在这个阶段中,我们与被测类(SUT)交互,执行测试。

  3. 结果验证(result verification)阶段

    在这个阶段,我们验证测试的结果是否符合方方面面的预期:方法的返回值是否和我们的期望值相同?SUT的内部状态是否更新到我们期望的值?是否向文件或数据库写入了预期的数据?是否按照契约调用了外部依赖的指定方法?等等。

  4. 环境清理(fixture teardown)阶段

    在这个阶段将系统和环境恢复到测试前的状态——测试不应该对系统和环境造成持久性的改变。这包括:从数据库中删除测试插入的数据,从文件系统删除测试创建的文件,将修改了的数据行恢复原状,等等。

存在于同一个测试类中的多个测试方法,在setup和teardown阶段往往有重复的内容。例如上一节的例子,两个测试方法在setup阶段存在下面的重复代码:

        Path file = tempDir.resolve("conf.properties");
        Files.write(file, Arrays.asList("birthday=2002-05-11",
                "size=15",
                "closed=true",
                "locked=false",
                "salary=12.5",
                "name=张三",
                "noneValue="));
        Configuration instance = Configuration.builder()
                .fromFile(file.toFile())
                .dateFormat("yyyy-MM-dd")
                .build();

JUnit提供了4个生命周期方法,帮助我们消除setup和teardown阶段的重复代码。

所有生命周期方法都应该满足以下条件:

  • 不是private的方法
  • 返回void
  • 一般也没有参数(一些特殊情况除外)
  • 可以抛出任何异常

1. @BeforeEach和@AfterEach

这两个注解标识在测试类的实例方法上。分别用在四阶段测试setup-exercise-verify-teardown的setup阶段和teardown阶段上。

标识为@BeforeEach的方法,会在测试类中的

每个

测试方法执行之前执行一次。标识为@AfterEach的方法,会在测试类中的

每个

测试方法执行之后执行一次。

上一节的测试类可以用BeforeEach和AfterEach改写为这个样子:

package yang.yu.configuration;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;

import static org.assertj.core.api.Assertions.assertThat;

class Configuration3Test {

    @TempDir
    Path tempDir;

    private Configuration instance;

    @BeforeEach
    void setUp() throws Exception {
        Path file = tempDir.resolve("conf.properties");
        Files.write(file, Arrays.asList("birthday=2002-05-11",
                "size=15",
                "closed=true",
                "locked = false",
                "salary=12.5",
                "name=张三",
                "noneValue="));
        instance = Configuration.builder()
                .fromFile(file.toFile())
                .dateFormat("yyyy-MM-dd")
                .build();
    }

    @AfterEach
    void tearDown() {
        System.out.println("Noting to do for now.");
    }

    /**
     * key存在,value存在,应当返回value
     */
    @Test
    void get_string_without_defaultValue_happy() throws IOException {
        assertThat(instance.getString("name")).isEqualTo("张三");
    }

    /**
     * key存在, value存在,格式正确,应当返回value
     */
    @Test
    void get_int_with_defaultValue_and_with_value() throws IOException {
        assertThat(instance.getInt("size", 1000)).isEqualTo(15);
    }

}

通过将共同的setup代码提取到@BeforeEach生命周期方法,减少了重复代码。

当我们采用了PER_CLASS生命周期时,会为每个测试方法实例化一个新的测试类,然后在这个测试类实例上依次调用@BeforeEach生命周期方法、测试方法和@AfterEach生命周期方法。

如果我们采用了PER_CLASS生命周期,则不会为每个测试方法分别创建一个新的测试类实例,而是共同同一个测试类实例,但是依然会在每个测试方法的前后执行一次@BeforeEach和@AfterEach生命周期方法。

2. @BeforeAll和@AfterAll

标识为@BeforeAll和@AfterAll的生命周期方法,会在每个测试类执行之前/后运行一次。不过测试类中有多少个测试方法,这两个方法都只在类级范围内运行一次。

如果我们采用PER

METHOD生命周期,@BeforeAll和@AfterAll只能标识测试类内的静态方法(从而只能操作测试类内的静态字段)。如果采用PER

CLASS生命周期,这两个方法可以标识在测试类的实例方法上。

下面是代码示例:

import static org.junit.jupiter.api.Assertions.fail;
import static org.junit.jupiter.api.Assumptions.assumeTrue;

import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;

class StandardTests {

    @BeforeAll
    static void initAll() {
    }

    @BeforeEach
    void init() {
    }

    @Test
    void succeedingTest() {
    }

    @Test
    void failingTest() {
        fail("a failing test");
    }

    @Test
    @Disabled("for demonstration purposes")
    void skippedTest() {
        // not executed
    }

    @Test
    void abortedTest() {
        assumeTrue("abc".contains("Z"));
        fail("test should have been aborted");
    }

    @AfterEach
    void tearDown() {
    }

    @AfterAll
    static void tearDownAll() {
    }

}

测试时方法的调用顺序是:

  1. initAll()
  2. init()
  3. 测试方法(succeedingTest()、failingTest()、abortedTest()三个方法之一。不确定先跑哪个)
  4. tearDown()
  5. init()
  6. 测试方法(succeedingTest()、failingTest()、abortedTest()三个方法之一。不确定先跑哪个)
  7. tearDown()
  8. init()
  9. 测试方法(succeedingTest()、failingTest()、abortedTest()三个方法之一。不确定先跑哪个)
  10. tearDown()
  11. tearDownAll()

3. 在测试的setup阶段应该用@BeforeEach还是@BeforeAll

一般而言,应该尽量使用@BeforeEach生命周期方法,因为它的隔离性更好,使得同一个测试类中的多个测试方法不会相互影响。只有在setup代码资源消耗太大、性能太低的情况下才考虑使用@BeforeAll代替@BeforeEach生命周期方法。在使用@BeforeAll时,一定要细心审查你的测试代码,确保一个测试的执行不会对另一个测试造成的显著影响。

@BeforeAll一般只用于集成测试。因为集成测试的setup阶段往往需要启动数据库或应用服务器等,资源消耗比较大。

4. 生命周期方法的继承关系

如果测试类所继承的超类或实现的接口中也定义了生命周期方法(Java 8之后允许在接口上定义缺省方法,缺省方法伴中可以包含代码),而测试类自身也定义了生命周期方法,当超类和接口中的生命周期方法没有被测试类隐藏(

hidden

)或覆盖(

overridden

)时,接口、超类和测试类本身的生命周期方法都会执行,而且:

  • 接口中定义的@BeforeAll和@BeforeEach生命周期方法会在实现类的相应生命周期方法之前执行;
  • 超类中定义的@BeforeAll和@BeforeEach生命周期方法会在子类的相应生命周期方法之前执行;
  • 接口中定义的@AfterAll和@AfterEach生命周期方法会在实现类的相应生命周期方法之后执行;
  • 超类中定义的@AfterAll和@AfterEach生命周期方法会在子类的相应生命周期方法之后执行。

如果有接口I,超类A和子类S,都定义了四个生命周期方法而且不相互覆盖/重载:

  1. 如果A实现IS继承A

(1)对于@BeforeAll@BeforeEach生命周期方法,执行顺序是:

接口上的方法 > 超类上的方法 > 子类上的方法

(2)对于@AfterAll@AfterEach生命周期方法,执行顺序是:

子类上的方法 > 超类上的方法 > 接口上的方法

  1. 如果S继承A,同时实现I

(1)对于@BeforeAll@BeforeEach生命周期方法,执行顺序是:

超类上的方法 > 接口上的方法 > 子类上的方法

(2)对于@AfterAll@AfterEach生命周期方法,执行顺序是:

子类上的方法 > 接口上的方法 > 超类上的方法

4. 代码示例

首先,我们定一个一个接口TheInterface

package yang.yu.tdd.lifecycle;

import org.junit.jupiter.api.*;

public interface TheInterface {

    @BeforeEach
    @DisplayName("Before Each In Interface")
    default void beforeEachInInterface() {
        System.out.println("Before Each In Interface");
    }

    @AfterEach
    @DisplayName("After Each In Interface")
    default void afterEachInInterface() {
        System.out.println("After Each In Interface");
    }

    @BeforeAll
    @DisplayName("Before All In Interface")
    static void beforeAllInInterface() {
        System.out.println("Before All In Interface");
    }

    @AfterAll
    @DisplayName("After All In Interface")
    static void afterAllInInterface() {
        System.out.println("After All In Interface");
    }
}

然后再定义一个抽象基类TheSuperClass,它实现了TheInterface接口:

package yang.yu.tdd.lifecycle;

import org.junit.jupiter.api.*;

public abstract class TheSuperClass implements TheInterface {

    @BeforeEach
    @DisplayName("Before Each In Superclass")
    void beforeEachInSuperclass() {
        System.out.println("Before Each In Superclass");
    }

    @AfterEach
    @DisplayName("After Each In Superclass")
    void afterEachInSuperclass() {
        System.out.println("After Each In Superclass");
    }

    @BeforeAll
    @DisplayName("Before All In Superclass")
    static void beforeAllInSuperclass() {
        System.out.println("Before All In Superclass");
    }

    @AfterAll
    @DisplayName("After All In Superclass")
    static void afterAllInSuperclass() {
        System.out.println("After All In Superclass");
    }
}

最后定义测试类TheSubClass,它继承TheSuperClass并定义了两个测试方法aTest()anotherTest()

package yang.yu.tdd.lifecycle;

import org.junit.jupiter.api.*;

public class TheSubClass extends TheSuperClass {

    @BeforeEach
    @DisplayName("Before Each In Subclass")
    void beforeEachInSubclass() {
        System.out.println("Before Each In Subclass");
    }

    @AfterEach
    @DisplayName("After Each In Subclass")
    void afterEachInSubclass() {
        System.out.println("After Each In Subclass");
    }

    @BeforeAll
    @DisplayName("Before All In Subclass")
    static void beforeAllInSubclass() {
        System.out.println("Before All In Subclass");
    }

    @AfterAll
    @DisplayName("After All In Subclass")
    static void afterAllInSubclass() {
        System.out.println("After All In Subclass");
    }

    @Test
    @DisplayName("A Test Method")
    void aTest() {
        System.out.println("A Test Method");
    }

    @Test
    @DisplayName("Another Test Method")
    void anotherTest() {
        System.out.println("Another Test Method");
    }
}

三个类中都定义了四个生命周期方法。对TheSubClass类执行单元测试,控制台输出如下:

Before All In Interface
Before All In Superclass
Before All In Subclass

Before Each In Interface
Before Each In Superclass
Before Each In Subclass
Another Test Method
After Each In Subclass
After Each In Superclass
After Each In Interface


Before Each In Interface
Before Each In Superclass
Before Each In Subclass
A Test Method
After Each In Subclass
After Each In Superclass
After Each In Interface

After All In Subclass
After All In Superclass
After All In Interface

Process finished with exit code 0

上面的测试结果证明了:

  • @BeforeAll方法在每个测试类之前执行一次,@AfterAll方法在每个测试类之后执行一次。
  • @BeforeEach方法在每个测试方法之前执行一次,@AfterEach方法在每个测试方法之后执行一次。
  • 如果测试类继承了超类或实现了接口,而后者也定义了生命周期方法并且没有被测试类隐藏或覆盖,则超类或接口上定义的生命周期方法也会执行,其顺序与继承或实现的层级相关。对于@BeforeAll@BeforeEach方法,是先执行接口和超类上的方法再执行子类上的方法;对于@AfterAll@AfterEach方法,是先执行子类上的方法再执行接口和超类上的方法。

下一节我们讲讲如何"编写显示名"