Bob叔叔(Bob Martin)在他的blog中提到,为了使系统的设计与单元测试脱钩,我们不应将具体的类直接暴露给单元测试。相反,我们应该公开一个代表我们系统的API,然后使用该API进行单元测试。
A rough representation of Uncle Bob's suggestion
根据我的理解,我认为通过API,他的意思是接口。因此,单元测试应该与接口而不是真实的类进行交互。
我的问题是:如果我们仅向单元测试公开接口,那么这些单元测试如何访问实际的实现以验证其行为?我们是否应该在测试中使用DI在运行时注入真实的类?下面的代码有什么办法可以工作?
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()方法是否适用于“个人”类型。显然,根据Bob叔叔的建议(如果我正确理解的话),由于我们没有使用具体的类,而是使用了一个接口,因此该测试将失败。
我如何通过此考试?任何建议都会有所帮助。
编辑1 谢谢@bleepzter建议的最小起订量。下面是修改后的单元测试类,测试有效和无效案例。
[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);
}
}
但是现在我很困惑。既然我们不是真正测试具体类,而是测试它的模拟类,那么这些单元测试是否真的告诉我们任何东西,除了可能模拟还可以正常工作?
答案 0 :(得分:1)
要回答您的问题-您将使用Moq之类的模拟框架。
总体思路是,接口或抽象类提供“合同”或一组您可以针对其进行编码的标准化API。
这些接口或抽象类的实现可以单独进行单元测试。这不是问题,实际上-这是您应该定期执行的操作。
但是,当那些实现是其他对象的依赖项时,复杂性就会增加。在这方面-要对这样一个复杂的对象进行单元测试,您首先必须构造依赖项的实现,然后将该依赖项插入要测试的对象的实例中。
此过程变得非常繁重,因为随着依赖关系链的增长,代码行为的可变性可能会非常复杂。为了简化测试并能够对复杂的依赖链中的多个条件进行单元测试,我们使用了模拟框架。
模拟提供的是一种用特定参数(输入/输出,无论它们可能是什么)“伪造”实现并将这些伪造品插入依赖图的方法。是的,尽管您可以模拟具体对象,但模拟由接口或抽象类定义的协定要容易得多。
了解最小概念的一个不错的出发点是moq框架文档。 https://github.com/Moq/moq4/wiki/Quickstart
编辑:
我知道这意味着什么,所以我想详细说明。
常见的设计模式(称为S.O.L.I.D)规定一个对象应该做1件事,并且只能做1件事并且做得很好。这就是所谓的“单一责任原则”。
另一个核心概念是对象应该依赖抽象而不是具体的实现。这个概念被称为依赖反转原理。
最后-Liskov替换原理,指示程序中的对象应该可以用其子类型的实例替换,而不会改变程序的正确性。换句话说-如果您的对象依赖抽象,则可以为这些抽象提供不同的实现(利用继承),而无需从根本上改变应用程序的行为。
这也巧妙地跳入了Open / Closed原理。 IE-软件实体应开放以进行扩展,但应封闭以进行修改。 (请考虑为这些抽象提供不同的实现)。
最后-我们具有控制反转原则-复杂的对象不应该负责创建自己的依赖关系;应该由其他人来负责创建它们,并且应该在需要它们时通过构造函数,方法或属性注入将它们“注入”。
那么这在单元测试的“去耦系统设计”中如何应用?
答案很简单。
假设我们正在编写一个对汽车进行建模的软件。
汽车具有车身,车轮和各种其他内部组件。
为简单起见,我们将说类型为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
};
}
}
太好了,我们得到了一个抽象来定义车轮的基本功能,并根据该抽象对汽车进行了编码。汽车的行驶代码没有任何变化,从而满足了Liskov替代原则。
所以现在,如果不是使用基本车轮创建汽车,而是使用RacingPerformanceWheels创建汽车,则控制汽车运行方式的代码将保持不变。这满足了开放/封闭原则。
但是-这带来了另一个问题。汽车的实际速度取决于所有四个车轮的平均线速度。因此,取决于车轮-汽车的性能会有所不同。
鉴于那里可能有上百万种不同类型的车轮,我们如何测试汽车的性能?!
进入模拟框架。由于汽车的运动取决于接口IWheel
定义的车轮的抽象概念-我们现在可以模拟这种车轮的不同实现,每个实现都有预定义的参数。
具体的车轮实现/对象本身(BasicWheel
,RacingPerformanceWheel
等。)应该在没有模拟的情况下进行单元测试。 是他们没有自己的依赖性。如果转轮的构造函数中有一个依赖项,则应对该对象使用模拟。
要测试汽车对象-应该使用模拟来描述传递给汽车构造函数的每个IWheel
实例(相关性)。 提供了两个优点-将整个系统设计与单元测试分离:
1)我们不在乎系统中的车轮。可能有一百万。
2)我们关心的是,对于特定的车轮尺寸,在给定的角速度(RPM)下,汽车应该达到非常特定的线速度。
针对{2的要求}的IWheel
模拟将告诉我们我们的车辆是否工作正常,否则,我们可以更改代码以更正错误。
答案 1 :(得分:1)
相反,我们应该公开代表我们系统的API,并且 然后使用此API进行单元测试。
正确
根据我的理解,我认为API是指 接口。因此,单元测试应该与接口交互 而不是真实的课程。
您在这里误解了第一句话。
首先,在单元测试中,您需要测试实际的实现以验证其行为。
然后,在单元测试中,您将实例化实际的类,但是您只允许使用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);
}
}
建议:通过“行为”部分中的“排列-行为-声明”方法,您只能使用API提供的方法和类型。
答案 2 :(得分:-1)
如果我们仅向单元测试公开接口,那么这些单元测试如何访问实际的实现以验证其行为?
任何方式。
我发现令人满意的一种方法是在抽象类中编写检查,然后从扩展抽象类的否则为空的类的构造函数中传递被测系统的实例。
从很多方面来说,测试框架都是……很……“框架”(显然)……因此,将可测试组件视为要注入框架的东西是有意义的。请参阅Mark Seemann,以了解DI friendly framework的外观,并确定您认为这些想法对您的测试套件是否合理。
您可以首先使用这种样式进行测试,但是我要承认,将关注点分开的一些举措会让人有些困惑-尽早引入接口,因为您真的了解哪种API易于使用,也许是可疑的。
(一个答案可能是花一些时间来加倍接口的时间,然后才投入精力编写实现的检查)。