测试替身的目的

在本节中,我们将学习允许我们测试这些具有挑战性的协作的技术。我们将介绍测试替身的概念。我们将学习如何应用 SOLID 原则来设计足够灵活的代码以使用这些测试替身。

上一节的挑战通过使用 测试替身 来解决。测试替身替换了我们测试中的一个协作对象。通过设计,这个测试替身避免了被替换对象的困难。可以将它们视为电影中的替身演员,替换真实演员以帮助安全地拍摄动作镜头。

软件测试替身是我们专门编写的对象,以便在我们的单元测试中易于使用。在测试中,我们在 Arrange 步骤中将测试替身注入到 SUT 中。在生产代码中,我们注入测试替身所替换的生产对象。

让我们重新考虑之前的 DiceRoll 示例。我们如何重构该代码以使其更易于测试?

  1. 创建一个抽象随机数源的接口:

    interface RandomNumbers {
        int nextInt(int upperBoundExclusive);
    }
  2. 应用依赖倒置原则到 DiceRoll 类以使用这个抽象:

    package examples;
    
    import java.util.random.RandomGenerator;
    
    public class DiceRoll {
        private final int NUMBER_OF_SIDES = 6;
        private final RandomNumbers rnd;
    
        public DiceRoll(RandomNumbers r) {
            this.rnd = r;
        }
    
        public String asText() {
            int rolled = rnd.nextInt(NUMBER_OF_SIDES) + 1;
            return String.format("You rolled a %d", rolled);
        }
    }

    我们通过用 RandomNumbers 接口替换随机数生成器来倒置了依赖。我们添加了一个构造函数,允许注入合适的 RandomNumbers 实现。我们将其分配给 rnd 字段。asText() 方法现在调用我们传递给构造函数的任何对象的 nextInt() 方法。

  3. 编写一个测试,使用测试替身替换 RandomNumbers 源:

    package examples;
    
    import org.junit.jupiter.api.Test;
    import static org.assertj.core.api.Assertions.assertThat;
    
    class DiceRollTest {
        @Test
        void producesMessage() {
            var stub = new StubRandomNumbers();
            var roll = new DiceRoll(stub);
    
            var actual = roll.asText();
    
            assertThat(actual).isEqualTo("You rolled a 5");
        }
    }

    我们在这个测试中看到了通常的 ArrangeActAssert 部分。这里的新概念是 StubRandomNumbers 类。让我们看看存根代码:

    package examples;
    
    public class StubRandomNumbers implements RandomNumbers {
    
        @Override
        public int nextInt(int upperBoundExclusive) {
            return 4; // @see https://xkcd.com/221
        }
    }

    关于这个存根有几件事需要注意。首先,它实现了我们的 RandomNumbers 接口,使其成为该接口的 LSP 兼容替代品。这允许我们将其注入到我们的 SUT DiceRoll 的构造函数中。第二个最重要的方面是每次调用 nextInt() 都会返回相同的数字。

通过用提供已知值的存根替换真实的 RandomNumbers 源,我们使测试断言易于编写。存根消除了随机生成器不可重复值的问题。

我们现在可以看到 DiceRollTest 是如何工作的。我们向 SUT 提供一个测试替身。测试替身总是返回相同的值。因此,我们可以对已知结果进行断言。

制作代码的生产版本

为了使 DiceRoll 类在生产中正常工作,我们需要注入一个真正的随机数源。一个合适的类如下:

public class RandomlyGeneratedNumbers implements RandomNumbers {
    private final RandomGenerator rnd = RandomGenerator.getDefault();

    @Override
    public int nextInt(int upperBoundExclusive) {
        return rnd.nextInt(upperBoundExclusive);
    }
}

这里没有太多工作要做——前面的代码只是使用 Java 内置的 RandomGenerator 库类实现了 nextInt() 方法。

我们现在可以使用它来创建我们的生产版本代码。我们已经更改了 DiceRoll 类,以允许我们注入任何合适的 RandomNumbers 接口实现。对于我们的测试代码,我们注入了一个测试替身——StubRandomNumbers 类的实例。对于我们的生产代码,我们将注入 RandomlyGeneratedNumbers 类的实例。生产代码将使用该对象创建真正的随机数——并且 DiceRoll 类内部不会有代码更改。我们使用了依赖倒置原则使 DiceRoll 类通过依赖注入可配置。这意味着 DiceRoll 类现在遵循开闭原则——它对新型随机数生成行为开放,但对类内部的代码更改关闭。

依赖倒置、依赖注入和控制反转

前面的示例展示了这三个概念的实际应用。依赖倒置是我们在代码中创建抽象的设计技术。依赖注入是我们向依赖该抽象的代码提供该抽象实现的运行时技术。这些概念一起通常被称为控制反转(IoC)。像 Spring 这样的框架有时被称为 IoC 容器,因为它们提供了帮助你在应用程序中管理和注入依赖的工具。

以下代码是我们如何在生产中使用 DiceRollRandomlyGeneratedNumbers 的示例:

public class DiceRollApp {
    public static void main(String[] args) {
        new DiceRollApp().run();
    }

    private void run() {
        var rnd = new RandomlyGeneratedNumbers();
        var roll = new DiceRoll(rnd);

        System.out.println(roll.asText());
    }
}

你可以看到在前面的代码中,我们将生产版本的 RandomlyGeneratedNumbers 类的实例注入到 DiceRoll 类中。这个创建和注入对象的过程通常被称为对象装配。像 SpringGoogle Guice 和内置的 Java CDI 这样的框架提供了使用注释最小化创建依赖和装配它们的样板代码的方法。

使用 DIP 将生产对象替换为测试替身是一个非常强大的技术。这个测试替身是一种称为存根的替身示例。我们将在下一节中介绍什么是存根以及何时使用它。