Loading

第八章-单元测试

单元测试 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,测试资源管理器是运行单元测试和查看结果的地方。 打开测试资源管理器后,它将扫描您的解决方案以查找单元测试并列出它们。 现在单击此窗口中的左绿色箭头以运行所有测试。

image

测试将运行和失败,如图 8-3 所示。

image

您也可以从 Visual Studio Code 运行单元测试,但您必须安装 .NET Core 测试资源管理器扩展,如图 8-4 所示。

image

现在您可以通过单击 VSC 左侧的测试资源管理器图标来运行测试,如图 8-5 所示。

image

VSC 测试资源管理器将显示几个按钮,如图 8-6 所示。 从左到右,您有一个按钮来运行测试、刷新可用测试列表、停止测试执行以及显示带有测试结果的日志。

image

单击刷新按钮。 这将扫描您的项目以查找可用的测试。 如果测试资源管理器找不到任何测试,您可以设置 dotnet-test-explorer.testProjectPath 设置,例如:

"dotnet-test-explorer.testProjectPath": "**/*Tests.csproj"

单击播放图标运行测试。 测试将运行并失败。 要查看测试结果,请单击日志按钮,然后查看问题选项卡,如图 8-7 所示。

image

让你的测试通过

为什么测试失败了? 如果您在 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 一样通过。

image

image

使用事实和理论

但是其他值呢? 使用 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] 属性一个。

image

image

检查你的理智

你有没有一段代码做的事情与你预期的不同?就个人而言,我开始怀疑我的理智,比如“我疯了吗?” 或者您是否使用了某人的方法,该方法记录不充分,并且没有按照应有的方式进行? 通过单元测试,您可以设置检查以查看方法是否执行您认为应该执行的操作。 如果没有,也许你需要和作者谈谈,看看什么更有意义。当你有单元测试时,你可以将它附加到错误报告中,方便作者重现错误。 让我们再看一个例子。 现在我想看看当我们将一个大整数传递给 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);
                                     });
}

image

为什么会失败? 让我们在 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 语句。 在这里,我们希望组件的标记与文字字符串匹配。 它确实以某种方式匹配,除了空格和换行符。 我们现在可以完成这项工作并将我们的字符串更新为实际输出,但这对我们稍后可能对组件进行的微小更改过于敏感。

image

让我们改进测试,如示例 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 所示的以下输出。 这个输出告诉我们出了什么问题,现在我们可以更改组件(或测试),直到测试通过。 修复测试。

image

测试组件交互

我们的 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 传递,则需要使用 Add<ComponentType, TItem> 重载,其中第一个泛型参数是要使用的组件的类型,第二个是要使用的组件的类型 RenderFragment 的通用参数。 所以在这种特定情况下,ComponentType 是 ListItem,TItem 是字符串(因为我们将 IEnumerable 传递给 TemplatedList)。

清单 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)在测试社区中并不一致。 有些人使用不同的名称对虚假对象进行分类,有些人甚至使用包含七种不同存根的分类法!

image-20220821024022690

使用存根(Stubs)

让我们从存根开始。存根是依赖项的虚假实现,仅用于协助测试。我们的 FetchData 组件将从 IWeatherService 依赖项中获取几个预测。但是这会返回多少预测呢?如果我们使用真实的服务,这可能取决于一堆我们无法控制的事情。所以我们使用 IWeatherService 的存根实现,我们可以完全控制它。存根只是用来协助测试,我们将对被测对象执行断言阶段,而不是存根。让我用另一个例子。想象一下,你在一家汽车公司工作,你想测试你的新型汽车的安全性。你想把这辆车撞到墙上,看看它是否会爆炸(就像电影里一样)。你会把车撞到真正的墙上吗?别人家?不会。您将让某人在受控环境中建造一堵假墙,因此没有人会冒受伤的风险。你把车开到墙上,然后墙就达到了它的目的。您将检查汽车以查看测试结果;墙不再重要。如图 8-16 所示。

image

使用存根的测试也称为状态验证测试。
让我们为 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 再次说明了这一点。

image

像这样的测试被称为对象交互测试。
让我们更新 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 类使加载器真正异步。 此类的实例有一个 Task,它将通过调用 SetResult 方法继续执行。 在此之前,任务将阻止任何等待者。 这允许我们渲染组件,查看加载 UI,然后通过调用 SetResult 完成任务,然后查看项目是否被渲染。

清单 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>");
        }
    }
}
posted @ 2022-09-04 14:10  F(x)_King  阅读(167)  评论(0编辑  收藏  举报