C#单元测试中的Fluent Assertions
[[academy-video-youtube({"vid": "TytferBCLOo", "start_time": "0", "title": "C# 单元测试中的流畅断言", "creator": "Tim Corey", "length": "10m 6s"})]]
单元测试会被编写一次却被多次阅读,这使得断言行成为测试套件中最重要的细节之一。Assert.Equal(expected, actual)是可行的,但仅仅比较两个字符串的失败信息并不总是能解释测试在检查什么。 Fluent Assertions重塑了断言语法,使得行读取为其执行的规则,并且失败消息变成了一句话而不是一个差异。
在他的影片《C#单元测试中的Fluent Assertions》中,Tim Corey展示了一个已经测试了SampleClass快乐路径的xUnit项目,然后引入了一个边缘案例(Eddie Van Halen),打破了简单的按空格分割逻辑。 他安装了Fluent Assertions包,使用AssertionScope结尾,使单个测试报告所有失败条件而不是在第一个失败时退出。 对于测试套件已经成长到超过简单相等差异清晰可读的团队们,将在这里找到升级路径。
xUnit的起点
[0:35 - 2:46] 屏幕上的项目很小:一个类库,在构造函数中接收全名并按空格拆分成LastName,以及一个xUnit测试项目,测试快乐路径。 测试使用Theory与InlineData属性,对两个输入("Tim Corey"和"Sue Storm")运行相同的逻辑,每个断言都是标准的Assert.Equal(expected, actual)形式。
[Theory]
[InlineData("Tim Corey", "Tim")]
[InlineData("Sue Storm", "Sue")]
public void TestFirstNameProperty(string fullName, string expected)
{
var sample = new SampleClass(fullName);
Assert.Equal(expected, sample.FirstName);
}[Theory]
[InlineData("Tim Corey", "Tim")]
[InlineData("Sue Storm", "Sue")]
public void TestFirstNameProperty(string fullName, string expected)
{
var sample = new SampleClass(fullName);
Assert.Equal(expected, sample.FirstName);
}运行套件给出六项通过的测试。 这没什么错,对于符合"名字中间空格姓"的情况,断言读起来很清晰。 有意思的部分开始于名字不符合这种形状时。
快乐路径何时停止工作
[2:46 - 4:32] Tim选择的边缘案例是"Eddie Van Halen",这是一个三字姓名,其姓氏是"Van Halen",而不仅仅是第一个空格后的标记。 当前实现抓取分割的索引1并称其为姓,所以类返回"Van"作为姓氏,完全丢掉"Halen"。 这个问题是真实存在的,为其编写失败的测试是修复它的第一步。
使用Assert.Equal("Van Halen", sample.LastName)编写的测试是可行的,但失败信息读起来像是没有上下文的字符串比较。 切换到Fluent Assertions使相同的测试用语表达规则,并生成一个失败消息来命名正在测试的属性。 对于一个小型套件来说,差异看起来很表面化; 对于数百个断言的套件,可读性差异会更加显著。
安装Fluent Assertions
[4:32 - 5:10] 该包在NuGet上以FluentAssertions的名义存储。 Tim将其标记为注册表中下载次数最多的包之一,这个背景信息很有用:任何新贡献者都可能在统计上之前见过它。 测试项目在顶部加入两个using指令:
using FluentAssertions;
using FluentAssertions.Execution;using FluentAssertions;
using FluentAssertions.Execution;第一个是引入断言扩展方法,这是大多数测试需要的。 第二个存在于视频中稍后覆盖的类型AssertionScope; 不分组断言的测试可以省略它。
Should.Be语法
[5:10 - 6:14] Van Halen测试的最简Fluent Assertions重写:
[Fact]
public void TestEdgeCaseNames()
{
var sample = new SampleClass("Eddie Van Halen");
sample.LastName.Should().Be("Van Halen");
}[Fact]
public void TestEdgeCaseNames()
{
var sample = new SampleClass("Eddie Van Halen");
sample.LastName.Should().Be("Van Halen");
}链条读起来接近口语化的英语。 .Should()扩展返回一个描述比较的断言对象; .Be(...)进行一项相等性检查。 大小写敏感性、前后空格都是该检查的一部分,这符合生产代码中大多数字符串比较的行为。
针对破损实现运行此测试会产生一个失败信息,指出属性并解释差距:"预期sample.LastName为'Van Halen',长度为9,但'Van'长度为3。"信息通过生成测试值的表达式识别测试中的值,这种上下文是裸Assert.Equal形式无法提供的。
链接多个条件
[6:14 - 8:10] 单个属性通常需要满足多个规则。 Fluent Assertions通过.And组合条件,使链保持在一个语句中:
sample.LastName.Should()
.StartWith("Van")
.And.EndWith("len")
.And.Contain(" ");sample.LastName.Should()
.StartWith("Van")
.And.EndWith("len")
.And.Contain(" ");链中的每个链接都是一个独立的条件。 默认情况下,链在第一次失败时停止报告:如果EndWith并在那里停止。 对于每个条件独立且您希望每个失败都暴露的测试,断言范围(下一部分)会更改这种行为。
链接语法倾向于表达意图而不是列举相等。 一个测试中写道"姓氏应该以Van开始,以len结尾,并包含空格"读起来像是一种规格说明。 用三个独立的Assert.True调用编写的相同逻辑会作为三个布尔检查读取,没有叙述将它们连接起来。
使用断言范围报告每个失败
[8:10 - 9:34] 默认行为在第一个失败处停止的行为对于后续条件依赖于之前条件的链条来说是可以的。 对于每个条件都独立重要的链,将断言包装在AssertionScope中,使测试在一次运行中报告每个失败:
[Fact]
public void TestEdgeCaseNames()
{
var sample = new SampleClass("Eddie Van Halen");
using var _ = new AssertionScope();
sample.LastName.Should().StartWith("Van");
sample.LastName.Should().EndWith("len");
sample.LastName.Should().Contain(" ");
}[Fact]
public void TestEdgeCaseNames()
{
var sample = new SampleClass("Eddie Van Halen");
using var _ = new AssertionScope();
sample.LastName.Should().StartWith("Van");
sample.LastName.Should().EndWith("len");
sample.LastName.Should().Contain(" ");
}using-var-discard行是标准模式:作用域持续至方法结尾变量超出作用域,舍弃下划线表示变量本身从未被读取。 当测试失败时,消息包含每个失败条件作为单独的行,这意味着一次运行可以提供整个故事,而不是强迫三次运行来呈现三个问题。 对于验证跨多个属性返回对象形状的测试,这就是每个测试周期修复一个错误与一次性修复所有错误之间的差异。
总结:像规格说明一样的测试
[9:34 - 10:06] Fluent Assertions不改变测试验证的内容; 它改变了验证的读法。 AssertionScope分组使测试代码更接近于测试行为的书面规范。结合与FluentValidation进行输入验证时使用的同一类流畅风格,模式生成的新手可以无需通览就能阅读的测试套件和验证规则。
结论
[9:34 - 10:06] 向测试项目添加Fluent Assertions需要一个NuGet安装、两个using指令以及将断言行从actual.Should().Be(expected)。 链在一个语句中表达多条件规则,AssertionScope使单个测试报告每个失败,而不是在第一个失败时停止。好处是失败信息解释了破坏的规则,这是堆栈跟踪和语句之间的差异。
示例小贴士:在集合上断言时,优先result.Should().BeEquivalentTo(expected)而不是逐个属性比较。 它为您遍历对象图,按路径报告第一个分歧属性(例如,users[2].Address.City),这比"集合不相等"的消息在一个50元素列表在一个嵌套字段上不一致时更有用。
在他的YouTube频道上观看完整视频,在10-Minute Training系列中获得更多关于编写可读、可维护C#测试的见解。

