从 Mock 到隔离






4.97/5 (17投票s)
本文讨论了如何使用 Mock 框架来将被测代码与被引用类的行为隔离开。
我们爬得越高,我们就变得越孤立。—— 斯坦尼斯拉斯·德·布夫勒
引言
测试框架和工具的快速发展,以及关于 TDD 各方面内容的书籍和文章,不仅为开发人员的需求带来了解决方案,也带来了困惑。Dummy 对象、fake、stub、mock——我们应该使用什么?什么时候使用?为什么使用?使情况更复杂的是,大多数开发人员无法投入大量时间来学习测试方法论——他们的效率通常用不同的标准来衡量,尽管现代软件项目的管理层深知用可维护的测试环境包围生产代码的重要性,但他们却不容易被说服,让他们投入数百个工作小时来更新系统测试,因为这涉及到范式的转变。
因此,我们必须努力用最小的努力把事情做好,并根据简单的实用标准来做决定。归根结底,我们代码的简洁性、可读性和可维护性才是最重要的。因此,我将要做的是写一个非常简单的类,它依赖于一个数据库,编写一个集成测试来说明拖拽这些依赖项与这样一个简单类的弊端,然后展示如何打破依赖关系:首先使用传统的 Mock 和记录的期望,然后使用一种更轻量级的 arrange-act-assert 方法,这种方法不需要精确地重构 Mock 对象的行为。
说明本文的示例代码使用了 TypeMock Isolator 框架(5.1 版本)。它是一个商业产品,但对于 .NET 开发来说,还有其他很棒的替代方案,例如 Rhino Mocks 和 Moq。然而,我认为 TypeMock Isolator 是对 Roy Osherove 在其博客文章 “告别 Mock,告别 Stub” 中解释的方法论演变的一个很好的例子。另一个原因是,Isolator 的最新版本已经扩展了 ArrangeActAssert
命名空间,使得不再需要关注 Mock 对象的行为方面。
1. 要测试的代码:计算保险价格分组
我们的目标是为计算汽车保险价格分组的算法编写测试。为了简化,该算法仅基于客户年龄:16 岁以下的人属于 Child
(儿童)组(被拒绝的可能性很大),16 至 25 岁的人属于 Junior
(青少年)组,25 至 65 岁的人属于 Adult
(成年人)组,任何年龄更大的人都被视为 Senior
(老年人)。代码如下
public class CarInsurance
{
public PriceGroup GetCustomerPriceGroup(int customerID)
{
DataLayer dataLayer = new DataLayer();
dataLayer.OpenConnection();
Customer customer = dataLayer.GetCustomer(customerID);
dataLayer.CloseConnection();
DateTime now = DateTime.Now;
if (customer.DateOfBirth > now.AddYears(-16))
return PriceGroup.Child;
else if (customer.DateOfBirth > now.AddYears(-25))
return PriceGroup.Junior;
else if (customer.DateOfBirth < now.AddYears(-65))
return PriceGroup.Senior;
else
return PriceGroup.Adult;
}
}
代码很简单,但并非潜在的测试挑战。GetCustomerPriceGroup
方法不接受 Customer
类型的实例,而是要求将客户 ID 作为参数传递,并且数据库查找发生在方法内部。因此,如果我们不使用任何 stub 或 mock,我们就必须创建一个 Customer
记录,然后将它的 ID 传递给 GetCustomerPriceGroup
方法。
另一个问题是 Customer
构造函数的可见性。它的定义如下
public class Customer
{
internal Customer()
{
}
public int CustomerID { get; internal set; }
public string FirstName { get; set; }
public string LastName { get; set; }
public DateTime DateOfBirth { get; set; }
}
我们的测试将在不同的程序集中编写,因此我们无法直接创建 Customer
对象。它应该只从下面定义的 DataLayer
对象实例化
public class DataLayer
{
public int CreateCustomer(string firstName, string lastName, DateTime dateOfBirth)
{
throw new Exception("Unable to connect to a database");
}
public Customer GetCustomer(int customerID)
{
throw new Exception("Unable to connect to a database");
}
internal void OpenConnection()
{
throw new Exception("Unable to connect to a database");
}
internal void CloseConnection()
{
throw new Exception("Unable to connect to a database");
}
}
当然,真正的 DataLayer
定义不会抛出异常:它会执行一系列已知的步骤:获取连接字符串、连接到数据库、实现 IDisposable
接口、执行 SQL 查询以及检索结果。但对我们来说,这并不重要,因为我们的工作目的是在不连接数据库的情况下编写和运行测试代码。因此,我实例化并执行 SqlCommand
还是简单地抛出异常,对我来说没有区别:我们不期望数据库可达,我们还没有创建所需的表和存储过程,任何执行 SQL 查询的尝试都会失败。所以,让我们不要关注数据库代码,我们将假设其他开发人员会修复它。
2. 集成测试
现在我们可以测试 GetCustomerPriceGroup
方法了。测试正文可能看起来像这样
var carInsurance = new CarInsurance();
PriceGroup priceGroup = carInsurance.GetCustomerPriceGroup(1);
Assert.AreEqual(PriceGroup.Adult, priceGroup, "Incorrect price group");
不行,那样不行。请注意传递给 GetCustomerPriceGroup
方法的硬编码的客户 ID。我们必须先创建一个属于成年人价格组的客户,然后将 CreateCustomer
返回的 ID 传递给 GetCustomerPriceGroup
调用。数据创建 API 通常与数据检索 API 分开,因此在一个较大的系统中,CreateCustomer
方法可能位于不同的程序集中。甚至更糟——出于安全原因,我们甚至无法使用它。如果它可用,我们就必须学习新的 API,仅仅为了在我们的测试代码中使用。这使我们的注意力从主要工作——编写和测试 CarInsurance
类——转移开。
集成测试代码如下
[Test]
public void GetCustomerPriceGroup_Adult()
{
var dataLayer = new DataLayer();
int customerID = dataLayer.CreateCustomer("John",
"Smith", new DateTime(1970, 1,
1, 0, 0, 0));
var carInsurance = new CarInsurance();
PriceGroup priceGroup = carInsurance.GetCustomerPriceGroup(customerID);
Assert.AreEqual(PriceGroup.Adult, priceGroup, "Incorrect price group");
}
如果我们编译并运行这个测试,我们将得到以下输出
TestCase 'UnitTests.CarInsuranceTest1_Integration.GetCustomerPriceGroup_Adult'
failed: System.Exception : Unable to connect to a database
C:\Projects\NET\TypeMockAAA\DataLayer.cs(12,0): at Customers.DataLayer.CreateCustomer(
String firstName, String lastName, DateTime dateOfBirth)
C:\Projects\NET\TypeMockAAA\UnitTests\CarInsuranceTest1.cs(19,0):
at UnitTests.CarInsuranceTest1_Integration.GetCustomerPriceGroup_Adult()
不是很令人鼓舞,是吗?我们花了一些时间学习客户创建 API,扩展了我们的代码来创建一个客户记录——结果却发现数据库不可用。有些开发人员可能会反对说,这不是一个糟糕的体验:我们需要在某个层级进行集成测试。是的,但我们应该将任何测试都写成集成测试吗?我们正在测试一个非常简单的计算算法。这项工作是否应该包括设置数据库及其连接字符串,以及学习一个我们以前从未用过的客户创建 API?
我必须承认,我们公司中有许多(实际上太多了)测试是像上面那样编写的:它们是集成测试。这可能也是我们项目平均测试代码覆盖率仍然保持在 50-60% 的原因之一:我们没有时间覆盖所有代码。编写集成测试需要额外的努力,而修复损坏的集成测试则需要更长的时间。当数百个集成测试由于不同原因失败时,这很有用。当它们都因一个地方的更改而失败时,这会变得很乏味。因此,我们可以得出几个结论
- 编写集成测试需要学习额外的 API。它可能还需要引用额外的程序集。这使得开发人员的工作效率降低,测试代码也更加复杂。
- 运行集成测试需要设置和配置对外部资源(如数据库)的访问权限。
在下一节中,我们将看到如何避免这种情况。
3. 使用传统 Mock 进行单元测试
既然我们知道在实际目的是测试一小段代码时编写集成测试不是一个好主意,让我们来看看我们能做什么。一种方法是让 DataLayer
类继承 IDataLayer
接口,然后实现一个派生自 IDataLayer
的 stub,它将返回一个我们选择的 Customer
对象。请注意,我们不仅需要改变 DataLayer
的实现,我们还需要改变 Customer
构造函数的可见性,它目前被定义为 internal
。虽然这显然会使我们的设计更易于测试,但它不一定会使设计更好。我完全支持使用接口,但不支持在没有重要原因的情况下放宽可见性限制。但类的可测试性还不够重要吗?我不确定。请记住,使类实例化公共化并不仅仅是为了测试。它也可能被滥用。幸运的是,从 .NET 2.0 开始,程序集属性 InternalsVisibleTo
可以为仅明确选择的程序集打开内部定义。
无论如何,我们将采取另一种方法:我们将使用 Mock。无需更改其他类的实现,无需定义新的接口。使用 TypeMock 框架,我们测试的新版本将如下所示
public void GetCustomerPriceGroup_Adult()
{
Customer customer = MockManager.MockObject<Customer>().Object;
customer.DateOfBirth = new DateTime(1970, 1, 1, 0, 0, 0);
using (RecordExpectations recorder = new RecordExpectations())
{
var dataLayer = new DataLayer();
recorder.ExpectAndReturn(dataLayer.GetCustomer(0), customer);
}
var carInsurance = new CarInsurance();
PriceGroup priceGroup = carInsurance.GetCustomerPriceGroup(0);
Assert.AreEqual(PriceGroup.Adult, priceGroup, "Incorrect price group");
}
正如你所见,我们记录了关于 GetCustomer
方法行为的期望。它将返回一个具有我们期望属性的 Customer
对象,因此我们可以使用这个对象来测试 GetCustomerPriceGroup
。
我们编译并运行测试,结果如下
TestCase 'UnitTests.CarInsuranceTest2_NaturalMocks.GetCustomerPriceGroup_Adult'
failed: System.Exception : Unable to connect to a database
C:\Projects\NET\TypeMockAAA\DataLayer.cs(22,0): at Customers.DataLayer.OpenConnection()
C:\Projects\NET\TypeMockAAA\CarInsurance.cs(13,0):
at Customers.CarInsurance.GetCustomerPriceGroup(Int32 customerID)
C:\Projects\NET\TypeMockAAA\UnitTests\CarInsuranceTest2.cs(31,0):
at UnitTests.CarInsuranceTest2_NaturalMocks.GetCustomerPriceGroup_Adult()
at TypeMock.VerifyMocksAttribute.Execute()
at TypeMock.MethodDecorator.e()
at TypeMock.MockManager.a(String A_0, String A_1, Object A_2, Object A_3,
Boolean A_4, Object[] A_5)
at TypeMock.InternalMockManager.getReturn(Object that, String typeName,
String methodName, Object methodParameters, Boolean isInjected)
C:\Projects\NET\TypeMockAAA\UnitTests\CarInsuranceTest2.cs(20,0):
at UnitTests.CarInsuranceTest2_NaturalMocks.GetCustomerPriceGroup_Adult()
仍然是同一个异常!无法连接到数据库。这是因为我们不能仅仅设置一个返回一个期望对象的方法的期望,我们必须记录 Mock DataLayer
对象上所有调用链。所以我们需要添加打开和关闭数据库连接的调用。一个修改后的(也是第一个成功的)测试版本如下
[Test]
[VerifyMocks]
public void GetCustomerPriceGroup_Adult()
{
Customer customer = MockManager.MockObject<Customer>().Object;
customer.DateOfBirth = new DateTime(1970, 1, 1, 0, 0, 0);
using (RecordExpectations recorder = new RecordExpectations())
{
var dataLayer = new DataLayer();
dataLayer.OpenConnection();
recorder.ExpectAndReturn(dataLayer.GetCustomer(0), customer);
dataLayer.CloseConnection();
}
var carInsurance = new CarInsurance();
PriceGroup priceGroup = carInsurance.GetCustomerPriceGroup(0);
Assert.AreEqual(PriceGroup.Adult, priceGroup, "Incorrect price group");
}
顺便说一下,我们不得不添加一个程序集属性 InternalsVisibleTo
来授予对方法 OpenConnection
和 CloseConnection
的访问权限,而这些方法并不是公共的。
使用 Mock 对象帮助我们从数据库依赖项中隔离了要测试的代码。然而,它并没有完全将代码与连接到数据库的类隔离。此外,如果你查看 RecordExpections
块中的测试代码,你很容易认出原始 GetCustomerPriceGroup
方法代码的一部分。这有代码重复的迹象,以及其臭名昭著的后果。我们可以得出以下结论
- Mocking 需要了解 Mock 对象的行为,而这在编写单元测试时本不应是必要的。
- 设置行为期望序列需要从原始代码中复制调用链。它不会给你带来更健壮的测试环境,恰恰相反——它需要额外的代码维护。
4. 使用隔离进行单元测试
那么,在上述场景中我们可以改进什么?显然,我们不想消除对 GetCustomer
的调用,因为正是从这个方法中,我们想要获得一个具有特定状态的客户。但这是唯一让我们感兴趣的方法,在给定上下文中,DataLayer
中的其他所有内容对我们来说都是不相关的。我们能否只引用 DataLayer
类中的 GetCustomer
方法来编写测试?
是的,我们可以,在 Mock 框架的一些帮助下。如前所述,我们公司使用的是 TypeMock Isolator,它最近已升级为支持我们正在尝试实现的功能。其工作原理如下
- 创建一个具有所需属性的
Customer
对象实例。 - 创建一个
DataLayer
对象的 fake 实例。 - 设置对
GetCustomer
的调用的行为,使其返回先前创建的Customer
对象。
这与我们在前面部分使用的方法有什么不同?区别在于,我们不再需要关心对 DataLayer
对象进行的额外调用——只需要关心影响我们测试中使用的状态的调用。代码如下
[Test]
[Isolated]
public void GetCustomerPriceGroup_Adult()
{
var customer = Isolate.Fake.Instance<Customer>();
Isolate.WhenCalled(() => customer.DateOfBirth).WillReturn(new DateTime(1970, 1,
1, 0, 0, 0));
var dataLayer = Isolate.Fake.Instance<Datalayer>();
Isolate.SwapNextInstance<Datalayer>().With(dataLayer);
Isolate.WhenCalled(() => dataLayer.GetCustomer(0)).WillReturn(customer);
var carInsurance = new CarInsurance();
PriceGroup priceGroup = carInsurance.GetCustomerPriceGroup(0);
Assert.AreEqual(PriceGroup.Adult, priceGroup, "Incorrect price group");
}
请注意,前几行 fake 一个 Customer
对象是必需的,因为 Customer
构造函数不是公共的,否则我们可以直接创建它的实例。因此,至关重要的几行是创建 DataLayer
fake、将下一个实例的创建替换为 fake 对象,然后设置对 GetCustomer
返回值的期望。在文章发布后,我收到一条评论,提出了一个如何完全消除 Customer
实例创建的建议:由于对客户状态的唯一期望是他的/她的出生日期,因此可以立即设置它,而无需先实例化 Customer
对象
[Test]
[Isolated]
public void GetCustomerPriceGroup_Adult()
{
var dataLayer = Isolate.Fake.Instance<Datalayer>();
Isolate.SwapNextInstance<Datalayer>().With(dataLayer);
Isolate.WhenCalled(() => dataLayer.GetCustomer(0).DateOfBirth).WillReturn(
new DateTime(1970, 1, 1, 0, 0, 0));
var carInsurance = new CarInsurance();
PriceGroup priceGroup = carInsurance.GetCustomerPriceGroup(0);
Assert.AreEqual(PriceGroup.Adult, priceGroup, "Incorrect price group");
}
但是 OpenConnection
和 CloseConnection
怎么办?它们被忽略了吗?如果它们返回了值怎么办?它们会返回什么值?
这完全取决于 fake 的创建方式。Isolate.Fake.Instance
有一个重载,它接受一个 fake 创建模式作为参数。可能的模式由 Members
枚举表示
MustSpecifyReturnValues
– 这是代码中使用的默认值。所有 void 方法都被忽略,有返回值的方法的值必须使用WhenCalled
指定,就像我们做的那样。如果未指定返回值,尝试执行有返回值的调用将导致异常。CallOriginal
– 这是使 Mock 对象像未被 Mock 一样运行的模式,除了使用WhenCalled
指定的情况。ReturnNulls
– 所有 void 方法都被忽略,非 void 的方法将返回 null 或零。ReturnRecursiveFakes
– 可能是隔离中最有用的模式。所有 void 方法都被忽略,有返回值的将返回 fake 值,除非使用WhenCalled
设置了特定值。此行为是递归应用的。
为了更好地理解它是如何工作的,让我们来玩一下我们的测试代码。我们首先删除对 Customer
状态的期望
[Test]
[Isolated]
public void GetCustomerPriceGroup_Adult()
{
var dataLayer = Isolate.Fake.Instance<Datalayer>();
Isolate.SwapNextInstance<Datalayer>().With(dataLayer);
var carInsurance = new CarInsurance();
PriceGroup priceGroup = carInsurance.GetCustomerPriceGroup(0);
Assert.AreEqual(PriceGroup.Adult, priceGroup, "Incorrect price group");
}
你认为这里应该发生什么?让我们看看。OpenConnection
和 CloseConnection
将被忽略。GetCustomer
不能被忽略,因为它返回一个值。由于我们没有指定任何 fake 创建模式,将使用默认模式 MustSpecifyReturnValues
。因此,我们必须为 GetCustomer
指定一个返回值。但我们没有。如果我们运行测试,会发生以下情况
TestCase 'UnitTests.CarInsuranceTest3_ArrangeActAssert.GetCustomerPriceGroup_Adult'
failed: TypeMock.VerifyException :
TypeMock Verification: Unexpected Call to Customers.DataLayer.GetCustomer()
at TypeMock.MockManager.a(String A_0, String A_1, Object A_2, Object A_3, Boolean A_4,
Object[] A_5)
at TypeMock.InternalMockManager.getReturn(Object that, String typeName,
String methodName, Object methodParameters, Boolean isInjected, Object p1)
C:\Projects\NET\TypeMockAAA\DataLayer.cs(16,0): at Customers.DataLayer.GetCustomer(
Int32 customerID)
C:\Projects\NET\TypeMockAAA\CarInsurance.cs(14,0):
at Customers.CarInsurance.GetCustomerPriceGroup(Int32 customerID)
C:\Projects\NET\TypeMockAAA\UnitTests\CarInsuranceTest3.cs(42,0):
at UnitTests.CarInsuranceTest3_ArrangeActAssert.GetCustomerPriceGroup_Adult ()
at TypeMock.MethodDecorator.CallRealMethod()
at TypeMock.DecoratorAttribute.CallDecoratedMethod()
at TypeMock.ArrangeActAssert.IsolatedAttribute.Execute()
at TypeMock.MethodDecorator.e()
at TypeMock.MockManager.a(String A_0, String A_1, Object A_2, Object A_3,
Boolean A_4, Object[] A_5)
at TypeMock.InternalMockManager.getReturn(Object that, String typeName,
String methodName, Object methodParameters, Boolean isInjected)
C:\Projects\NET\TypeMockAAA\UnitTests\CarInsuranceTest3.cs(37,0):
at UnitTests.CarInsuranceTest3_ArrangeActAssert.GetCustomerPriceGroup_Adult ()
这很公平,不是吗?任何对有返回值的方法的调用都是意外的——必须先分配值。
但是,如果我们做同样的事情,并将 fake 创建模式设置为 ReturnRecursiveFakes
呢?在这种情况下,Isolator 将不得不创建一个 Customer
对象实例,该实例将由 DataLayer.GetCustomer
返回。我们来试试
[Test]
[Isolated]
public void GetCustomerPriceGroup_Adult()
{
var dataLayer = Isolate.Fake.Instance<Datalayer>(Members.ReturnRecursiveFakes);
Isolate.SwapNextInstance<Datalayer>().With(dataLayer);
var carInsurance = new CarInsurance();
PriceGroup priceGroup = carInsurance.GetCustomerPriceGroup(0);
Assert.AreEqual(PriceGroup.Adult, priceGroup, "Incorrect price group");
}
这是输出
TestCase 'UnitTests.CarInsuranceTest3_ArrangeActAssert.GetCustomerPriceGroup_Adult'
failed:
Incorrect price group
Expected: Adult
But was: Senior
C:\Projects\NET\TypeMockAAA\UnitTests\CarInsuranceTest3.cs(55,0):
at UnitTests.CarInsuranceTest3_ArrangeActAssert.GetCustomerPriceGroup_Adult()
at TypeMock.MethodDecorator.CallRealMethod()
at TypeMock.DecoratorAttribute.CallDecoratedMethod()
at TypeMock.ArrangeActAssert.IsolatedAttribute.Execute()
at TypeMock.MethodDecorator.e()
at TypeMock.MockManager.a(String A_0, String A_1, Object A_2, Object A_3, Boolean A_4,
Object[] A_5)
at TypeMock.InternalMockManager.getReturn(Object that, String typeName,
String methodName, Object methodParameters, Boolean isInjected)
C:\Projects\NET\TypeMockAAA\UnitTests\CarInsuranceTest3.cs(49,0):
at UnitTests.CarInsuranceTest3_ArrangeActAssert.GetCustomerPriceGroup_Adult()
这很有趣。为什么我们得到了一个老年客户?因为 Isolator 创建了一个具有默认属性的 Customer
实例,因此将 DateOfBirth
设置为 01.01.0001
。这么老的客户怎么会被当作老年人?
结论:写更少的测试代码,写好的测试代码
我们已经研究了编写测试代码的不同方法:集成测试、基于期望行为记录的单元测试,以及隔离被测类与代码执行的任何无关方面的单元测试。我相信后一种方法在许多情况下是赢家:它允许你以轻量级、直接的方式设置你不感兴趣测试的对象的状态。它不仅让你专注于正在测试的功能,还为你节省了将来更新因设计更改而受影响的记录行为所需的时间。
我知道我冒着过度简化的风险。如果你还没有阅读 Martin Fowler 的文章 “Mocks Aren’t Stubs”,你可以在那里找到术语的定义以及选择它的原因。但恕我直言,考虑到问题的复杂性,我认为我们不应忽视如此简单的标准,如测试代码的简洁性和可维护性,以及使用原始代码“照原样”进行测试,而不对其进行修改以提高可测试性。这些都是强有力的论据,它们证明了本文所示的轻量级“arrange-act-assert”方法的合理性。
我认为我们现在看到的是测试方法论新阶段的开始:将与生产代码相同的严格规则应用于测试代码的质量。理论上,相同的开发人员应该以相同的质量编写生产代码和测试代码。实际上,测试代码一直是妥协的牺牲品。例如,看看开发人员对低代码覆盖率的接受程度:他们可能目标是 80% 的代码覆盖率,但如果只达到 60% 也不会推迟发布。你能想象他们未经管理层批准就发布一款功能减少 20% 的产品吗?
这种态度有实际甚至道德上的理由。在实际方面,测试代码被视为二等代码。这段代码不应该被部署,它是内部使用的,所以得到的关注较少。在道德方面,当自动化测试如此被低估时,每个开发人员编写自动化测试的努力都应该被珍视。批评一个编写测试的开发人员写了糟糕的测试,就像批评一个虐待动物的斗士不聪明地战斗一样。但我们必须抛开关于单元测试的所有浪漫主义。它必须遵守明确的软件开发规则。其中一条规则是“写更少的代码”。
测试驱动开发早期阶段起到积极作用,现在应该被视为有害的说法是“测试再多也不为过”。是的,可能会。多年来,我们一直在为由多个复杂层和难以或不可能从测试环境中访问的外部集成点组成的系统编写测试。我们试图以某种方式命中逻辑错误,通常都成功了。然而,就我个人而言,我注意到我对待捕获第一个错误的方式变得宽松了:在我专门用于测试给定函数的代码中。这是一种危险的态度。它降低了覆盖每个类的单元测试以验证其行为所有方面的努力。“如果 A 的单元测试未能暴露所有错误,那么 B 的单元测试可能会暴露它们。在最坏的情况下,它们将由集成测试暴露。”我见过很多次,当开发人员需要查找生产中发生的错误时,他们设法编写了新的集成测试(生产错误通常更容易从集成测试中重现),然后他们在调试器中跟踪这个测试,识别出有问题模块,并在那里修复了错误。修复后,他们确保集成测试成功,并认为问题已解决。他们没有为受影响的模块编写新的单元测试。“为什么?我们已经写了一个。”一段时间后,系统被数千个测试覆盖,但有时仍有可能在不破坏其专用测试的情况下在模块中引入错误。
很长一段时间以来,这很难责怪。人们对每日构建、夜间测试、类似红绿灯的测试运行器感到非常兴奋,以至于一些开发人员甚至设法将它们连接到大型工业 LED 板上,以便每个访客都能知道谁打破了上一次构建。这很棒,而且并没有结束。我们只需要开始衡量测试工作的效率。如果为测试模块 A 编写的单元测试暴露了模块 B 中的一个错误,而为测试 B 编写的单元测试没有暴露这个错误,那么你就有一些工作要做。如果一个逻辑错误被集成测试暴露了,你也需要做一些工作。集成测试只应该暴露与配置、数据库和与外部资源通信相关的问题。其他所有内容都应该由专门用于验证相应功能的单元测试来处理。重复测试代码是一种坏习惯。将许多依赖项引入测试模块是一种坏习惯。让测试代码了解与被测代码使用的状态无关的任何内容都是一种坏习惯。而这正是 TDD 工具和框架的最新发展可以为我们提供巨大帮助的地方。我们只需要开始改变我们的旧习惯。
参考文献
- Roy Osherove 的 《告别 Mock,告别 Stub》
- Martin Fowler 的 《Mock 并非 Stub》