我怎样才能将系统设计与单元测试分离(正如鲍勃大叔所建议的那样)?

How can I decouple system design from unit tests (as suggested by Uncle Bob)?

Bob 叔叔 (Bob Martin) 在他的 blog 中提到,为了将我们的系统设计与单元测试分离,我们不应该将我们的具体 classes 直接暴露给单元测试。相反,我们应该只公开一个代表我们系统的 API,然后使用这个 API 进行单元测试。

A rough representation of Uncle Bob's suggestion

按照我的理解,我认为他说的API是指接口。所以单元测试应该与接口交互,而不是真正的 classes.

我的问题是:如果我们只向单元测试公开接口,这些单元测试如何访问实际实现以验证它们的行为?我们应该在测试中使用 DI 在 运行 时间注入真正的 classes 吗?下面的代码有什么方法可以工作吗?

ILoanEligibility.cs

public interface ILoanEligibility
{
    bool HasCorrectType(string loanType);
}

LoanEligibility.cs

public class LoanEligibility : ILoanEligibility
{
    public bool HasCorrectType(string loanType)
    {
        if(loanType.Equals("Personal"))
        {
            return true;
        }
        return false;
    }
}

单元测试

[TestClass]
public class LoanEligibilityTest
{
    ILoanEligibility _loanEligibility;
    [TestMethod]
    public void TestLoanTypePersonal()
    {
        //Arrange
        string loanType = "Personal";

        //Act
        bool expected = _loanEligibility.HasCorrectType(loanType);

        //Assert
        Assert.IsTrue(expected);
    }
}

上面的单元测试试图查看 LoanEligibility.HasCorrectType() 方法是否适用于 "Personal" 类型。显然,根据 Uncle Bob 的建议(如果我理解正确的话),测试将失败,因为我们没有使用具体的 class,而是使用接口。

如何通过这个测试?任何建议都会有所帮助。

编辑 1 感谢@bleepzter 建议最小起订量。以下是修改后的单元测试 class,同时测试有效和无效情况。

[TestClass]
public class LoanEligibilityTest
{
    private Mock<ILoanEligibility> _loanEligibility;

    [TestMethod]
    public void TestLoanTypePersonal()
    {
        SetMockLoanEligibility();
        //Arrange
        string loanType = "Personal";
        //Act
        bool expected = _loanEligibility.Object.HasCorrectType(loanType);
        //Assert
        Assert.IsTrue(expected);
    }

    [TestMethod]
    public void TestLoanTypeInvalid()
    {
        SetMockLoanEligibility();
        //Arrange
        string loanType = "House";
        //Act
        bool expected = _loanEligibility.Object.HasCorrectType(loanType);
        //Assert
        Assert.IsFalse(expected);
    }

    public void SetMockLoanEligibility()
    {
        _loanEligibility = new Mock<ILoanEligibility>();
        _loanEligibility.Setup(loanElg => loanElg.HasCorrectType("Personal"))
                        .Returns(true);
    }
}

但现在我很困惑。因为我们不是 真正地 测试我们的具体 class 而是它的模拟,这些单元测试是否真的告诉我们任何东西,除了可能我们的模拟工作正常之外?

要回答您的问题 - 您将使用模拟框架,例如 Moq。

总体思路是接口或抽象 classes 提供 "contracts" 或一组标准化的 API,您可以根据它们进行编码。

这些接口或抽象 classes 的实现可以单独进行单元测试。这不是问题,事实上 - 这是您应该定期做的事情。

但是,当这些实现是其他对象的依赖项时,就会出现复杂性。在这方面——要对这样一个复杂的对象进行单元测试,您首先必须构建依赖项的实现,将该依赖项插入到您正在测试的任何实例中。

这个过程变得非常繁琐,因为随着依赖链的增长 - 代码行为方式的可变性可能会非常复杂。为了简化测试并能够对复杂依赖链中的多个条件进行单元测试 - 我们使用模拟框架。

mock 提供的是一种 "fake" 具有特定参数(input/output,无论它们是什么)的实现并将这些伪造品插入依赖关系图中的方法。虽然是的 - 你可以模拟具体对象 - 模拟由接口或抽象定义的合同要容易得多 class.

理解这些概念的一个不错的起点是最小起订量框架文档。 https://github.com/Moq/moq4/wiki/Quickstart

编辑:

我看到有人对这意味着什么感到困惑,所以我想详细说明一下。

常见的设计模式(称为 S.O.L.I.D)规定一个对象应该做一件事,并且只做一件事并且把它做好。这就是所谓的单一职责原则。

另一个核心概念是对象应该依赖于抽象而不是具体的实现。这个概念被称为依赖倒置原则。

最后 - 里氏替换原则规定,程序中的对象应该可以用其子类型的实例替换,而不会改变程序的正确性。换句话说 - 如果您的对象依赖于抽象,那么您可以为这些抽象提供不同的实现(利用继承),而无需从根本上改变应用程序的行为。

这也巧妙地跳入了 Open/Closed 原则。 IE——软件实体应该对扩展开放,对修改关闭。 (考虑为这些抽象提供不同的实现)。

最后 - 我们有控制反转原则 - 一个复杂的对象不应该负责创建它自己的依赖关系;其他东西应该负责创建它们,它们应该 "injected" 通过构造函数、方法或 属性 在需要的地方注入。

那么这如何应用于单元测试的 "decoupling system design"?

答案很简单。

假设我们正在编写一个汽车建模软件。 汽车有车身和车轮,以及各种其他内部组件。 为简单起见,我们会说 Car 类型的对象有一个构造函数,该构造函数将四个 wheel 对象作为参数:

public class Wheel {
   public double Radius { get; set; }
   public double RPM { get; set; }
   public void Spin(){ ... }
   public double GetLinearVelocity() { ... }
}

public class LinearMovement{
   public double Velocity { get; set; }     
}

public class Car {

  private Wheel wheelOne;
  private Wheel wheelTwo;
  private Wheel wheelThree;
  private Wheel wheelFour;

  public Car(Wheel one, Wheel two, Wheel three, Wheel four){
    wheelOne = one;
    wheelTwo = two;
    wheelThree = three;
    wheelFour = four;
  } 

  public LinearMovement Move(){
    wheelOne.Spin();
    wheelTwo.Spin();
    wheelThree.Spin();
    wheelFour.Spin();

    speedOne = wheelOne.GetLinearVelocity();
    speedTwo = wheelTwo.GetLinearVelocity();
    speedThree = wheelThree.GetLinearVelocity();
    speedFour = wheelFour.GetLinearVelocity();

    return new LinearMovement(){ 
       Velocity = (speedOne + speedTwo + speedThree + speedFour) / 4
    };
  }
}

汽车移动的能力取决于汽车的车轮类型。车轮可以有软橡胶,从而将汽车粘在拐角处的道路上,或者它可以非常窄以适应深雪但速度非常慢。

因此 - 轮子的想法变成了一种抽象。那里有各种各样的轮子,一个轮子的具体实现不可能涵盖所有这些。输入依赖倒置原则。

我们使用 IWheel 接口对车轮进行抽象,以声明任何车轮应该能够执行的基本最小功能,以便与我们的汽车一起工作。 (在我们的例子中它至少应该旋转......)

public interface IWheel {
    double Radius { get; set; }
    double RPM { get; set; }
    void Spin();
    double GetLinearVelocity();
}

public class BasicWheel : IWheel {
   public double Radius { get; set; }
   public double RPM { get; set; }
   public void Spin(){ ... }
   public double GetLinearVelocity() { ... }   
}

public class Car {
    ...
    public Car(IWheel one, IWheel two, IWheel three, IWheel four){
    ...
    } 

    public LinearMovement Move(){
        wheelOne.Spin();
        wheelTwo.Spin();
        wheelThree.Spin();
        wheelFour.Spin();

        speedOne = wheelOne.GetLinearVelocity();
        speedTwo = wheelTwo.GetLinearVelocity();
        speedThree = wheelThree.GetLinearVelocity();
        speedFour = wheelFour.GetLinearVelocity();

        return new LinearMovement(){ 
            Velocity = (speedOne + speedTwo + speedThree + speedFour) / 4
        };
    }
}

太好了,我们得到了一个抽象来定义车轮的基本功能,并且我们根据该抽象对汽车进行了编码。汽车移动的代码没有任何变化——因此满足里氏代换原则。

所以现在,如果我们不创建带有基本轮子的汽车,而是创建带有 RacingPerformanceWheels 的汽车,那么控制汽车移动方式的代码将保持不变。这满足了开放/封闭原则。

但是 - 它带来了另一个问题。汽车的实际速度 - 取决于所有 4 个车轮的平均线速度。因此,取决于车轮 - 汽车的行为会有所不同。

鉴于可能有一百万种不同类型的车轮,我们如何测试汽车的行为?!?

进入模拟框架。因为汽车的运动取决于接口定义的轮子的抽象概念 IWheel - 我们现在可以模拟这种轮子的不同实现,每个实现都有预定义的参数。

混凝土轮 implementations/objects 本身(BasicWheelRacingPerformanceWheel 等)应该在没有模拟的情况下进行单元测试。 原因是它们没有自己的依赖关系。如果轮子在它的构造函数中有一个依赖项——那么模拟应该用于该依赖项。

测试汽车对象 - 应使用模拟来描述传递给汽车构造函数的每个 IWheel 实例(依赖项)。 这提供了几个优点 - 将整个系统设计与单元测试分离:

1) 我们不关心系统中有哪些轮子。其中可能有 100 万。

2) 我们关心特定的车轮尺寸,在给定的 angular 速度 (RPM) 下 - 汽车应该达到非常特定的线速度。

IWheel 根据 #2 的要求模拟会告诉我们车辆是否正常工作,如果不正常 - 我们可以更改代码以纠正错误。

If we are exposing only interfaces to our unit tests, how do these unit tests get access to the actual implementations to verify their behavior?

随心所欲。

我发现令人满意的一种方法是在抽象 class 中编写检查,并从扩展的其他空 class 的构造函数中传入被测系统的实例摘要 class.

在很多方面,测试框架是……嗯……"frameworks"(很明显)……因此将可测试组件视为要注入的东西是有意义的框架。请参阅 Mark Seemann 以探索 DI friendly framework 可能是什么样子,并确定您认为这些想法对您的测试套件是否合理。

可以 首先以这种方式进行测试,但我承认,分离关注点的一些步骤会让人感觉有点做作 --尽早引入接口,因为你真的了解 API 什么东西用起来会舒服,这可能是可疑的。

(一个答案可能是在着手编写实施检查之前,先花时间检查接口。)

Rather, we should just expose an API that represents our system, and then use this API for unit testing.

正确

According to my understanding, I think that by an API, he meant an interface. So the unit tests should be interacting with interfaces instead of real classes.

这里你误解了第一句话。
首先在单元测试中,您需要测试实际实现以验证其行为。
然后在单元测试中,您将实例化实际的 类 ,但您只允许使用 API 的消费者可以访问 .

的方法和类型

在您的特定示例中

[TestClass]
public class LoanEligibilityTest
{        
    [TestMethod]
    public void TestLoanTypePersonal()
    {
        //Arrange
        ILoanEligibility loanEligibility = new LoanEligibility(); // actual implementation
        string loanType = "Personal";

        //Act
        bool expected = _loanEligibility.HasCorrectType(loanType);

        //Assert
        Assert.IsTrue(expected);
    }
}

建议:使用 "Act" 部分中的 Arrange-Act-Assert 方法,您只允许使用 API 提供的方法和类型。