2.3 纯度和可测试性
在上一节中,你看到了纯函数在并发情况下的特性。 因为副作用与状态突变有关,你可以去掉突变,得到的纯函数可以顺利地并行运行。
现在我们来看看纯函数在单元测试方面的属性,以及在副作用与I/O有关的情况下。与突变不同,你不能避免与I/O有关的影响;突变是一个实现细节,而I/O通常是一个需求。
您可能已经对单元测试有所了解,可以帮助您理解纯度,因为两者是紧密联系在一起的。与上一节一样,本节也应该有助于消除纯度仅具有理论意义的观念——您的经理可能不在乎您是否编写纯函数,但他可能热衷于良好的测试覆盖率。
2.3.1 实践中:验证场景
我将首先介绍科德兰银行(BOC)的一个假想的网上银行应用的一些代码。网上银行的一个常见功能是允许用户进行资金转移,所以我们将从这个开始。 想象一下,客户可以使用网络或移动客户端来请求转账,如图2.2所示。在预订转账之前,服务器必须验证这个请求。
假设用户的转账请求由 MakeTransfer 命令表示。命令是一个简单的数据对象,它封装了有关要执行的操作的详细信息:
public abstract class Command { }
public sealed class MakeTransfer : Command
{
public Guid DebitedAccountId { get; set; }
public string Beneficiary { get; set; }
public string Iban { get; set; }
public string Bic { get; set; }
public decimal Amount { get; set; }
public DateTime Date { get; set; }
}
这种情况下的验证可能相当复杂,所以在本解释中,我们只看以下的验证:
- 日期(Date)字段代表转让应执行的日期,不应该是过去。
- BIC代码是受益人银行的标准标识符,应该是有效的。
我们将遵循单一责任原则,为每个特定的验证器写一个类。让我们为所有这些验证器类起草一个简单的接口:
public interface IValidator<T> {
bool IsValid(T t);
}
现在我们已经有了特定领域的抽象,让我们从一个基本实现开始:
using System.Text.RegularExpressions;
public sealed class BicFormatValidator: IValidator < MakeTransfer > {
static readonly Regex regex = new Regex("^[A-Z]{6}[A-Z1-9]{5}$");
public bool IsValid(MakeTransfer cmd) => regex.IsMatch(cmd.Bic);
}
public class DateNotPastValidator: IValidator < MakeTransfer > {
public bool IsValid(MakeTransfer cmd) => (DateTime.UtcNow.Date <= cmd.Date.Date);
}
那是相当容易的。BicFormatValidator的逻辑是纯粹的吗?是的,因为没有副作用,IsValid的结果是确定的。那DateNotPast-Validator呢? 在这种情况下,IsValid的结果将取决于当前的日期,所以,很明显,答案是否定的!我们面临着什么样的副作用?我们在这里面临什么样的副作用?它是I/O。DateTime.UtcNow查询系统时钟,这不在程序的上下文中。
执行 I/O 的函数很难测试。例如,下面的测试在我写这篇文章时通过了,但它会在 2016 年 12 月 13 日开始失败:
[Test]
public void WhenTransferDateIsFuture_ThenValidationPasses() {
var transfer = new MakeTransfer {
Date = new DateTime(2016, 12, 12)
};
var validator = new DateNotPastValidator();
var actual = validator.IsValid(transfer);
Assert.AreEqual(true, actual);
}
接下来,我们将研究解决此问题的不同方法,并使您的单元测试可预测。
2.3.2 测试不纯函数
确保单元测试行为一致的标准面向对象 (OO) 技术是在接口中抽象 I/O 操作,并在测试中使用确定性实现。我将其称为基于接口的方法;它被认为是一种最佳实践,但由于它需要大量样板文件,我开始将其视为一种反模式。如果您已经熟悉这种方法,则可以跳到下一小节。
在这种方法中,不是直接调用 DateTime.UtcNow,而是像这样抽象访问系统时钟:
public interface IDateTimeService {
//将不纯行为封装在接口中
DateTime UtcNow { get; }
}
public class DefaultDateTimeService: IDateTimeService {
//提供默认实现
public DateTime UtcNow => DateTime.UtcNow;
}
然后重构日期验证器以使用此接口,而不是直接访问系统时钟。验证器的行为现在取决于接口,它的实例应该被注入(通常在构造函数中),如下所示:
public class DateNotPastValidator: IValidator < MakeTransfer > {
private readonly IDateTimeService clock;
//接口被注入到构造函数中。
public DateNotPastValidator(IDateTimeService clock) {
this.clock = clock;
}
//验证现在取决于接口
public bool IsValid(MakeTransfer request) => clock.UtcNow.Date <= request.Date.Date;
}
关于琐碎的构造函数 这个新引入的构造函数所做的就是在字段中存储其输入参数。许多语言通过拥有 "主要构造函数 "来免除这种仪式–我们希望在C#的某个未来版本中看到这一特性。
为了专注于有意义的代码,在本书的其余部分,我通常会省略这些琐碎的构造函数。你应该假定类的字段总是在构造函数中被注入和设置,除非它们被内联设置。
我们来看看重构后的IsValid方法:它是一个纯函数吗?好吧,答案是,这取决于!当然,这取决于插入的IDateTimeService的实现。当然,这取决于所注入的IDateTimeService的实现:
- 当正常运行时,您将组合您的对象,以便您获得检查系统时钟的“真实”不纯实现。
- 在运行单元测试时,您将注入一个“假”纯实现,它执行一些可预测的操作,例如始终返回相同的 DateTime,从而使您能够编写可重复的测试。
使用这种方法,可以以这种形式编写测试:
public class DateNotPastValidatorTest {
static DateTime presentDate = new DateTime(2016, 12, 12);
//提供纯纯粹的假的实现
private class FakeDateTimeService: IDateTimeService {
public DateTime UtcNow => presentDate;
}
[Test]
public void WhenTransferDateIsPast_ThenValidationFails() {
//注入假的依赖
var sut = new DateNotPastValidator(new FakeDateTimeService());
var cmd = new MakeTransfer {
Date = presentDate.AddDays(-1)
};
Assert.AreEqual(false, sut.IsValid(cmd));
}
}
如您所见,可测试性和函数纯度之间有很强的联系:单元测试需要隔离(无 I/O)和可重复(给定相同的输入,总是得到相同的结果)。当您使用纯函数时,这些属性得到保证。
接下来,让我们以函数的眼光来看代码和单元测试,看看是否有改进的空间。
2.3.3 为什么测试不纯函数很困难
当你写单元测试时,你在测试什么? 当然是一个单元,但单元到底是什么?无论你测试的是什么单元,都是一个函数,或者可以被看作是一个函数。
如果你测试的实际上是一个纯函数,测试很容易:你只需给它一个输入并验证输出是否符合预期,如图 2.3 所示。如果您在单元测试中使用标准的排列行为断言 (AAA) 模式,并且您重新测试的单元是一个纯函数,那么排列步骤包括定义输入值,行为步骤是函数调用,以及断言步骤包括检查输出是否符合预期。
如果你对一组有代表性的输入值这样做,你就可以相信该函数能按预期工作。
另一方面,如果您正在测试的单元是一个不纯的函数,它的行为不仅取决于它的输入,还可能取决于程序的状态(即,任何不属于函数局部的可变状态)测试)和世界状态(程序上下文之外的任何内容)。此外,函数的副作用可能会导致程序和世界的新状态。例如,
- 日期验证器取决于世界的状态,特别是当前时间。
- 发送电子邮件的返回空值的方法没有明确的输出来 assert against,但它会导致一个新的世界状态。
- 设置非局部变量的方法会导致程序的新状态。
因此,您可以将不纯函数视为纯函数,它将其参数以及程序和世界的当前状态作为输入,并返回其输出以及程序和世界的新状态,如图所示在图 2.4 中。
另一种看法是,一个不纯的函数除了它的参数外,还有隐含的输入,或者除了它的返回值外还有隐含的输出,或者两者都有。
这对测试有什么影响?在不纯函数的情况下,安排阶段不仅必须为被测函数提供明确的输入,还必须另外设置程序和世界状态的表示。同样地,断言阶段不仅要检查结果,还要检查程序和世界的状态是否发生了预期的变化。这在表2.2中作了总结。
表 2.2 功能角度的单元测试
AAA pattern | 函数式思维 |
---|---|
Arrange | 设置被测函数的(显式和隐式)输入 |
Act | 评估被测函数 |
Assert | 验证(显式和隐式)输出的正确性 |
同样,我们应该在测试方面区分不同种类的副作用:
- 世界的状态是通过使用模拟创建一个运行测试的人工世界来管理的。这是一项艰苦的工作,但技术很好理解。这表明您可以测试依赖于 I/O 操作的代码。
- 设置程序的状态并检查它是否正确更新不需要mocks,但它会导致脆弱的测试并破坏封装。
2.3.4 参数化单元测试
单元测试可以被参数化,这样你就可以证明你的测试通过了各种输入值。参数化测试往往更具函数性,因为它们让您根据输入和输出进行思考。
例如,您可以测试 date-not-past 验证在各种情况下是否有效,如下所示。
清单 2.6 参数化测试允许您在各种情况下检查代码
public class DateNotPastValidatorTest {
static DateTime presentDate = new DateTime(2016, 12, 12);
private class FakeDateTimeService: IDateTimeService {
public DateTime UtcNow => presentDate;
}
[TestCase(+1, ExpectedResult = true)]
[TestCase(0, ExpectedResult = true)]
[TestCase(-1, ExpectedResult = false)]
public bool WhenTransferDateIsPast_ThenValidatorFails(int offset) {
var sut = new DateNotPastValidator(new FakeDateTimeService());
var cmd = new MakeTransfer {
Date = presentDate.AddDays(offset)
};
return sut.IsValid(cmd);
}
}
前面的代码使用 NUnit 的 TestCase 属性来有效地运行三个测试:请求在今天(2016 年 12 月 12 日)、昨天和明天进行的传输。
参数化测试的优势在于您可以通过调整参数值来测试各种场景。客户是否应该能够要求两年后的日期转移?如果是这样,您可以用一行添加一个测试:
[TestCase(730, ExpectedResult = true)]
请注意,现在测试方法本身就是一个函数;它将给定的参数值映射到 NUnit 可以检查的输出。参数化测试本质上只是被测函数的适配器。在此示例中,该测试使用硬编码的当前数据创建了一个人工世界状态,并将测试的输入参数(当前日期和请求的传输日期之间的偏移量)映射到适当填充的 MakeTransfer 对象,该对象作为输入提供给被测功能。
我希望您逐渐看到函数式思维如何为日常开发任务(例如编写单元测试)注入新鲜空气。
2.3.5 避免头接口
在前面的部分中,您看到了使用依赖注入和mock来测试不纯函数的标准的、基于接口的方法。我用日期和余额验证器展示了这种做法,并且可以按照以下步骤系统地使用这种方法:
- 定义一个接口(例如 IDateTimeService),它抽象了在被测类中使用的不纯操作。
- 将不纯的实现(例如 DateTime.UtcNow)放在实现该接口的类(例如 DefaultDateTimeService)中。
- 在被测类中,在构造函数中 require 接口,将其存储在一个字段中,并根据需要使用它。
- 引入一些引导逻辑(手动或在框架的帮助下),以便在实例化被测类时注入正确的实现。
- 为单元测试的目的创建并注入一个虚假的实现。
单元测试非常有价值,开发人员很乐意接受所有这些工作,即使是像 DateTime.UtcNow 这样简单的测试。
系统地使用这种方法的最不理想的效果之一是接口数量的爆炸式增长,因为您必须为每个具有 I/O 元素的组件定义一个接口。大多数应用程序目前都为每个服务开发了一个接口,即使只设想了一个具体的实现。这些被称为“头接口”——它们不是最初设计的接口(具有几种不同实现的通用合同),但它们“全面重复使用。你最终会得到更多的文件、更多的间接性、更多的程序集和难以导航的代码。
在本节中,我将向您展示更简单的替代方案。
向外推进边界
我们能摆脱整个问题,让一切都变得纯洁吗?不。但有时我们可以突破纯代码的界限。例如,如果您按如下方式重写 datevalidator 会怎样?
清单 2.7 注入一个特定的值,而不是一个接口,使 IsValid 纯粹化
public class DateNotPastValidator: IValidator < MakeTransfer > {
private readonly DateTime today;
public DateNotPastValidator(DateTime today) {
this.today = today;
}
publicbool IsValid(MakeTransfer cmd) => (today <= cmd.Date.Date);
}
不是注入一个接口,而是暴露一些你可以调用的方法,注入一个值。现在 IsValid 的实现是纯粹的(因为今天是不可变的)。
你已经有效地将向外读取当前日期的副作用推到了实例化验证器的代码上。
现在,任何实例化 DateNotPastValidator 的代码都必须知道如何获取当前时间。此外,DateNotPastValidator 必须是短暂的。在这种情况下,这些约束似乎是合理的:验证器可以基于每个请求进行实例化,并且实例化代码可以提供时间。
请求一个值,而不是让方法或接口提供该值,很容易获胜,使您的代码更加纯净,从而易于测试。这种方法适用于配置和特定于环境的设置。但事情很少这么简单,所以让我们继续看一个更接近典型场景的例子。
注入函数的依赖
我之前展示了一个简单的验证器,用于检查 BIC 代码的格式是否正确。在实践中,大多数在线银行应用程序做得比这更好:它们检查 BIC 代码是否确实识别了现有银行。为此,您需要一个验证器,它可以获取有效代码列表并检查该列表是否包含传输命令中的代码:
public sealed class BicExistsValidator: IValidator < MakeTransfer > {
readonly IEnumerable < string > validCodes;
public bool IsValid(MakeTransfer cmd) => validCodes.Contains(cmd.Bic);
}
自然地,有效代码列表随着银行建立新分行或关闭现有分行而变化,因此获取当前有效代码是一种不纯操作,涉及对某些外部系统的查询或从可变状态读取。
你能要求在构造函数中注入有效代码列表吗?我认为你不能,因为在那种情况下,谁来负责检索代码?
- 客户端代码依赖于验证器,所以它当然不应该做验证器的工作。
- 实例化代码不知道何时或是否会使用验证器。也许某些先前的验证会失败,并且从不需要有效代码。
无论哪种情况,你都会违反单一职责原则。当然,您可以使用基于接口的方法并注入一些可以从中获取有效代码列表的存储库,但是,正如我所展示的,这涉及到相当乏味的过程。只需要一个可以调用的函数来查询代码怎么样?
public sealed class BicExistsValidator: IValidator < MakeTransfer > {
readonly Func < IEnumerable < string >> getValidCodes;
public BicExistsValidator(Func < IEnumerable < string >> getValidCodes) {
this.getValidCodes = getValidCodes;
}
publicbool IsValid(MakeTransfer cmd) => getValidCodes().Contains(cmd.Bic);
}
此解决方案符合所有条件。现在您不需要定义任何不必要的接口,并且 BicExistsValidator 除了调用 getValidCodes 引起的副作用之外没有其他副作用。这意味着您仍然可以轻松编写单元测试:
public class BicExistsValidatorTest {
static string[] validCodes = {
"ABCDEFGJ123"
};
[TestCase("ABCDEFGJ123", ExpectedResult = true)]
[TestCase("XXXXXXXXXXX", ExpectedResult = false)]
//注入一个确定性地返回一个硬编码值的函数
public bool WhenBicNotFound_ThenValidationFails(string bic)
=> new BicExistsValidator(() => validCodes).IsValid(new MakeTransfer { Bic = bic });
}
请记住,函数签名是一个接口;本质上,在这种基于函数的依赖注入方法中,一个类声明了它所依赖的函数。这相当于声明它只依赖于一个方法的接口,但没有头接口的噪音。我们将在第 7 章进一步探讨这种方法。