第八章-单元测试
单元测试 Blazor 组件
让我们为 Blazor 应用程序创建几个测试。 在本章的代码下载中,您可以找到测试解决方案。 用你最喜欢的编辑器打开它。 项目中的所有内容都应该看起来很熟悉。 有 Counter 组件和 FetchData 组件,它们使用 IWeatherService 从服务器检索天气预报。
添加单元测试项目
让我们看一个使用 xUnit 的示例,它是一个流行的 .NET 测试库,我们也将使用它来测试我们的 Blazor 组件。
使用 Visual Studio 时,右键单击测试文件夹并选择添加新项目。 在 Add New Project 对话框中,搜索 xUnit Test Project 模板。 现在单击下一步。 将 Location 设置为测试文件夹并将其命名为 Testing.ComponentTests。
如果您使用的是 Code,请打开命令提示符到 test 文件夹并执行以下命令:
dotnet new xunit -n Testing.ComponentTests
现在将目录更改为父目录并执行
dotnet sln add .\test\Testing.ComponentTests
无论您使用哪种工具,都将项目引用添加到客户端和共享项目。 测试项目文件应如清单 8-1 所示,其中我还启用了可为空的引用类型。 由于我将测试启用了可为空引用类型的组件,因此我认为单元测试项目也应该如此。
清单 8-1 Testing.Component 测试项目
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.8.3" />
<PackageReference Include="xunit" Version="2.4.1" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.3">
<IncludeAssets>runtime; build; native; contentfiles; analyzers;
buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="1.3.0">
<IncludeAssets>runtime; build; native; contentfiles; analyzers;
buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Client\Testing.Client.csproj" />
<ProjectReference Include="..\..\src\Shared\Testing.Shared.csproj" />
</ItemGroup>
</Project>
将 bUnit 添加到测试项目
使用当前的单元测试项目,我们可以测试我们的服务和其他非 Blazor 类。为了测试 Blazor 组件,我们需要添加 bUnit。 所以使用你喜欢的方法添加 bUnit 包(选择最新的稳定版本)。
您还需要更改项目的 SDK,如清单 8-2 所示。 我们需要这样做,因为我们将使用 razor 语法为 Blazor 组件构建单元测试。
清单 8-2 bUnit 项目
<Project Sdk="Microsoft.NET.Sdk.Razor">
编写你的第一个单元测试
现在我们已经准备好了一切,我们可以编写我们的第一个单元测试。 我们将从编写一个简单的单元测试开始,看看它是如何与 Visual Studio 和 Code 一起工作的。
编写好的单元测试方法
每个单元测试都将包括三个阶段:安排、执行和断言,也称为单元测试的三 A。 安排阶段将通过创建被测对象 (SUT) 来设置单元测试,我的意思是我们要测试的类及其依赖项。 Act 阶段将对我们要测试的方法执行调用,Assert 将验证结果是否成功。
将一个名为 Utils 的新类添加到 Shared 项目中,如清单 8-3 所示。 Square 方法应该返回一个数字的平方(并且它有一个错误)。
清单 8-3 一个简单的 Utils 类
namespace Testing.Shared
{
public class Utils
{
public int Square(int i)
{
return i;
}
}
}
让我们为这个方法编写一个简单的单元测试,如清单 8-4 所示。 对于 xUnit,单元测试是一个带有 [Fact] 属性的公共方法。 正如这个属性所说,测试的结果应该是事实! 在安排阶段,我们设置了我喜欢称之为 sut 的测试对象。 这样,我很容易识别出我想要测试的实例(只是一个约定,随意命名)。 然后在排列阶段,我们调用 Square 方法,将结果存储在实际变量中。 接下来是 Assert 阶段,我使用 xUnit 中的 Assert 类来验证结果是否与预期结果匹配。 Assert 类有一系列方法来检查测试的结果是否是预期的结果。 在这里,我们使用 Equals 方法来查看结果是否等于 9,应该是 3 的平方。
清单 8-4 测试平方方法
using Testing.Shared;
using Xunit;
namespace Testing.ComponentTests
{
public class SquareShould
{
[Fact]
public void Return9For3()
{
// Arrange
var sut = new Utils();
// Act
var actual = sut.Square(3);
// Assert
Assert.Equal(expected: 9, actual: actual);
}
}
}
运行你的测试
使用 Visual Studio,打开测试资源管理器窗口(测试 ➤ 测试资源管理器),如图 8-2 所示。 使用 Visual Studio,测试资源管理器是运行单元测试和查看结果的地方。 打开测试资源管理器后,它将扫描您的解决方案以查找单元测试并列出它们。 现在单击此窗口中的左绿色箭头以运行所有测试。
测试将运行和失败,如图 8-3 所示。
您也可以从 Visual Studio Code 运行单元测试,但您必须安装 .NET Core 测试资源管理器扩展,如图 8-4 所示。
现在您可以通过单击 VSC 左侧的测试资源管理器图标来运行测试,如图 8-5 所示。
VSC 测试资源管理器将显示几个按钮,如图 8-6 所示。 从左到右,您有一个按钮来运行测试、刷新可用测试列表、停止测试执行以及显示带有测试结果的日志。
单击刷新按钮。 这将扫描您的项目以查找可用的测试。 如果测试资源管理器找不到任何测试,您可以设置 dotnet-test-explorer.testProjectPath 设置,例如:
"dotnet-test-explorer.testProjectPath": "**/*Tests.csproj"
单击播放图标运行测试。 测试将运行并失败。 要查看测试结果,请单击日志按钮,然后查看问题选项卡,如图 8-7 所示。
让你的测试通过
为什么测试失败了? 如果您在 Square 方法中放置一个断点并再次单击测试资源管理器中的箭头,您将看到 Visual Studio 不会在断点处停止。VSC 也是如此。 为什么? 调试需要一些特殊的设置,这需要时间。还记得我们希望我们的测试尽可能短地完成吗? 使用 Visual Studio,您可以按如下方式启用调试器。 右键单击测试资源管理器窗口中的测试并选择调试(在 VSC 中类似)。 现在调试器将在您的断点处停止。 当您进入 Square 方法时,您应该会看到错误(呃!)。 修复它,如示例 8-5 所示。
清单 8-5 修正平方法
namespace Testing.Shared
{
public class Utils
{
public int Square(int i)
{
return i*i;
}
}
}
现在再次运行测试(有或没有调试器)。 现在它应该像图 8-8 和 8-9 一样通过。
使用事实和理论
但是其他值呢? 使用 xUnit,我们可以编写一整套测试,而无需复制粘贴大量测试(复制粘贴到重复代码通常很糟糕,也称为不要重复自己 (DRY))。 向 SquareShould 类添加另一个单元测试,如清单 8-6 所示。 在这里,我们使用 [Theory] 属性来告诉 xUnit 使用不同的参数运行它。 我们使用 [InlineData] 属性将参数传递给测试方法。
清单 8-6 使用 Theories
[Theory]
[InlineData(1,1)]
[InlineData(2,4)]
[InlineData(-1,1)]
public void ReturnSquareOfNumber(int number, int square)
{
// Arrange
var sut = new Utils();
// Act
var actual = sut.Square(number);
// Assert
Assert.Equal(expected: square, actual: actual);
}
现在,当我们运行测试时,您将在图 8-10 和 8-11 中看到 xUnit 运行三个测试,每个 [InlineData] 属性一个。
检查你的理智
你有没有一段代码做的事情与你预期的不同?就个人而言,我开始怀疑我的理智,比如“我疯了吗?” 或者您是否使用了某人的方法,该方法记录不充分,并且没有按照应有的方式进行? 通过单元测试,您可以设置检查以查看方法是否执行您认为应该执行的操作。 如果没有,也许你需要和作者谈谈,看看什么更有意义。当你有单元测试时,你可以将它附加到错误报告中,方便作者重现错误。 让我们再看一个例子。 现在我想看看当我们将一个大整数传递给 Square 方法时是否会引发错误(并不是每个平方整数都是另一个整数,因为它的范围有限)。 添加另一个测试方法,如清单 8-7 所示。 所以在这里我们用最大的 int 来调用 Square。 结果永远无法放入 int 中,因此我们希望这会引发 OverflowException。
清单 8-7 测试异常情况
[Fact]
public void ThrowOverflowForBigNumbers()
{
// Arrange
var sut = new Utils();
// Act & Assert
Assert.Throws<OverflowException>(() =>
{
int result = sut.Square(int.MaxValue);
});
}
为什么会失败? 让我们在 Square 方法上设置一个断点。 也许我们在这里做错了什么? 使用调试器运行测试。 当调试器停止时,查看参数的值:2147483647。这是最大的有符号整数。现在退出方法,直到设置结果。 它的价值是什么? 是 1。现在 2147483647*2147483647 不是 1! 再说一次,发生了什么? 事实证明,C# 像 C++ 和 C 一样工作。这些编程语言默认情况下不会在计算溢出时抛出异常! 他们甚至使用它来创建散列和加密算法。 那么我们该如何解决这个问题呢? 您可以使用 C# 中的 checked 关键字打开溢出检查,如清单 8-8 所示。
清单 8-8 启用溢出检查
namespace Testing.Shared
{
public class Utils
{
public int Square(int i)
{
checked
{
return i * i;
}
}
}
}
再次运行测试。 现在它通过了。 哇! 这实际上是正常的行为。
单元测试非常适合发现这些奇怪的行为,并允许您捕获以后导致错误的修改。
用 C# 编写一个 bUnit 测试
我们已经了解了如何为 .NET 类及其方法编写单元测试。 在这里,我们将了解如何在 xUnit 之上为 Blazor 组件编写测试。
了解 bUnit?
bUnit 是 Blazor 组件的测试库,由 Egil Hansen 编写,源代码可在 GitHub 中找到,网址为 https://github.com/bUnit-dev/bUnit。 使用 bUnit,您可以轻松地为 Blazor 组件编写单元测试。 为什么我们应该为 Blazor 组件编写单元测试? 为常规类编写单元测试的原因与此相同:确保它们按预期工作,并确保它们在某些依赖项更新的情况下继续工作。 当然,您的大部分测试应该在实现业务逻辑的服务类上进行。 例如,您希望确保您的 Blazor 组件在用户与该组件交互时调用该服务上的某个方法。 使用 bUnit,我们可以实现自动化,因此用户无需实际单击按钮! 我们可以连续运行这些测试,这样我们就可以在更改后的几分钟内知道何时破坏了组件。
测试 Blazor 组件的一部分是呈现和检查组件的输出。 但它远不止于此。 您可以与组件交互并查看更改、替换依赖项等。
让我们从 Counter 组件开始,如示例 8-9 所示。 这个现在熟悉的组件显示一个 currentCount 字段,该字段最初为 0。因此,一个非常简单的单元测试将是查看组件的输出是否与预期输出匹配。
清单 8-9 计数器组件
@page "/counter"
<h1>Counter</h1>
<p>Current count: @currentCount</p>
<button class="btn btn-primary" @onclick="IncrementCount">
Click me
</button>
@code {
private int currentCount = 0;
private void IncrementCount()
{
currentCount++;
}
}
将一个名为 CounterShould 的新类添加到单元测试项目中。 你可以为这个类命名任何你想要的名字,但我喜欢使用方法或组件名称,然后是“应该”这个词的命名约定。 从 TestContext 基类派生这个类,这将使您能够访问 bUnit 中所有方便的方法。 我们将继续使用这些方法,并通过从 TestContext 派生您的测试类,它们可以通过继承获得。 实现第一个单元测试 RenderCorrectlyWithInitialZero,如清单 8-10 所示。
清单 8-10 CounterShould 类
using Bunit;
using Testing.Client.Pages;
using Xunit;
namespace Testing.ComponentTests
{
public class CounterShould : TestContext
{
[Fact]
public void RenderCorrectlyWithInitialZero()
{
var cut = RenderComponent<Counter>();
Assert.Equal(@"
<h1>Counter</h1>
<p>Current count: 0</p>
<button class=""btn btn-primary"" >
Click me
</button>", cut.Markup);
}
}
}
在这里,我们将 xUnit 与 bUnit 一起使用,因此我们的单元测试具有 [Fact] 属性。 首先,我们进行排列阶段,在此我们通过调用 RenderComponent<Counter>
方法创建被测组件(我将其命名为 cut,类似于 sut)。这将创建组件并一次性渲染它。 所以这也需要处理 Act 阶段。 接下来,我们执行 Assert 阶段,我们想查看组件是否生成了正确类型的输出。
此测试将失败。 为什么? 只需运行测试,然后查看测试输出,如图 8-13 所示。再次查看示例 8-10 中的 Assert 语句。 在这里,我们希望组件的标记与文字字符串匹配。 它确实以某种方式匹配,除了空格和换行符。 我们现在可以完成这项工作并将我们的字符串更新为实际输出,但这对我们稍后可能对组件进行的微小更改过于敏感。
让我们改进测试,如示例 8-11 所示。 现在我们使用 MarkupMatches 方法,它将在组件的标记和我们的字符串之间进行语义比较。 这将在比较过程中忽略空格、换行符、注释和其他不相关的东西,现在我们应该看到测试通过了! 现在,当我们在更改标记格式的组件中添加换行符或注释时,我们的测试将不再中断!
清单 8-11 通过语义比较改进我们的单元测试
using Bunit;
using Testing.Client.Pages;
using Xunit;
namespace Testing.ComponentTests
{
public class CounterShould : TestContext
{
[Fact]
public void RenderCorrectlyWithInitialZero()
{
var cut = RenderComponent<Counter>();
cut.MarkupMatches(@"
<h1>Counter</h1>
<p>Current count: 0</p>
<button class=""btn btn-primary"" >
Click me
</button>
");
}
}
}
我们甚至可以做得更好,专注于组件的相关部分。 我们知道我们的 Counter 组件使用 <p>
元素来渲染 currentCount 变量,但是我们如何访问渲染树的这一部分呢? bUnit 库有一个 Find 方法,它采用 CSS 选择器并返回查询结果。 向 ShouldRender 类添加另一个测试方法,如清单 8-12 所示。 我们找到 <p>
元素,我们可以使用 MarkupMatches 方法查看它是否与预期输出匹配,该方法忽略空格。
清单 8-12 使用查找方法
[Fact]
public void RenderParagraphCorrectlyWithInitialZero()
{
var cut = RenderComponent<Counter>();
cut.Find(cssSelector: "p")
.MarkupMatches("<p>Current count: 0</p>");
}
运行你的测试,看看它们是否通过,它们应该通过。
测试失败时会发生什么?
在 RenderParagraphCorrectlyWithInitialZero 方法中,将 0 替换为 1。运行测试。 它失败! 选择测试,您应该会看到如图 8-14 所示的以下输出。 这个输出告诉我们出了什么问题,现在我们可以更改组件(或测试),直到测试通过。 修复测试。
测试组件交互
我们的 Counter 组件有一个按钮,当您单击该按钮时,它应该将 currentCount 增加 1 并呈现新值。 让我们看看如何通过与 Blazor 组件交互来对其执行测试,并查看该组件是否已正确更新。 向 ShouldRender 类添加一个新的单元测试,如清单 8-13 所示。测试中的第二行使用 Find 方法检索按钮,然后使用 Click 方法对其执行 @onclick 事件。 这应该具有预期的副作用,我们在下一行进行测试以查看组件是否以预期值重新渲染。 运行测试,应该可以通过。 嘿,这很容易!
清单 8-13 与计数器组件交互
[Fact]
public void IncrementCounterWhenButtonIsClicked()
{
var cut = RenderComponent<Counter>();
cut.Find(cssSelector: "button")
.Click();
cut.Find(cssSelector: "p")
.MarkupMatches(@"<p>Current count: 1</p>");
}
bUnit 库带有许多调度方法,可以触发组件上的事件。 使用 Find 方法检索组件中的元素,然后对其调用适当的调度方法,例如 Click。 这些调度方法还允许您传递事件参数。 所以让我们看一个例子。
首先将一个名为 MouseTracker 的新组件添加到您的 Blazor 项目中,并使用清单 8-14 中的标记。
清单 8-14 MouseTracker 组件的标记
<div style="width: 300px; height: 300px;
background: green; margin:50px"
@onmousemove="MouseMove">
@pos
</div>
该组件有一个 MouseMove 事件处理程序,如清单 8-15 所示。
清单 8-15 MouseTracker 组件的代码
using Microsoft.AspNetCore.Components.Web;
namespace Testing.Client.Pages
{
public partial class MouseTracker
{
private string pos = "";
private void MouseMove(MouseEventArgs e)
=> pos = $"Mouse at {e.ClientX}x{e.ClientY}";
}
}
在单元测试项目中,添加一个名为 MouseTrackerShould 的新类,其中包含一个单元测试,如清单 8-16 所示。 在 bUnit 测试的排列阶段,我们创建了一个 MouseEventArgs 实例,并将 ClientX 和 ClientY 设置为某个值。 然后,我们使用 TestContext 的 RenderComponent 方法创建 MouseTracker 组件的实例。 现在我们从组件中找到 div 并将其存储在 theDiv 引用中。
现在我们可以通过触发 MouseMove 事件来执行测试的 Act 阶段,传递我们之前创建的 MouseMoveEventArgs 实例。 这将重新渲染组件,因此我们准备好进入 Assert 阶段,在该阶段我们使用 MarkupMatches 方法检查 theDiv 是否具有预期的内容。 请注意,我们再次使用语义比较,在这里我们可以使用 style:ignore 属性告诉比较也忽略样式属性。 我们将在本章的后面部分详细讨论这一点。
清单 8-16 MouseTrackerShould 单元测试
using Bunit;
using Microsoft.AspNetCore.Components.Web;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http.Headers;
using System.Text;
using System.Threading.Tasks;
using Testing.Client.Pages;
using Xunit;
namespace Testing.ComponentTests
{
public class MouseTrackerShould : TestContext
{
[Fact]
public void ShowCorrectMousePosition()
{
var eventArgs = new MouseEventArgs()
{
ClientX = 100,
ClientY = 200
};
var cut = RenderComponent<MouseTracker>();
var theDiv = cut.Find(cssSelector: "div");
theDiv.MouseMove(eventArgs);
theDiv.MarkupMatches($"<div style:ignore>Mouse at {eventArgs.ClientX}x{eventArgs.ClientY}");
}
}
}
将参数传递给我们的组件
通过数据绑定,我们可以将参数从父组件传递给子组件。 我们如何用 bUnit 传递参数? 首先复制 Blazor 项目中的 Counter 组件,将其重命名为 TwoWayCounter,然后将其更改为如清单 8-17 所示。 这个 TwoWayCounter 组件有几个参数,包括 CurrentCount 和 Increment 参数。
清单 8-17 TwoWayCounter 组件
<h1>Counter</h1>
<p>Current count: @CurrentCount</p>
<button class="btn btn-primary" @onclick="IncrementCount">Click me</button>
@code {
private int currentCount = 0;
[Parameter]
public int CurrentCount
{
get => currentCount;
set
{
if (value != currentCount)
{
currentCount = value;
CurrentCountChanged.InvokeAsync(currentCount);
}
}
}
private int increment = 1;
[Parameter]
public int Increment {
get => increment;
set
{
if( value != increment)
{
increment = value;
IncrementChanged.InvokeAsync(increment);
}
}
}
[Parameter]
public EventCallback<int> CurrentCountChanged { get; set; }
[Parameter]
public EventCallback<int> IncrementChanged { get; set; }
private void IncrementCount()
{
CurrentCount+=Increment;
}
}
向名为 TwoWayCounterShould 的测试项目添加另一个单元测试,并添加第一个 bUnit 测试,如清单 8-18 所示。 我们想向这个组件传递两个参数,我们可以通过使用 RenderComponent 方法的重载来实现这一点,如清单 8-18 所示。这需要一个委托,该委托的参数参数类型为 ComponentParameterCollectionBuilder<TComponent>
。 此类有一个带有两个参数的 Add 方法:您在其中传递参数名称和参数值的表达式。
清单 8-18 TwoWayCounterShould 测试类
using Bunit;
using Testing.Client.Pages;
using Xunit;
namespace Testing.ComponentTests
{
public class TwoWayCounterShould : TestContext
{
[Fact]
public void IncrementCounterWhenClicked()
{
var cut = RenderComponent<TwoWayCounter>(
parameters =>
parameters.Add(counter => counter.CurrentCount, 0)
.Add(counter => counter.Increment, 1)
);
cut.Find("button").Click();
cut.Find("p")
.MarkupMatches("<p>Current count: 1</p>");
}
}
}
这种向组件传递参数的方式非常方便,因为我们可以使用 IntelliSense 来选择参数的名称。 还有其他传递参数的方法,如清单8-19所示。 在这里,我们使用 xUnit 的 Theory 将不同的参数传递给组件,并且每个参数作为 ValueTuple 传递,包含每个参数的名称和值(这就是为什么将它们包裹在一个左括号和右括号中)。
但是,我个人不喜欢这种工作方式,因为现在我们将参数的名称作为字符串传递。 编译器不会检查字符串的内容以查看它是否实际上是参数的名称。 当您犯了错误(或者您稍后决定重命名参数)时会发生什么? 编译器不会抱怨,您将收到失败的测试并显示以下消息:
Message:
System.InvalidOperationException : Object of type 'Testing.Client.Pages.
TwoWayCounter' does not have a property matching the name 'CurrentCuont'.
在包含类名称、属性和其他代码结构的代码中使用硬编码字符串是一种反模式,我称之为“基于字符串的编程”,应该避免。
清单 8-19 使用理论来测试不同的案例
[Theory]
[InlineData(3)]
[InlineData(-3)]
public void IncrementCounterWithIncrementWhenClicked(int increment)
{
var cut = RenderComponent<TwoWayCounter>(
("CurrentCount", 0),
("Increment", increment)
);
cut.Find("button").Click();
cut.Find("p")
.MarkupMatches($"<p>Current count: {increment}</p>");
}
当然,使用现代 C#,我们可以修复这个问题,并且仍然使用示例 8-20 中的这种样式。 在这里,我们使用 nameof 运算符,它获取属性的名称并返回该属性的字符串表示形式。 您还可以将 nameof 与类、方法和其他内容一起使用。
清单 8-20 使用 nameof 传递属性名称
[Theory]
[InlineData(3)]
[InlineData(-3)]
public void IncrementCounterWithIncrementWhenClickedWithNameOf(
int increment)
{
var cut = RenderComponent<TwoWayCounter>(
(nameof(TwoWayCounter.CurrentCount), 0),
(nameof(TwoWayCounter.Increment), increment)
);
cut.Find("button").Click();
cut.Find("p")
.MarkupMatches($"<p>Current count: {increment}</p>");
}
测试双向数据绑定和事件
我们的 TwoWayCounter 具有实现双向数据绑定的参数。 让我们看看这个组件是否正确实现了这一点。 我们可以使用与以前相同的技术将处理程序传递给 CurrentCountChanged 和 IncrementChanged 参数。 但在我们这样做之前,请将 FluentAssertions 包添加到您的测试项目中。 FluentAssertions 允许您以更易读和简洁的方式编写您的断言语句,我们将在此处使用它(尽管这不是必需的)。 你可以在 https://fluentassertions.com 找到更多关于流利断言的信息。
查看示例 8-21 中的 bUnit 测试。 我们添加了四个参数,其中两个是 EventCallback<int>
类型。 我们使用委托给 EventCallback<int>
赋值,这个委托增加一个局部变量。 这样,我们统计了 CurrentCountChanged 和 IncrementChanged 事件回调的调用次数。
单击按钮后,我们希望 CurrentCountChanged 已被调用,我们使用 FluentAssertions Should().Be(1) 方法调用对其进行测试。 但是我们还想测试 Increment 属性的更改处理程序,我们可以通过使用 cut.Instance 属性访问组件并直接为 Increment 分配一个新值来做到这一点。 如果您的编译器对此语句发出警告,这是正常的,因为通常不允许您直接从代码访问组件的参数。
清单 8-21 测试双向更改的处理程序
[Fact]
public void TriggerChangedEventForCurrentCounter()
{
int nrOfCurrentCountChanged = 0;
int nrOfIncrementChanged = 0;
var cut = RenderComponent<TwoWayCounter>(parameters =>
parameters.Add(counter => counter.CurrentCount, 0)
.Add(counter => counter.Increment, 1)
.Add(counter => counter.CurrentCountChanged,
() => nrOfCurrentCountChanged++)
.Add(counter => counter.IncrementChanged,
() => nrOfIncrementChanged++)
);
cut.Find("button").Click();
cut.Instance.Increment = 2;
nrOfCurrentCountChanged.Should().Be(1);
nrOfIncrementChanged.Should().Be(1);
}
您还可以在首次渲染组件后更改参数值。 查看示例 8-22 中的示例,其中我们使用 SetParametersAndRender 方法来修改 Increment 参数的值。
清单 8-22 修改参数值
[Fact]
public void TriggerChangedEventForCurrentCounter2()
{
int nrOfIncrementChanged = 0;
var cut = RenderComponent<TwoWayCounter>(parameters =>
parameters.Add(counter => counter.CurrentCount, 0)
.Add(counter => counter.Increment, 1)
.Add(counter => counter.IncrementChanged,
() => nrOfIncrementChanged++)
);
cut.SetParametersAndRender(parameters =>
parameters.Add(counter => counter.Increment, 2));
nrOfIncrementChanged.Should().Be(1);
}
测试使用 RenderFragment 的组件
使用 RenderFragment 的组件(例如 ChildContent 和模板化组件)呢? RenderFragment 是一种特殊的 Blazor 类型,因此需要特别注意。 首先向 Blazor 项目添加一个 Alert 组件,如清单 8-23。
清单 8-23 Alert组件
<div class="alert alert-secondary mt-4" role="alert">
@ChildContent
</div>
@code {
[Parameter]
public RenderFragment ChildContent { get; set; } = default!;
}
现在将清单 8-24 中的 AlertShould 类添加到您的测试项目中。 如您所见, ChildContent 只是另一个参数,但带有一些方便的方法使其易于添加。
清单 8-24 AlertShould 测试类
using Bunit;
using Testing.Client.Pages;
using Xunit;
namespace Testing.ComponentTests
{
public class AlertShould : TestContext
{
[Fact]
public void RenderSimpleChildContent()
{
var cut = RenderComponent<Alert>(parameters =>
parameters.AddChildContent("<p>Hello world!</p>"));
cut.MarkupMatches(@"
<div class=""alert alert-secondary mt-4"" role=""alert"">
<p>Hello world!</p>
</ div >
");
}
}
}
如果 Alert 组件有额外的参数,我们可以像示例 8-18 中那样传递。
在示例 8-24 中,我们传递了一些简单的 HTML 作为 ChildContent,但我们可以做更复杂的事情。 例如,在示例 8-25 中,我们将 Counter 作为 ChildContent 传递。
清单 8-25 将计数器作为 ChildContent 传递
[Fact]
public void RenderCounterAsChildContent()
{
var cut = RenderComponent<Alert>(parameters =>
parameters.AddChildContent<Counter>());
var p = cut.Find("p");
p.MarkupMatches("<p>Current count: 0</p>");
}
我们甚至可以将参数传递给 ChildContent,例如,当使用示例 8-26 中的 TwoWayCounter 时。
清单 8-26 将 TwoWayCounter 作为 ChildContent 传递
[Fact]
public void RenderTwoWayCounterWithParametersAsChildContent()
{
var cut = RenderComponent<Alert>(parameters =>
parameters.AddChildContent<TwoWayCounter>(parameters =>
parameters.Add(counter=>counter.CurrentCount, 3)));
var p = cut.Find("p");
p.MarkupMatches("<p>Current count: 3</p>");
}
您甚至可以多次调用 AddChildContent 来添加多个片段。 清单 8-27 说明了这一点,我们同时添加了一个 HTML 字符串和一个计数器。 还要注意 const 字符串的使用,因此我们不需要同步 AddChildContent 和 MarkupMatches 方法中使用的内容(不要重复自己的原则 (DRY))。
清单 8-27 多次调用 AddChildContent
[Fact]
public void RenderTitleAndCounterAsChildContent()
{
const string header = "<h1>This is a counter</h1>";
var cut = RenderComponent<Alert>(parameters =>
parameters.AddChildContent(header)
.AddChildContent<Counter>());
var h1 = cut.Find("h1");
h1.MarkupMatches(header);
var p = cut.Find("p");
p.MarkupMatches("<p>Current count: 0</p>");
}
模板化组件呢? 首先添加(或从本书提供的代码下载中复制)清单 8-28 和 8-29(标记和代码)中的模板化组件。 此模板化组件使用两个 RenderFragment 和一个 RenderFragment<TItem>
。 它还有一个参数来传递一个 Loader,这是一个获取该组件项目的函数。 首先,我们将查看 RenderFragment,然后查看 RenderFragment<TItem>
。
清单 8-28 TemplatedList 组件的标记
@typeparam TItem
@if (items is null)
{
@LoadingContent
}
else if (items.Count() == 0)
{
@EmptyContent
}
else
{
<div class="list-group @ListGroupClass">
@foreach (var item in items)
{
<div class="list-group-item">
@ItemContent(item)
</div>
}
</div>
}
清单 8-29 TemplatedList 组件的代码
using Microsoft.AspNetCore.Components;
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Threading.Tasks;
namespace Testing.Client.Pages
{
public partial class TemplatedList<TItem>
{
IEnumerable<TItem>? items;
[Parameter]
public Func<ValueTask<IEnumerable<TItem>>>? Loader { get; set; }
[Parameter]
public RenderFragment LoadingContent { get; set; } = default!;
[Parameter]
public RenderFragment? EmptyContent { get; set; } = default!;
[Parameter]
public RenderFragment<TItem> ItemContent { get; set; } = default!;
[Parameter]
public string ListGroupClass { get; set; } = string.Empty;
protected override async Task OnParametersSetAsync()
{
if (Loader is not null)
{
items = await Loader();
}
}
}
}
现在将 TemplatedListShould 类添加到清单 8-30 中的测试项目中。 在这里,我们添加了两个参数,一个用于 Loader 参数,一个用于 LoadingContent 模板。 如您所见,我们可以使用相同的 Add 方法,就像普通参数一样。
清单 8-30 在测试中使用 RenderFragment
using Bunit;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Testing.Client.Pages;
using Xunit;
namespace Testing.ComponentTests
{
public class TemplatedListShould : TestContext
{
[Fact]
public void RenderLoadingTemplateWhenItemsIsNull()
{
const string loading =
"<div class=\"loader\">Loading...</div>";
Func<ValueTask<IEnumerable<string>?>> loader =
() => new ValueTask<IEnumerable<string>?>(result:null);
var cut = RenderComponent<TemplatedList<string>>(
parameters =>
parameters.Add(tl => tl.Loader, loader)
.Add(tl => tl.LoadingContent, loading)
);
cut.Find("div.loader")
.MarkupMatches(loading);
}
}
}
但是使用更复杂的 RenderFragment<TItem>
的 ItemContent 参数呢? 添加一个新的单元测试,如清单 8-31 所示。 在这里,我们将使用加载器 Func<ValueTask<IEnumerable<string>>>
传递五个字符串。 请注意使用 Enumerable.Repeat 方法创建元素集合。 我们将加载器作为参数传递给 TemplatedList<string>
组件,我们还传递了 ItemContent,它是一个 RenderFragment<string>
。 因为这需要一个参数,所以我们使用一个 Func<string, string> 委托,它将返回一个 RenderFragment<string>
(因为 Add 方法会处理这个问题)。
现在我们要检查它是否使用了我们集合(五个“A”字符串)中每个项目的 ItemContent。 有一个 FindAll 方法采用 CSS 选择器,它将返回与选择器匹配的所有元素。 ItemContent RenderFragment 使用了一个 p,所以我们使用它作为 CSS 选择器。 首先,我们检查段落的数量是否与项目的数量相匹配,然后我们遍历每个项目并检查标记是否与预期的输出匹配。
清单 8-31 传递一个 RenderFragment
[Fact]
public void RenderItemsCorrectly()
{
const int count = 5;
Func<ValueTask<IEnumerable<string>>> loader =
() => new ValueTask<IEnumerable<string>>(
Enumerable.Repeat("A", count));
var cut = RenderComponent<TemplatedList<string>>(
parameters =>
parameters.Add(tl => tl.Loader, loader)
.Add(tl => tl.ItemContent,
(context) => $"<p>{context}</p>"));
var ps = cut.FindAll("p");
ps.Should().NotBeEmpty();
foreach (var p in ps)
{
p.MarkupMatches("<p>A</p>");
}
}
运行这个测试; 它通常应该通过。 如果没有,我们将在“处理异步重新渲染”一节中讨论这个问题,所以请继续阅读。
最后一个例子。 让我们使用另一个组件作为 ItemContent 并将上下文作为参数传递。 添加清单 8-32 中名为 ListItem 的新组件(它是清单 8-31 中 ItemContent 的复制粘贴)。
清单 8-32 ListItem 组件
<p>@Item</p>
@code {
[Parameter]
public string Item { get; set; } = default!;
}
现在复制并粘贴 RenderItemsCorrectly 方法,将其重命名为示例 8-33。 该清单中唯一需要修改的部分是我们传递 ItemContent 参数的地方。 如果要使用组件作为 RenderFragment
清单 8-33 将组件作为 RenderFragment
传递
[Fact]
public void RenderItemsWithListItemCorrectly()
{
const int count = 5;
Func<ValueTask<IEnumerable<string>?>> loader =
() => new ValueTask<IEnumerable<string>?>(
Enumerable.Repeat("A", count));
var cut = RenderComponent<TemplatedList<string>>(
parameters =>
parameters.Add(tl => tl.Loader, loader)
/*component*//*TItem*/
.Add<ListItem, string>(tl => tl.ItemContent,
context => itemParams
=> itemParams.Add(p => p.Item,325
context)
));
var ps = cut.FindAll("p");
ps.Should().NotBeEmpty();
foreach (var p in ps)
{
p.MarkupMatches("<p>A</p>");
}
}
这个 Add<ListItem, string> 重载有两个表达式:第一个返回要设置的参数(ItemContent),第二个表达式需要更深入的解释。 让我们看一下这段有点难读的代码:
Add<ListItem, string>(
tl => tl.ItemContent,
context => itemParams
=> itemParams.Add(p => p.Item, context)
));
所以第一个参数是 tl => tl.ItemContent ,它返回要设置的参数。 第二个参数是一个 lambda 函数,它接受 TItem 的值(在我们的例子中是一个字符串),并返回另一个接受 ComponentParameterCollectionBuilder<TComponent>
的 lambda 函数。 这听起来很熟悉吗? 是的。 它与我们从本节开始时用来将参数传递给组件的类型相同(示例 8-18 示例)。 在这里,我们通过调用 Add 向 ListItem 组件添加参数。
运行此测试(如果您愿意,还可以运行其他测试)。 所有测试都应该通过。 呸!
使用级联参数
一些组件使用一个或多个级联参数,因此要测试这些组件,我们需要为级联参数传递一个值。 首先复制 Counter 组件,并将其重命名为 CounterWithCV。 添加一个 Increment 级联参数,如清单 8-34 所示。
清单 8-34 CounterWithVC 组件
@page "/counter"
<h1>Counter</h1>
<p>Current count: @currentCount</p>
<button class="btn btn-primary" @onclick="IncrementCount">
Click me
</button>
@code {
[CascadingParameter]
public int Increment { get; set; }
private int currentCount = 0;
private void IncrementCount()
{
currentCount += Increment;
}
}
添加一个名为 CounterWithCVShould 的新测试类,并如清单 8-35 所示实现测试。 正如你所看到的,由于级联属性是通过它们的类型来识别的,你只需要传递值。
清单 8-35 使用级联参数测试组件
using Bunit;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Testing.Client.Pages;
using Xunit;
namespace Testing.ComponentTests
{
public class CounterWithVCShould : TestContext
{
[Fact]
public void ShouldUseCascadingIncrement()
{
var cut = RenderComponent<CounterWithCV>(parameters =>
parameters.AddCascadingValue(3));
cut.Find(cssSelector: "button")
.Click();
cut.Find(cssSelector: "p")
.MarkupMatches(@"<p>Current count: 3</p>");
}
}
}
你也可以命名级联值,所以试试这个:首先命名 Increment 级联参数,如清单 8-36 所示,然后更新测试,如清单 8-37 所示。
清单 8-36 使用命名级联参数
[CascadingParameter(Name = "Increment")]
public int Increment { get; set; }
清单 8-37 传递命名级联参数
var cut = RenderComponent<CounterWithCV>(parameters =>
parameters.AddCascadingValue("Increment", 3));
使用 MOQ 创建虚假实现
我们已经看到组件应该很好地完成一件事(单一职责原则),我们应该使用服务来实现逻辑,例如使用 REST 检索数据,或者实现业务逻辑。 我们使用依赖注入将这些服务传递给组件。 在这里,我们将了解如何使用 bUnit 将依赖项传递给组件,以及如何用虚假实现替换您的服务以更好地推动您的单元测试。
使用 bUnit 注入依赖项
让我们从清单 8-38 中的 FetchData 组件开始。 该组件有一个依赖项,即 IWeatherService。
清单 8-38 FetchData 组件
@page "/fetchdata"
@using Testing.Shared
@inject IWeatherService WeatherService
<h1>Weather forecast</h1>
<p>This component demonstrates fetching data from the server.</p>
@if (forecasts == null)
{
<p><em>Loading...</em></p>
}
else
{
<table class="table">
<thead>
<tr>
<th>Date</th>
<th>Temp. (C)</th>
<th>Temp. (F)</th>
<th>Summary</th>
</tr>
</thead>
<tbody>
@foreach (var forecast in forecasts)
{
<tr>
<td>@forecast.Date.ToShortDateString()</td>
<td>@forecast.TemperatureC</td>
<td>@forecast.TemperatureF</td>
<td>@forecast.Summary</td>
</tr>
}
</tbody>
</table>
}
@code {
private IEnumerable<WeatherForecast>? forecasts;
protected override async Task OnInitializedAsync()
{
forecasts = await WeatherService.GetForecasts();
}
}
在 Blazor 应用程序中使用此组件时,Blazor 运行时将负责注入此依赖项。 当您在 bUnit 测试中使用该组件时,bUnit 运行时将负责注入依赖项。 我们唯一需要说明的是使用哪个类来实例化实例。
向测试项目添加一个新的测试类,将其命名为 FetchDataShould,并如清单 8-39 所示完成它。 要在 bUnit 测试中配置依赖注入,请使用与常规依赖注入、AddSingleton、AddTransient 和 AddScoped 相同的方法将依赖项添加到 Services 属性。
清单 8-39 测试 FetchData 组件
using Bunit;
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
using Testing.Client.Pages;
using Testing.Shared;
using Xunit;
namespace Testing.ComponentTests
{
public class FetchDataShould : TestContext
{
[Fact]
public void UseWeatherService()
{
// Use Services for dependency injection
Services.AddSingleton<IWeatherService,
Testing.Shared.WeatherService>();
var cut = RenderComponent<FetchData>();
var rows = cut.FindAll("tbody tr");
rows.Count.Should().Be(5);
}
}
}
尝试运行此测试。 它失败? 查看失败测试的输出。 事实证明,共享项目中的 WeatherService 有自己的依赖项,即 ILogger。我们是否应该添加另一个依赖项? 在这种情况下,我们应该构建一个实现 ILogger 接口的类或找到一个现有的类。 我们不会。 让我们谈谈假物体。
Message:
System.InvalidOperationException : Unable to resolve service for type
'Microsoft.Extensions.Logging.ILogger`1[Testing.Shared.WeatherService]'
while attempting to activate 'Testing.Shared.WeatherService'.
用假对象替换依赖项
在测试组件时,您希望完全控制依赖关系。 这意味着在许多情况下,您不能使用真正的依赖项。 首先,记住测试应该是快速和自动的吗? 如果真正的依赖使用数据库或 REST 调用来获取数据,这将使您的测试变慢。 网络、磁盘和数据库是比从内存访问数据慢的几个因素。 所以我们要避免这些事情。 此外,数据库和磁盘都有内存,所以当测试对数据进行修改时,下一次运行测试时,它会使用不同的数据并且可能会失败。 所以我们不想使用真正的依赖(我们正在测试组件,而不是依赖!)。 因此,我们将使用依赖项的虚假实现,这就是为什么让您的依赖项实现接口如此重要的原因。 构建另一个具有相同接口的类既简单又实用。
并且有不同种类的假物体。 让我们讨论如图 8-15 所示的存根和模拟。 如您所见,存根和模拟都是假对象的特例。 不幸的是,这里使用的术语(stub、mock、fake)在测试社区中并不一致。 有些人使用不同的名称对虚假对象进行分类,有些人甚至使用包含七种不同存根的分类法!
使用存根(Stubs)
让我们从存根开始。存根是依赖项的虚假实现,仅用于协助测试。我们的 FetchData 组件将从 IWeatherService 依赖项中获取几个预测。但是这会返回多少预测呢?如果我们使用真实的服务,这可能取决于一堆我们无法控制的事情。所以我们使用 IWeatherService 的存根实现,我们可以完全控制它。存根只是用来协助测试,我们将对被测对象执行断言阶段,而不是存根。让我用另一个例子。想象一下,你在一家汽车公司工作,你想测试你的新型汽车的安全性。你想把这辆车撞到墙上,看看它是否会爆炸(就像电影里一样)。你会把车撞到真正的墙上吗?别人家?不会。您将让某人在受控环境中建造一堵假墙,因此没有人会冒受伤的风险。你把车开到墙上,然后墙就达到了它的目的。您将检查汽车以查看测试结果;墙不再重要。如图 8-16 所示。
使用存根的测试也称为状态验证测试。
让我们为 IWeatherService 构建一个存根。 首先向测试项目添加一个名为 WeatherServiceStub 的新类。
实现如示例 8-40 所示的接口。 我们的存根有一个属性,它将保存将从服务返回的数据。
清单 8-40 实现 IWeatherService 存根
private class WeatherServiceStub : IWeatherService
{
public IEnumerable<WeatherForecast> FakeForecasts { get; set; }
= default!;
public ValueTask<IEnumerable<WeatherForecast>> GetForecasts()
=> new ValueTask<IEnumerable<WeatherForecast>>(
FakeForecasts);
}
现在更新 UseWeatherService 测试,如清单 8-41 所示。 我们创建存根的一个实例,用我们想要的数据对其进行初始化,然后将其作为单例传递给依赖注入。 当 FetchData 组件被初始化时,我们将使用存根,并且我们确信我们的服务会返回五行数据(或不同的数字;这就是我使用 const 方便更新的原因)。
清单 8-41 使用存根测试 FetchData 组件
[Fact]
public void UseWeatherService()
{
const int nrOfForecasts = 5;
var stub = new WeatherServiceStub
{
FakeForecasts = Enumerable.Repeat(new WeatherForecast(),
nrOfForecasts)
};
Services.AddSingleton<IWeatherService>(stub);
var cut = RenderComponent<FetchData>();
var rows = cut.FindAll("tbody tr");
rows.Count.Should().Be(nrOfForecasts);
}
使用模拟(Mocks)
那么什么是模拟?模拟是一种假实现,我们想要验证被测对象是否在模拟上调用了某些方法和属性。因此,mock 有点像数据记录器,记住调用了哪些方法,甚至记录方法调用中参数的值。构建一个 mock 需要更多的工作,这不足为奇!当您在测试中使用模拟时,您将通过模拟进行断言阶段,并提出诸如“被测对象是否调用此方法?”之类的问题。让我们再次使用汽车示例。现在我们想看看汽车司机是否在正面撞墙时受伤。我们已经有一堵墙,但现在我们需要一个司机。有志愿者吗?不?当然不是。我们将使用碰撞测试假人模拟驱动程序(模拟对象)。这些假人看起来很像人类(如果你是荷马辛普森),并且塞满了传感器。你让汽车撞到墙上。撞车后,你对墙不感兴趣,汽车也不感兴趣。你会问假人(模拟人记得)哪里疼。图 8-17 再次说明了这一点。
像这样的测试被称为对象交互测试。
让我们更新 FetchData 组件以执行一些日志记录,因此为 ILogger 添加一个@inject,并在 OnInitializedAsync 中使用它,如清单 8-42 所示。
清单 8-42 更新 FetchData 组件以使用日志记录
@page "/fetchdata"
@using Microsoft.Extensions.Logging
@using Testing.Shared
@inject IWeatherService WeatherService
@inject ILogger logger
...
protected override async Task OnInitializedAsync()
{
logger.LogInformation("Fetching forecasts");
forecasts = await WeatherService.GetForecasts();
}
}
所以我们要测试在 OnInitializedAsync 期间是否使用了 ILogger。 我们需要一个模拟实现,因为我们不想解析日志文件。 将一个名为 LoggerMock 的新类添加到您的测试项目中,如清单 8-43 所示。 单独实现这个类需要一些工作! 接下来,我们将看看如何使这更容易。 我们的模拟记录器只是在日志列表中记录了几个参数。
清单 8-43 实现 ILogger 模拟
private class LoggerMock : ILogger
{
public List<(LogLevel logLevel, object? state)> Journal
{ get; set; } = new List<(LogLevel,object?)>();
public IDisposable BeginScope<TState>(TState state)
=> throw new NotImplementedException();
public bool IsEnabled(LogLevel logLevel)
=> true;
public void Log<TState>(LogLevel logLevel, EventId eventId,
TState state, Exception? exception,
Func<TState, Exception?, string> formatter)
{
Journal.Add((logLevel, state));
}
}
向 FetchDataShould 类添加一个新的单元测试,如清单 8-44 所示。
清单 8-44 使用 Mock 测试 FetchData 组件
[Fact]
public void UseProperLogging()
{
const int nrOfForecasts = 5;
var stub = new WeatherServiceStub
{
FakeForecasts = Enumerable.Repeat(new WeatherForecast(),
nrOfForecasts)
};
Services.AddSingleton<IWeatherService>(stub);
LoggerMock logger = new LoggerMock();
Services.AddSingleton<ILogger>(logger);
var cut = RenderComponent<FetchData>();
logger.Journal.Count.Should().Be(1);
logger.Journal.First().state.Should().NotBeNull();
logger.Journal.First().state!.ToString().Should().Contain("Fetching forecasts");
}
因此,我们为 IWeatherService 创建一个存根,为 ILogger 创建一个模拟,然后渲染组件。 现在我们要检查 LoggerMock 的日志。 应该有一个对记录器的调用,所以我们检查日志的长度。 然后我们检查条目的状态,看它是否包含消息。 一切都直截了当,但工作量很大!
运行所有测试。 UseWeatherService 测试中断! 为什么? 因为我们引入了另一个依赖,所以我们也需要在这个测试中依赖注入一个记录器。 我会把修理交给你有能力的人。
使用 MOQ 实现存根(Stubs)和模拟(Mocks)
我们如何用更少的工作来实现存根和模拟? 其他人一直在问同样的问题,其中一些人构建了使这成为可能的库。通常,这些库被称为隔离框架。 隔离框架允许您为类和接口快速生成存根和模拟,在其中您只实现测试所需的方法,并验证被测对象是否调用具有某些参数的方法一定次数。 在这里,我们将看看目前测试社区中最受欢迎的最小起订量之一。 我们将在这里介绍 MOQ 的许多功能,但如果您想了解更多信息,可以访问
https://documentation.help/Moq。
首先将 MOQ NuGet 包添加到测试项目。 现在复制 UseWeatherServices 方法并将其重命名为 UseWeatherServicesMOQ。 更改其实现,如示例 8-45。 首先,我们创建希望 IWeatherService 返回的预测数据。 接下来,我们创建一个 Mock<IWeatherService>
的实例,它是 MOQ 中的一个类。 此类允许我们从接口设置方法并返回某个结果。 提供存根实现就是这么简单。 但是 MOQ 允许您走得更远,并使方法返回不同的结果,例如,取决于参数。
接下来,我们配置 bUnit 的依赖注入来注入一个单例实例,传递 stub.Object,它是一个实现 IWeatherService 接口的实例。 无需构建我们自己的类来创建存根。
我们的 FetchData 组件也需要一个记录器,但是这里我们对组件和记录器之间的交互不感兴趣,所以我们创建了另一个存根。 其余的测试保持不变。
清单 8-45。 使用 MOQ 实现存根
[Fact]
public void UseWeatherServiceMOQ()
{
const int nrOfForecasts = 5;
var forecasts = Enumerable.Repeat(new WeatherForecast(), nrOfForecasts);
Mock<IWeatherService> stub = new Mock<IWeatherService>();
stub.Setup(s => s.GetForecasts())
.Returns(new ValueTask<IEnumerable<WeatherForecast>>(forecasts));
Services.AddSingleton<IWeatherService>(stub.Object);
Mock<ILogger> loggerStub = new Mock<ILogger>();
Services.AddSingleton<ILogger>(loggerStub.Object);
var cut = RenderComponent<FetchData>();
var rows = cut.FindAll("tbody tr");
rows.Count.Should().Be(nrOfForecasts);
}
运行测试; 它应该通过。
现在是时候实现一个模拟了,我们想看看 FetchData 组件是否会调用记录器。 复制 UseProperLogging 方法并将其命名为 UseProperLoggingMOQ,如清单 8-46 所示。 在这里,您应该关注Verify方法。这里,我们验证Log方法是否被调用,我们可以说明多少次。 您可以在“从不”、“一次”、“至少”、“最多”、“完全”等之间进行选择。 Log 方法需要一堆参数,这个 Log 方法的工作方式有些尴尬。 第一个参数是 LogLevel 类型,我们检查 LogLevel.Information 值是否与
It.Is<LogLevel>(l => l == LogLevel.Information)
每个参数都用参数值的检查来表示。 您也可以使用 It.IsAny<T>
忽略参数的值,指定参数的类型。需要这种类型的参数来消除重载的歧义。 其他参数以类似的方式工作,即使是通用参数。 例如,如果一个参数的类型是 List<T>
,而您不知道 T,则使用 It.Is<List<It.IsAnyType>>
。 由于 ILogger 的具体实现细节,我们需要在这里使用它。
清单 8-46。 使用 MOQ 实现模拟
[Fact]
public void UseProperLoggingMOQ()
{
const int nrOfForecasts = 5;
var forecasts = Enumerable.Repeat(new WeatherForecast(), nrOfForecasts);
Mock<IWeatherService> stub = new Mock<IWeatherService>();
stub.Setup(s => s.GetForecasts())
.Returns(new ValueTask<IEnumerable<WeatherForecast>>(forecasts));
Services.AddSingleton<IWeatherService>(stub.Object);
Mock<ILogger> loggerMock = new Mock<ILogger>();
Services.AddSingleton<ILogger>(loggerMock.Object);
var cut = RenderComponent<FetchData>();
loggerMock.Verify(
l => l.Log(
It.Is<LogLevel>(l => l == LogLevel.Information),
It.IsAny<EventId>(),
It.Is<It.IsAnyType>(
(msg,t) => msg.ToString()!
.Contains("Fetching forecasts")),
It.IsAny<Exception>(),
It.Is<Func<It.IsAnyType, Exception?, string>>(
(v,t)=>true))
, Times.Once);
}
运行测试。 它应该通过。
在 Razor 中编写单元测试
当您使用 bUnit 构建单元测试时,有时会因为生成的所有标记而导致测试很长。 此外,MarkupMatches 方法接受一个字符串,如果您的标记使用 HTML 属性,则需要使用 \ 转义引号。 对于这些类型的测试,我们也可以使用 razor 来编写测试。 使用 razor 编写单元测试需要两件事:项目需要引用 razor SDK,这意味着您的测试项目应该将 SDK 类型设置为 razor:
<Project Sdk="Microsoft.NET.Sdk.Razor">
其次,您应该在测试项目中添加一个 _Imports.razor 文件以便于参考,如清单 8-47 所示。
清单 8-47 测试项目的 _Imports.razor 文件
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using Microsoft.JSInterop
@using Microsoft.Extensions.DependencyInjection
@using AngleSharp.Dom
@using Bunit
@using Bunit.TestDoubles
@using Xunit
我也建议在这里添加你项目的命名空间。
第一次Razor测试
在您的测试项目中,添加一个名为 RCounterShould 的新 razor 组件,如清单 8-48 所示。在这里,我将为 razor 单元测试添加一个 R 前缀,这样我们就不会与其他 CounterShould 测试类发生名称冲突。 我们将使测试继承自 TestContext,就像我们用 C# 编写的测试类一样。 然后我们添加一个@code 部分并将我们的xUnit 测试方法放在那里。 因为这是一个 razor 文件,我们可以在 Render 方法中使用 razor 编写测试的标记。
在 MarkupMatches 方法中,我们还可以使用普通的 razor 编写标记。 这使得编写这样的测试更简单、更令人愉快。
清单 8-48 使用 Razor 编写简单的单元测试
@inherits Bunit.TestContext
@code {
[Fact]
public void RenderCorrectlyWithInitialZero()
{
var cut = Render(@<Counter />);
cut.Find("p")
.MarkupMatches(@<p>Current count: 0</p>);
}
[Fact]
public void IncrementCounterWhenButtonIsClicked()
{
var cut = RenderComponent<Counter>();
cut.Find(cssSelector: "button")
.Click();
cut.Find(cssSelector: "p")
.MarkupMatches(@"<p>Current count: 1</p>");
}
}
传递参数呢? 添加一个名为 RTwoWayCounterShould 的新组件,如清单 8-49 所示。 由于我们可以使用普通 razor 渲染我们的组件,我们可以在 razor 语法中传递参数,如第一个测试方法所示! 第二种测试方法说明了我们如何测试双向数据绑定,同样使用同样熟悉的 razor 语法。
清单 8-49 在 Razor 测试中传递参数
@inherits Bunit.TestContext
@code {
[Fact]
public void IncrementCounterWhenButtonIsClicked()
{
var cut = Render(@<TwoWayCounter CurrentCount="1" Increment="2"/>);
cut.Find("button").Click();
cut.Find("p")
.MarkupMatches(@<p>Current count: 3</p>
);
}
[Fact]
public void TriggerChangedEventForCurrentCounter2()
{
int currentCount = 1;
var cut = Render(@<TwoWayCounter
@bind-CurrentCount="currentCount"
Increment="2"/>
);
cut.Find(cssSelector: "button")
.Click();
currentCount.Should().Be(3);
}
}
让我们看一个使用 ChildContent 的示例。 将一个名为 RAlertShould 的新 razor 组件添加到测试项目中,如清单 8-50 所示。 Alert 组件使用 ChildContent,我们可以通过将子内容嵌套在 Alert 标记中来传递它。要查看组件是否按预期呈现,我们可以在 MarkupMatches 方法中使用简单的 HTML 标记。
清单 8-50 使用 ChildContent 测试组件
@inherits Bunit.TestContext
@code {
[Fact]
public void RenderSimpleChildContent()
{
var cut = Render(
@<Alert>
<h1>Hello world!</h1>
</Alert>
);
cut.MarkupMatches(
@<div class="alert alert-secondary mt-4" role="alert">
<h1>Hello world!</h1>
</div>
);
}
}
添加另一个 razor 组件,称为清单 8-51 中的 RTemplatedListShould。再次,我们想看看当项目为 null 时组件是否显示正在加载的 RenderFragment。 再次使用 razor 传递 RenderFragment。
清单 8-51 对模板化组件使用 Razor 测试
@inherits Bunit.TestContext
@code {
[Fact]
public void RenderLoadingTemplateWhenItemsIsNull()
{
RenderFragment loading =
@<div class="loader">Loading...</div>;
Func<ValueTask<IEnumerable<string>?>> loader =
() => new ValueTask<IEnumerable<string>?>(
result: null);
var cut = Render(
@<TemplatedList Loader="@loader">
<LoadingContent>
<div class="loader">Loading...</div>
</LoadingContent>
</TemplatedList>
);
cut.Find("div.loader")
.MarkupMatches(loading);
}
}
处理异步重新渲染
当您构建覆盖 OnInitializedAsync 或OnParametersSetAsync,您的组件将至少呈现两次自身 - 第一次是在创建组件时以及在完成 OnInitializedAsync 之后以及在完成每个 OnParametersSetAsync 之后再次呈现。
在 bUnit 测试中,这可能会给您带来问题。 让我们看一个例子。
将清单 8-52 中的以下单元测试添加到 RTemplatedListShould 类。 在这个测试中,我们使用 TaskCompletionSource
清单 8-52 测试异步重新渲染
[Fact]
public void RenderItemsAftersItemsLoadedAsyncCorrectly()
{
const int count = 5;
var tcs = new TaskCompletionSource<IEnumerable<string>?>();
Func<ValueTask<IEnumerable<string>?>> loader =
() => new ValueTask<IEnumerable<string>?>(tcs.Task);
var cut = Render(
@<TemplatedList Loader="@loader">
<LoadingContent>
<div class="loader">Loading...</div>
</LoadingContent>
<ItemContent Context="item">
<ListItem Item="@item" />
</ItemContent>
</TemplatedList>
);
cut.Find("div.loader")
.MarkupMatches(@<div class="loader">Loading...</div>);
// Complete the loader task,
// this should rerender the component asynchronously
tcs.SetResult(Enumerable.Repeat("A", count));
var ps = cut.FindAll("p");
ps.Should().NotBeEmpty();
foreach (var p in ps)
{
p.MarkupMatches(@<p>A</p>);
}
}
运行测试。 它会失败! 为什么? 因为我们的组件会在另一个线程上渲染 UI,并且测试会在渲染完成之前检查 UI。 所以我们需要等待一段时间,直到 UI 渲染完成。 我们应该怎么做? 在 SetResult 调用之后添加这行代码,使用示例 8-53 中的完整方法。
cut.WaitForState(() => cut.FindAll("p").Any());
WaitForState 方法将等到条件返回 true。 我们知道 UI 会渲染一堆段落,所以我们等到看到它们。 WaitForState 还有一个参数(此处未显示)来设置超时,默认值为 1 秒。 如果剪切在超时时间内未通过条件,则测试将失败并出现 WaitForFailedException。
清单 8-53 测试异步重新渲染
[Fact]
public void RenderItemsAftersItemsLoadedAsyncCorrectly()
{
const int count = 5;
var tcs = new TaskCompletionSource<IEnumerable<string>?>();
Func<ValueTask<IEnumerable<string>?>> loader =
() => new ValueTask<IEnumerable<string>?>(tcs.Task);
var cut = Render(
@<TemplatedList Loader="@loader">
<LoadingContent>
<div class="loader">Loading...</div>
</LoadingContent>
<ItemContent Context="item">
<ListItem Item="@item" />
</ItemContent>
</TemplatedList>
);
cut.Find("div.loader")
.MarkupMatches(@<div class="loader">Loading...</div>);
// Complete the loader task,
// this should rerender the component asynchronously
tcs.SetResult(Enumerable.Repeat("A", count));
// Wait for rendering to complete
cut.WaitForState(() => cut.FindAll("p").Any());
var ps = cut.FindAll("p");
ps.Should().NotBeEmpty();
foreach (var p in ps)
{
p.MarkupMatches(@<p>A</p>);
}
}
配置语义比较
bUnit 测试库使用 AngleSharp Diffing 库将生成的标记与 MarkupMatches 方法中的预期标记进行比较。 您可以在 GitHub 上的 https://github.com/AngleSharp/AngleSharp.Diffing 上找到 AngleSharp。 为了使您的测试更加健壮,您可以配置语义比较的工作方式; 例如,我们可以告诉它忽略某些 HTML 属性和元素。
为什么我们需要语义比较?
使用字符串来比较标记对标记中的微小变化过于敏感。 例如,格式化您的代码可能会添加一些空格,并且由于字符串比较会比较每个字符,因此工作测试会突然失败。 而且还有很多无辜的更改会破坏测试,例如,更改属性的顺序,或重新排序类属性中的类,或添加注释。 语义比较会忽略所有这些变化,导致测试不会因为一个简单的变化而中断。
自定义语义比较
记住我们之前的一个测试,我们告诉 MarkupMatches 方法忽略该属性(示例 8-16)。 AngleSharp Diffing 库允许我们使用特殊属性来忽略某些元素和属性; 例如, <div style:ignore>
将忽略 style 属性的内容。 我们也可以让它忽略某些 HTML 元素; 例如,将清单 8-54 中的测试添加到 AlertShould 类中。
清单 8-54 使用语义比较忽略元素
[Fact]
public void RenderCorrectly()
{
var cut = RenderComponent<Alert>(parameters =>
parameters.AddChildContent("<p>Hello world!</p>"));
cut.MarkupMatches(@"
<div class=""alert alert-secondary mt-4"" role=""alert"">
<p diff:ignore></p>
</div>");
}
我们可以对 razor 测试做同样的事情,例如清单 8-55,它应该被添加到 RAlertShould razor 文件中。
清单 8-55 使用 Razor 测试忽略元素
[Fact]
public void RenderCorrectly()
{
var cut = Render(
@<Alert>
<h1>Hello world!</h1>
</Alert>
);
cut.MarkupMatches(
@<div class="alert alert-secondary mt-4" role="alert">
<h1 diff:ignore></h1>
</div>
);
}
默认情况下,语义比较会忽略空格,但在某些情况下,您想验证组件是否实际呈现了一些空格。 使用 diff:whitespace="preserve" 执行此操作。
您还可以告诉语义比较忽略大小写或使用正则表达式进行比较。
让我们测试示例 8-56 中的简单 Card 组件。
清单 8-56 一个简单的卡片组件
<h3 id="card-@Id">Card @Id</h3>
@code {
[Parameter]
public int Id { get; set; }
}
一个单元测试将检查 id 属性是否与 card- 后跟一到四位数字以及内容是否与 Card 与一到四位数字匹配,如示例 8-57 所示。 我们还希望测试忽略卡片内容的大小写。
清单 8-57 忽略大小写并使用正则表达式
using Bunit;
using Testing.Client.Pages;
using Xunit;
namespace Testing.ComponentTests
{
public class CardShould : TestContext
{
[Fact]
public void RenderCorrectlyWithProperId()
{
var cut = RenderComponent<Card>();
cut.MarkupMatches(@"<h3 diff:ignorecase diff:regex id:regex=""card-\d{1,4}"">card \d{1,4}</h3>");
}
}
}