如果你使用过单元测试,可能已经使用过依赖注入来解耦对象并在测试它们时控制它们的行为。 可能已经将模拟或存根注入到被测系统中,以便定义可重复的、确定性的单元测试。
这样的测试可能如下所示:
[Fact]
public async Task AcceptWhenInnerManagerAccepts()
{
var r = new Reservation(
DateTime.Now.AddDays(10).Date.AddHours(18),
"x@example.com",
"",
1);
var mgrTD = new Mock<IReservationsManager>();
mgrTD.Setup(mgr => mgr.TrySave(r)).ReturnsAsync(true);
var sut = new RestaurantManager(
TimeSpan.FromHours(18),
TimeSpan.FromHours(21),
mgrTD.Object);
var actual = await sut.Check(r);
Assert.True(actual);
}
(此 C# 测试使用 xUnit.net 2.4.1 和 Moq 4.14.1。)
这样的测试很脆弱,会增加维护负担。
为什么内部依赖性不好
正如上面的单元测试所暗示的,RestaurantManager
依赖于注入的 IReservationsManager
依赖项。 这个接口是一个内部实现细节。 将整个应用程序想象成一个蓝色盒子,其中有两个对象作为内部组件:
应用程序包含许多内部构建块。 上图强调了两个这样的组件,以及它们如何相互作用。如果想重构应用程序代码会怎样? 重构通常涉及更改内部构建块之间的交互方式。 例如,你可能想要更改 IReservationsManager
接口。当进行这样的更改时,将破坏一些依赖于接口的代码。 这是可以预料的。 毕竟,重构涉及更改代码。
当测试还依赖于内部实现细节时,重构也会破坏测试。 现在,除了改进内部代码外,还必须修复所有损坏的测试。使用像 Moq 这样的动态模拟库往往会放大问题。 现在必须访问配置模拟的所有测试并调整它们以模拟新的内部交互。这种摩擦可能会从一开始就阻止你进行重构。 如果知道有保证的重构会给你带来很多额外的修复测试工作,你可能会认为不值得为此费心。 相反,你会让生产代码处于次优状态。
有没有更好的办法?
功能核心
为了找到更好的替代方案,必须首先了解问题所在。 为什么首先使用测试替身(模拟和存根)?测试替身有一个主要目的:它们使我们能够编写确定性的单元测试。单元测试应该是确定性的。 多次运行测试应该每次都产生相同的结果(其他条件不变)。 在星期三成功的测试不应该在星期六失败。
通过使用测试替身,每个测试都可以控制依赖项的行为方式。 在 《Working Effectively with Legacy Code 》中,Michael Feathers 将测试比作虎钳。 它是一种修复特定行为的工具。然而,测试替身并不是使测试具有确定性的唯一方法。更好的选择是使生产代码本身具有确定性。 例如,假设需要编写代码来计算截锥体的体积。 只要视锥体不变,体积就保持不变。 这样的计算是完全确定的。
主要使用确定性操作来编写生产代码。 例如,代替上面的 RestaurantManager
,可以使用如下方法编写不可变类:
public bool WillAccept(
DateTime now,
IEnumerable<Reservation> existingReservations,
Reservation candidate)
{
if (existingReservations is null)
throw new ArgumentNullException(nameof(existingReservations));
if (candidate is null)
throw new ArgumentNullException(nameof(candidate));
if (candidate.At < now)
return false;
if (IsOutsideOfOpeningHours(candidate))
return false;
var seating = new Seating(SeatingDuration, candidate.At);
var relevantReservations =
existingReservations.Where(seating.Overlaps);
var availableTables = Allocate(relevantReservations);
return availableTables.Any(t => t.Fits(candidate.Quantity));
}
此示例与本文中的所有代码一样,来自我的书《Code That Fits in Your Head》。 尽管实现了相当复杂的业务逻辑,但它是一个纯函数。 所有涉及的辅助方法(IsOutsideOfOpeningHours
、Overlaps
、Allocate
等)也是确定性的。
结果是确定性操作很容易测试。 例如,这是参数化测试的路径:
[Theory, ClassData(typeof(AcceptTestCases))]
public void Accept(MaitreD sut, DateTime now, IEnumerable<Reservation> reservations)
{
var r = Some.Reservation.WithQuantity(11);
var actual = sut.WillAccept(now, reservations, r);
Assert.True(actual);
}
此代码片段不显示测试用例数据源 (AcceptTestCases
),但它是一个小型帮助器类,可生成七个测试用例,为 sut
、now
和reservations
提供值。
这种测试方法是典型的纯函数单元测试:
1.准备输入值
2.调用函数
3.将预期结果与实际值进行比较
如果将该结构识别为 Arrange Act Assert 模式,没有错,但这不是重点。 值得注意的是,尽管业务逻辑非常重要,但不需要测试替身(即模拟或存根)。 这是纯函数的众多优点之一。 由于它们已经是确定性的,因此不必在代码中引入人工接缝来启用测试。
将大部分代码库编写为确定性函数是可能的,但需要练习。 这种编程风格称为函数式编程 (FP),虽然面向对象的程序员可能需要付出努力来改变视角,但它确实是游戏规则的改变者——既因为测试的好处,也因为其他原因。
然而,即使是最惯用的 FP 代码库也必须处理混乱的、不确定的现实世界。now
和 existingReservations
等输入值从何而来?
命令式外壳
典型的功能架构类似于端口和适配器架构。 将所有业务和应用程序逻辑实现为纯函数,并将不纯的操作推向边缘。
在边缘,而且只有在边缘,才会允许它发生。 在贯穿《Code That Fits in Your Head》 的示例代码中,这发生在控制器中。 例如,这个TryCreate
辅助方法是在 ReservationsController
类中定义的:
private async Task<ActionResult> TryCreate(
Restaurant restaurant,
Reservation reservation)
{
using var scope =
new TransactionScope(TransactionScopeAsyncFlowOption.Enabled);
var reservations = await Repository
.ReadReservations(restaurant.Id, reservation.At)
.ConfigureAwait(false);
var now = Clock.GetCurrentDateTime();
if (!restaurant.MaitreD.WillAccept(now, reservations, reservation))
return NoTables500InternalServerError();
await Repository.Create(restaurant.Id, reservation)
.ConfigureAwait(false);
scope.Complete();
return Reservation201Created(restaurant.Id, reservation);
}
TryCreate
方法使用了两个不纯的注入依赖项:Repository 和 Clock。
Repository 依赖表示存储预订的数据库,而 Clock 表示某种时钟。 这些依赖关系不是任意的。 它们的存在是为了支持应用程序的命令式 shell 的单元测试,并且它们必须被注入依赖项,因为它们是不确定性的来源。最容易理解为什么 Clock 是非确定性的来源。 每次你问现在几点了,答案都会改变。 这是不确定的,因为教科书对确定性的定义是相同的输入应该总是产生相同的输出。
相同的定义适用于数据库。 可以重复相同的数据库查询,但随着时间的推移会收到不同的输出,因为数据库的状态会发生变化。 根据确定性的定义,这使得数据库具有不确定性:相同的输入可能会产生不同的输出。你仍然可以对命令式 shell 进行单元测试,但不必使用脆弱的动态模拟对象。 相反,使用fake。
Fakes
在 xUnit 测试模式的模式语言中,fake 是一种测试替身,几乎可以充当接口的“真实”实现。 内存中的“数据库”是一个有用的例子:
public sealed class FakeDatabase :
ConcurrentDictionary<int, Collection<Reservation>>,
IReservationsRepository
在实现 IReservationsRepository
时,这个特定于测试的FakeDatabase
类继承了 ConcurrentDictionary<int, Collection<Reservation>>
,这意味着它可以利用字典基类来添加和删除保留。 这是创建实现:
public Task Create(int restaurantId, Reservation reservation)
{
AddOrUpdate(
restaurantId,
new Collection<Reservation> { reservation },
(_, rs) => { rs.Add(reservation); return rs; });
return Task.CompletedTask;
}
这是ReadReservations
的实现:
public Task<IReadOnlyCollection<Reservation>> ReadReservations(
int restaurantId,
DateTime min,
DateTime max)
{
return Task.FromResult<IReadOnlyCollection<Reservation>>(
GetOrAdd(restaurantId, new Collection<Reservation>())
.Where(r => min <= r.At && r.At <= max).ToList());
}
ReadReservations
将返回已使用Create
方法添加到存储库的预订。 当然,它只在 FakeDatabase
对象保留在内存中时才有效,但这对于单元测试来说已经足够了:
[Theory]
[InlineData(1049, 19, 00, "juliad@example.net", "Julia Domna", 5)]
[InlineData(1130, 18, 15, "x@example.com", "Xenia Ng", 9)]
[InlineData( 956, 16, 55, "kite@example.edu", null, 2)]
[InlineData( 433, 17, 30, "shli@example.org", "Shanghai Li", 5)]
public async Task PostValidReservationWhenDatabaseIsEmpty(
int days,
int hours,
int minutes,
string email,
string name,
int quantity)
{
var at = DateTime.Now.Date + new TimeSpan(days, hours, minutes, 0);
var db = new FakeDatabase();
var sut = new ReservationsController(
new SystemClock(),
new InMemoryRestaurantDatabase(Grandfather.Restaurant),
db);
var dto = new ReservationDto
{
Id = "B50DF5B1-F484-4D99-88F9-1915087AF568",
At = at.ToString("O"),
Email = email,
Name = name,
Quantity = quantity
};
await sut.Post(dto);
var expected = new Reservation(
Guid.Parse(dto.Id),
at,
new Email(email),
new Name(name ?? ""),
quantity);
Assert.Contains(expected, db.Grandfather);
}
该测试注入一个名为 db 的 FakeDatabase
变量,并最终断言 db 具有预期的状态。 由于 db 在测试期间保持在范围内,因此其行为是确定性和一致的。
面对变化,使用fake更加稳健。 如果希望重构涉及更改 IReservationsRepository
等接口的代码,需要对测试代码进行的唯一更改是编辑伪造的实现,以确保它仍然保留类型的不变量。 这是必须维护的一个测试文件,而不是使用动态模拟库时所必需的。
架构依赖
回顾一下:功能核心不需要依赖注入来支持单元测试,因为功能始终是可测试的(根据定义是确定性的)。 只有命令式 shell 需要依赖注入来支持单元测试。需要哪些依赖项? 不确定行为和副作用的每个来源。 这往往与所讨论的应用程序的实际架构依赖性相对应,可能会添加一个时钟和一个随机数生成器。
《Code That Fits in Your Head 》中的示例系统具有三个“真实”依赖项:它的数据库、SMTP 网关和系统时钟:
除了系统时钟之外,这些依赖项也是在说明系统的整体架构时也会绘制的组件。 该应用程序是一个不透明的盒子,它的内部组织实现细节,但它的“真实”依赖关系代表可能在网络上其他地方运行的其他进程。这些依赖关系是您可能考虑显式建模的依赖关系。 可以将这些依赖项隐藏在接口后面,使用构造函数注入进行注入,并使用测试替身进行替换。 不是模拟、存根或间谍,而是fake。
结论
代码库中应该存在哪些依赖项?
那些代表非确定性行为或副作用的, 向数据库中添加行,发送电子邮件,获取当前时间和日期,查询数据库,这些往往对应于所讨论系统的体系结构依赖性。 如果应用程序需要数据库才能正常工作,可以将数据库建模为多态依赖项。 如果系统必须能够发送电子邮件,则需要一个消息传递网关接口。
除了此类架构依赖性之外,系统时间和随机数生成器是其他众所周知的非确定性来源,因此也将它们建模为显式依赖性。就是这样。 这些是需要的依赖项。 其余的是实现细节,可能会使测试代码更加脆弱。这意味着典型的系统将只有少数依赖项。