使用 C# 和 .NET 进行单元测试(完整指南)
在本节中,我们将探索 C# 和 .NET 中的单元测试世界,了解什么是单元测试、为什么它很重要,以及开发人员可用的测试框架和工具的概况。
单元测试是指将代码中各个单元(通常是方法或函数)与系统其他部分隔离,并验证其正确性的过程。这可以确保每个单元都能按预期运行,并有助于在开发过程早期发现潜在的错误或问题。
单元测试是自动化的、独立的,并且专注于单元的特定方面。它们应该易于理解、维护和执行,为任何想要确保代码质量的开发人员提供坚实的基础。
单元测试在软件开发中的重要性
单元测试是现代软件开发中至关重要的实践。它的重要性怎么强调都不为过,因为它:
- 确保功能正常:它验证每个代码单元是否按预期工作,避免出现错误和其他问题。
- 增强可维护性:编写良好的测试就像一张安全网,使开发人员能够自信地重构或更改代码。
- 提高代码质量:它鼓励遵循 SOLID 原则等最佳实践,使开发人员能够更好地编写更易于测试的代码。
- 加速开发:尽早且频繁地进行测试,可以让开发人员更快地发现和修复问题,从而减少调试所花费的总时间。
- 促进协作:共享测试套件使开发人员对代码有共同的理解,从而实现顺畅的协作和更好的沟通。
C# 和 .NET 单元测试概览:测试框架和工具
C# 和 .NET 中有多种单元测试框架和工具可供使用,但最流行的有:
- xUnit:一个现代化的、可扩展的测试框架,注重简洁性和易用性。它通常被认为是 .NET Core 单元测试的事实标准。
- NUnit:一个广泛使用、成熟的测试框架,拥有丰富的功能集和庞大的插件生态系统。它历史悠久,许多传统的 .NET 项目都在使用它。
- MSTest:Microsoft Visual Studio 套件提供的默认测试框架,与 Visual Studio 紧密集成,并由 Microsoft 提供支持。
- Moq:一个功能强大的模拟库,专为 .NET 设计,允许开发人员创建模拟对象,以便对与外部依赖项交互的单元进行隔离测试。
每个框架都有其优点和缺点,但在本文中,我们将重点介绍 xUnit 和 Moq,它们是 C# 开发人员进行单元测试的热门选择。
xUnit 入门:一款现代化的 C# 测试框架
现在我们已经了解了 C# 单元测试的概况,让我们深入了解 xUnit,学习它的优势,并使用该框架设置我们的第一个单元测试。
为什么选择 xUnit 而不是其他测试框架?
xUnit 已成为 .NET 社区的首选,原因有以下几点:
- 现代性:它是专门为 .NET Core 设计的,带来了现代化的方法和新功能。
- 简洁性:xUnit 强调简洁性,即使对于单元测试新手来说,也易于学习和使用。
- 可扩展性:xUnit 提供了许多可扩展点,例如其属性、断言和约定,允许开发人员根据自己的需要进行定制。
- 强大的社区支持:xUnit 在 .NET 社区中得到广泛应用,拥有丰富的资源、文档和常见问题的解答。
- 集成:它与 Visual Studio、VSCode、ReSharper 和 .NET CLI 等流行工具集成,简化了测试体验。
鉴于这些优势,我们将在以下章节中向您展示如何开始使用 xUnit。
在 .NET 项目中安装和设置 xUnit
要开始使用 xUnit,请按照以下简单步骤操作:
- 首先,使用您喜欢的方法(例如 Visual Studio、JetBrains Rider 或 .NET CLI)创建一个新的 .NET Core 测试项目:
dotnet new xunit -n MyUnitTestProject
- 接下来,导航到项目目录,然后运行以下命令来恢复项目依赖项:
dotnet restore
- 最后,使用以下命令执行您的第一个示例测试:
dotnet test
设置完成后,您现在可以开始使用 xUnit 编写自己的单元测试了!
使用 xUnit 编写你的第一个 C# 单元测试用例
在本节中,我们将逐步讲解如何使用 xUnit 创建一个简单的单元测试。例如,假设我们有一个类Add,其中包含以下方法的实现Calculator:
public class Calculator
{
public int Add(int a, int b)
{
return a + b;
}
}
要为该方法编写单元测试Add,请在测试项目中创建一个名为 `test` 的新测试类CalculatorTests。在该类中,添加一个名为 `get` 的方法Add_PositiveNumbers_ReturnsExpectedResult,并使用 `@ [Fact]Test` 属性进行修饰,如下所示:
using Xunit;
using MyProject;
public class CalculatorTests
{
[Fact]
public void Add_PositiveNumbers_ReturnsExpectedResult()
{
// Arrange
var calculator = new Calculator();
int a = 3;
int b = 5;
int expectedResult = 8;
// Act
int actualResult = calculator.Add(a, b);
// Assert
Assert.Equal(expectedResult, actualResult);
}
}
当使用该测试运行测试时dotnet test,测试套件现在将执行此测试,并根据该方法是否按预期运行提供通过/失败结果。
xUnit 测试属性、约定和执行流程
xUnit 在其执行流程中使用了多种属性和约定:
- 事实:
[Fact]属性指示某个方法是否为测试用例。该方法既不应有任何返回类型,也不应有任何输入参数。 - 理论:
[Theory]属性表示数据驱动的测试方法,允许多个输入,每个输入都会导致单独的测试执行。 - InlineData:
[InlineData]该属性为测试提供内联数据[Theory],简化测试数据管理。 - MemberData:
[MemberData]允许通过指定要从中提取测试数据的成员,在测试方法之间共享数据。
此外,xUnit 还提供了一个可选的测试执行顺序控制约定,可以根据具体要求进行自定义,以控制测试执行流程。
现在我们已经掌握了基础知识,可以开始学习使用 xUnit 和 C# 进行更高级的单元测试技术了。
编写高效且易于维护的 C# 单元测试
单元测试的一个重要方面是创建易于理解、维护和扩展的测试用例。在本节中,我们将探讨结构良好的测试用例的组成要素,在测试设计中应用 SOLID 原则,封装测试的设置和清理过程等等。
结构良好的测试的剖析:安排、实施、陈述
一个好的单元测试遵循“安排、执行、断言”模式,这使得代码简洁、易于理解和维护。为了解释这种模式,我们来详细分析一下:
- 准备:搭建测试环境并实例化被测系统或其依赖项。具体来说,这可能意味着创建模拟对象、设置异常处理程序或初始化状态。
- 操作:使用准备好的环境调用目标方法。
- 断言:检查预期结果是否与实际结果相等。如果不相等,则测试失败。尽量保证每个测试只使用一个断言。
这种模式有助于确保测试在逻辑上组织有序且易于访问,从而提高测试的可维护性。
在测试设计中应用SOLID原则
为了确保单元测试保持可管理性、可维护性和易于理解性,它们应该像生产代码一样遵循 SOLID 原则:
- 单一职责原则(SRP):每个测试都应该专注于一个特定的单元或行为。避免在单个测试中混合多个断言,这样更容易理解和排查问题。
- 开闭原则(OCP):确保测试是开放的,以便于扩展,这意味着添加新的测试用例不需要修改现有的测试用例。
- 里氏替换原则(LSP):在使用测试继承或共享测试夹具时,确保基类或测试夹具可以被其派生类型替换,而不会损害测试的完整性。
- 接口隔离原则(ISP):如果测试需要特定的接口,则应仅依赖于该接口,而不是依赖于更大、更复杂的接口。这有助于缩小测试的依赖关系和范围。
- 依赖倒置原则(DIP):依赖于抽象概念,而不是具体的实现。在测试中,这意味着使用像 Moq 这样的模拟框架来将测试与依赖项的实际实现隔离。
封装测试设置和拆卸
在许多测试场景中,我们需要在测试执行前后编写设置或清理代码。xUnit 支持通过以下方式封装测试设置和清理代码:
- 构造函数和 IDisposable:在 xUnit 中,测试类的构造函数用于设置,而 IDisposable 接口的实现用于清理。这是最常用且推荐的方法。
public class MyTestFixture : IDisposable
{
public MyTestFixture()
{
// Test setup code here
}
public void Dispose()
{
// Test teardown code here
}
}
- IAsyncLifetime:对于异步设置和拆卸代码,xUnit 提供了该
IAsyncLifetime接口,它具有InitializeAsync和DisposeAsync方法。
public class MyTestFixture : IAsyncLifetime
{
public async Task InitializeAsync()
{
// Async test setup code here
}
public async Task DisposeAsync()
{
// Async test teardown code here
}
}
C# 单元测试最佳实践:命名、组织和粒度
为了保持测试代码的简洁性和可维护性,关注代码的组织结构和命名规范至关重要。一些最佳实践包括:
- 命名:为测试方法命名时,请使用能够清晰传达其目的的描述性标题。使用类似这样的命名约定
MethodName_Scenario_ExpectedBehavior有助于快速理解测试的意图。 - 组织:将相关的测试分组到同一个类或命名空间中,以便更容易找到相关的测试。
- 粒度:每个测试用例只测试一个行为。较小的测试用例可以减少测试失败后的调试工作量。
- 可读性:编写简洁易懂的测试,使开发人员能够轻松理解测试的意图和预期结果。
将测试驱动开发 (TDD) 融入您的工作流程
测试驱动开发 (TDD) 是一种强大的开发方法,其核心是先编写测试,然后再进行实现。它遵循一个循环过程:
- 编写一个失败的测试
- 实现该功能以通过测试
- 在保持测试通过的前提下重构代码
TDD 从一开始就注重编写简单、清晰且经过测试的代码,从而提高代码质量、可维护性和整体开发效率。
用于编写健壮测试用例的高级 xUnit 技术
接下来,让我们扩展对 xUnit 的知识,并探索创建健壮测试用例的高级技术。
利用 InlineData 和 MemberData 实现数据驱动测试
xUnit 的[Theory]属性允许创建数据驱动的测试,使用[InlineData]`or`[MemberData]来提供输入值:
- InlineData:直接在测试方法属性中提供内联数据值。
[Theory]
[InlineData(1, 2, 3)]
[InlineData(-1, -2, -3)]
public void Add_TwoNumbers_ReturnsSum(int a, int b, int expectedResult)
{
// Test implementation
}
- MemberData:指定一个成员(属性或方法),该成员返回测试数据的枚举,每个测试用例应返回一个对象数组。
public static IEnumerable<object[]> TestData
{
get
{
yield return new object[] { 1, 2, 3 };
yield return new object[] { -1, -2, -3 };
}
}
[Theory]
[MemberData(nameof(TestData))]
public void Add_TwoNumbers_ReturnsSum(int a, int b, int expectedResult)
{
// Test implementation
}
使用类和集合测试用例共享测试上下文
在 xUnit 中,测试上下文共享是通过 fixtures 实现的,这有助于避免代码重复并确保一致的设置/拆卸。
- 类测试夹具:在类中的所有测试中共享同一个上下文实例。要使用类测试夹具,请创建一个实现 `class fixture` 接口的类
IClassFixture<T>,其中 ` classTfixture` 是上下文类型。
public class MyTestFixture : IClassFixture<MyContext>
{
// Test implementation
}
- 集合测试用例:在多个测试类之间共享上下文实例,适用于资源密集型环境。创建一个实现该
ICollectionFixture<T>接口的集合定义类,然后应用该[CollectionDefinition]属性。
[CollectionDefinition("MyCollection")]
public class MyCollection : ICollectionFixture<MyContext>
{
}
[Collection("MyCollection")]
public class MyTest1
{
// Test implementation
}
[Collection("MyCollection")]
public class MyTest2
{
// Test implementation
}
xUnit 中的跳过测试和条件测试执行
有时,我们可能希望跳过测试或根据特定情况有条件地执行测试。xUnit 提供了相应的选项来简化这一过程:
- 跳过测试:使用or属性
Skip上的参数来跳过测试。[Fact][Theory]
[Fact(Skip = "Skipping this test due to ...")]
public void MySkippedTest()
{
// Test implementation
}
- 条件测试执行:
[ConditionalFact]and[ConditionalTheory]属性允许您根据布尔值有条件地执行测试,该布尔值可以通过自定义方法或属性获得。
[ConditionalFact(nameof(IsFeatureEnabled))]
public void MyConditionalTest()
{
// Test implementation
}
private static bool IsFeatureEnabled()
{
// Return true if feature is enabled
}
自定义测试输出:xUnit 日志记录器和报告器
在某些情况下,您可能需要自定义测试输出,以生成各种格式的报告或日志。xUnit 通过一套日志记录器和报告器系统来支持此功能:
- 日志记录器:实现
ITestOutputHelper将测试输出重定向到自定义位置或附加其他信息的接口。
public class MyTests
{
private readonly ITestOutputHelper _output;
public MyTests(ITestOutputHelper output)
{
_output = output;
}
[Fact]
public void TestWithCustomOutput()
{
_output.WriteLine("Custom log message");
// Test implementation
}
}
- 报告器:可以通过实现该接口创建自定义报告器
IMessageSinkWithTypes,从而对测试输出和格式进行更精细的控制。
public class MyCustomReporter : IMessageSinkWithTypes
{
public bool OnMessageWithTypes(IMessageSinkMessage message, HashSet<string> messageTypes)
{
// Custom test report logic here
}
}
应对不稳定和非确定性测试的技巧
不稳定的测试会造成诸多麻烦和时间浪费,因为测试结果(通过/不通过)可能会受到外部因素或时间的影响。以下是一些应对非确定性测试的技巧:
- 模拟:使用 Moq 等模拟框架来替换可能导致不稳定的外部依赖项。
- 超时:使用超时机制来确保测试不会因为同步机制故障或长时间延迟而无限期运行。
- 测试稳定性:使你的测试能够适应系统中的微小变化,在确保一致性的同时,还能检查正确性。
- 重试:针对因瞬态问题而间歇性失败的测试,实现重试逻辑。
C# 单元测试中的 Mocking 和 Moq 简介
在本部分中,我们将介绍模拟的概念、它的好处,以及 .NET 生态系统中最流行的模拟库之一 Moq。
了解嘲讽的目的及其益处
模拟(Mocking)是指创建被测系统与之交互的对象或依赖项的“虚假”实现。这使得开发人员能够将测试与实际实现隔离,从而提高控制力、稳定性和隔离性。模拟的优势包括:
- 测试隔离:模拟可以完全控制测试行为,避免真实实现带来的副作用。
- 更大的灵活性:借助模拟,测试变得更加灵活,可以模拟一些使用实际依赖项可能难以重现的边缘案例或特殊场景。
- 降低复杂性:模拟可以让你专注于被测系统的行为,而不是处理现实世界依赖关系的复杂性。
- 速度:模拟可以通过减少资源密集型操作或网络交互来显著加快测试执行速度。
Moq:一款功能强大且广受欢迎的.NET开发人员模拟库
Moq 是一个流行且功能强大的 .NET 模拟库,它提供了一个简单直观的 API,用于创建和管理模拟对象。Moq 的主要特性包括:
- 强类型:Moq 利用 C# 的强类型,提供对模拟方法调用和行为的编译时检查。
- 富有表现力的 API:Moq 的 API 设计得富有表现力且易于使用,允许开发人员用最少的代码指定预期和行为。
- LINQ查询:Moq 支持 LINQ 查询,可以轻松定义复杂的模拟行为和预期。
- 集成:Moq 可以与流行的测试框架(如 xUnit)无缝协作,从而更容易创建健壮且易于维护的单元测试。
将 Moq 集成到您的 .NET 项目中
要开始使用 Moq,请按照以下步骤操作:
- 使用您喜欢的方法(例如 Visual Studio、JetBrains Rider 或 .NET CLI)将 Moq NuGet 包添加到您的测试项目中:
dotnet add package Moq
using Moq;在计划使用模拟对象的测试类中添加该语句。Mock<T>在测试中使用 Moq 的类来创建和配置模拟对象,其中T模拟的是接口或类。
精通 Moq:使用 Mock 对象编写隔离单元测试
在本节中,我们将深入了解 Moq,学习如何创建和配置模拟对象、设置期望、响应和返回值,以及验证交互和行为。
使用 Moq 创建和配置模拟对象
要使用 Moq 创建模拟对象,请实例化一个Mock<T>对象,其中T`<method>` 是要模拟的接口或类。以下示例以IOrderService接口为例:
public interface IOrderService
{
bool PlaceOrder(Order order);
}
要创建此接口的模拟对象,请使用以下代码:
var mockOrderService = new Mock<IOrderService>();
创建模拟对象后,您可以使用 Moq 的方法(例如Setup,、,等等)Returns来配置其行为和预期。Throws
使用 Moq 设置预期、响应和返回值
Moq 允许您为方法指定预期结果和响应。让我们来看几个常见场景:
- 返回值:要为模拟方法指定返回值,请使用以下
Returns方法:
mockOrderService.Setup(x => x.PlaceOrder(It.IsAny<Order>())).Returns(true);
- 抛出异常:要在调用模拟方法时抛出异常,请使用以下
Throws方法:
mockOrderService.Setup(x => x.PlaceOrder(It.IsAny<Order>())).Throws(new InvalidOperationException());
- 回调函数:如果您希望在调用模拟方法时执行特定代码,请使用以下
Callback方法:
mockOrderService.Setup(x => x.PlaceOrder(It.IsAny<Order>()))
.Callback<Order>(order => Console.WriteLine($"Order placed: {order.Id}"))
.Returns(true);
验证交互和行为:断言模拟调用
Moq 的另一个强大功能是能够验证被测系统与模拟依赖项之间的交互。为此,请使用以下Verify方法:
mockOrderService.Verify(x => x.PlaceOrder(It.IsAny<Order>()), Times.Once);
在这个例子中,我们验证该PlaceOrder方法是否被调用过一次,且调用Order对象不限。
超越基础:最小请求回调、序列和事件触发
Moq 为高级场景提供更高级的选项:
- 回调函数:如前所述,您可以使用回调
Callback函数在调用模拟方法时执行特定代码。您甚至可以捕获方法参数以进行进一步验证:
mockOrderService.Setup(x => x.PlaceOrder(It.IsAny<Order>()))
.Callback<Order>(order => Assert.NotNull(order))
.Returns(true);
- 序列:如果模拟方法被多次调用,您可以使用扩展程序设置一系列响应或操作
Sequence:
mockOrderService.SetupSequence(x => x.PlaceOrder(It.IsAny<Order>()))
.Returns(true)
.Throws(new InvalidOperationException())
.Returns(false);
- 事件触发:Moq 允许您模拟事件并从模拟对象触发它们:
public interface IOrderNotifier
{
event EventHandler<OrderEventArgs> OrderPlaced;
}
var mockOrderNotifier = new Mock<IOrderNotifier>();
// Raise the event
mockOrderNotifier.Raise(x => x.OrderPlaced += null, new OrderEventArgs { Order = myOrder });
Moq 和 xUnit 集成:用例和注意事项
由于 Moq 和 xUnit 兼容性良好,因此将它们集成起来非常简单。这两个库都强调简洁性和易用性。但是,也存在一些潜在的缺陷,例如:
- 测试夹具生命周期:在使用 Moq 共享测试夹具时,请注意夹具的生命周期会影响模拟对象的状态。可能需要重置或重新配置这些夹具。
- 异步/等待:如果您的测试涉及异步方法,请务必相应地设置和验证模拟对象的行为。
利用依赖注入和模拟最佳实践增强单元测试
现在我们对 Moq 有了更多了解,让我们来探讨在测试中结合依赖注入 (DI) 和模拟的最佳实践。
利用依赖注入提高测试灵活性
依赖注入 (DI) 是一种设计模式,它能提高测试的灵活性。通过将依赖项作为构造函数或方法参数注入,而不是直接实例化它们,我们可以实现以下目标:
- 更容易模拟:模拟对象可以更容易地注入,从而实现更好的测试隔离。
- 降低耦合度:依赖关系明确表达,使代码更容易理解和维护。
尽可能应用依赖注入,尤其是在处理外部依赖项时,这样可以更有效地使用 Moq。
测试中何时使用模拟数据、存根数据和伪数据
在测试中处理依赖项时,了解何时使用每种类型至关重要:
- 模拟对象:当需要验证被测系统与其依赖项之间的交互或行为时,可以使用模拟对象。它可用于断言特定方法是否被调用、是否使用了特定参数或是否被调用了特定次数。
- 存根:当您需要提供来自依赖项的固定响应或数据,但又不想断言其行为时,请使用存根。它们非常适合提供输入和模拟状态转换。
- 伪类:伪类是依赖项的轻量级内存实现,可以复制其基本行为。当您需要更精细地控制依赖项的行为或状态,且模拟或桩函数不足以满足需求时,请使用伪类。
寻求平衡:有效利用模拟方法实现可维护的测试
过度使用模拟对象会导致测试变得脆弱,容易出错且难以维护。因此,在模拟对象和其他技术(例如桩对象或伪对象)之间取得平衡至关重要。关键技巧包括:
- 不要过度模拟:只模拟必要的依赖项,避免模拟所有内容。
- 仅模拟依赖项的部分内容:有时,您可以模拟依赖项的部分内容,而不是整个对象。
- 简洁至上:评估是否真的需要模拟对象。使用更简单的替代方案,例如桩对象或伪对象,可以简化测试。
利用工厂和建筑商构建模块化测试
为了进一步提高测试的可维护性,可以使用工厂和构建器来生成模拟对象、测试数据或被测系统的实例:
- 工厂:创建工厂方法或类,用于实例化或配置测试套件中使用的通用对象。这可以封装对象创建过程,减少代码重复。
- 构建器模式:使用构建器模式可以通过逐步设置属性或配置来构建复杂对象。这可以使测试代码更易读、更灵活。
利用指标衡量和改进测试质量
最后,我们来讨论如何衡量测试质量,并使用代码覆盖率、测试质量指标和处理边界情况等指标进行改进。
C#单元测试中代码覆盖率的重要性
代码覆盖率是一项重要的指标,可以帮助您了解测试对生产代码的覆盖程度。虽然它不能全面反映测试质量,但确实可以帮助您发现测试套件中可能存在的不足。
目标是达到合理且符合实际情况的代码覆盖率。追求 100% 的覆盖率并不总是实际或高效。相反,应该确定代码中哪些部分需要更彻底的测试,并确保关键代码路径得到充分测试。
利用 Visual Studio 和 .NET CLI 进行代码覆盖率分析
Visual Studio 和 .NET CLI 支持分析代码覆盖率,使您能够轻松地衡量测试的覆盖率:
- Visual Studio:Visual Studio 企业版内置代码覆盖率分析功能,提供易于使用的界面和结果可视化。您也可以在其他 Visual Studio 版本中使用 ReSharper 或 dotCover 等扩展进行代码覆盖率分析。
- .NET CLI:您可以通过安装代码覆盖率工具(例如 Cobertura、OpenCover 等)并使用 ReportGenerator 等外部工具分析结果,来使用该
dotnet test命令生成各种格式(Cobertura、OpenCover 等)的代码覆盖率报告。coverlet
超越代码覆盖率:测试质量指标和启发式方法
虽然代码覆盖率很有用,但衡量测试质量的其他方面也至关重要,包括:
- 测试有效性:评估测试用例检测代码中实际问题的能力。高质量的测试用例能够更好地发现缺陷,并最大限度地减少误报或漏报。
- 易于维护性:评估您的测试用例是否易于理解、修改和扩展。高质量的测试用例有助于提高可维护性,从而降低长期开发成本。
- 测试执行时间:监控测试运行所需的时间。测试运行缓慢会阻碍开发和反馈周期,降低整体生产力。
在评估测试套件的质量并进行改进时,请考虑以下因素。
处理边缘情况和极端情况:全面测试策略
最后,在设计测试时,务必考虑边界情况和极端情况。这些情况可能导致意外行为或缺陷,因此必须进行全面测试以确保系统稳定性。处理边界情况的一些策略包括:
- 边界值分析:测试输入或条件在可接受范围边缘的情况。这些情况通常会揭示边缘条件或极限检查方面的问题。
- 等价类划分:将输入数据划分为预期行为相似的组或类,并使用代表性值测试每个组。这有助于发现特定输入组的问题。
- 错误情况:测试代码可能出现的各种故障或错误情况,尤其是在异常处理和错误报告方面。确保代码能够优雅地处理这些情况。
- 性能限制:识别性能或资源限制并进行相应的测试。了解系统在高负载、内存受限或其他受限条件下的运行情况。
- 用户行为:最后,请考虑用户如何与您的应用程序交互,并测试任何异常的使用模式或输入。这样做可以帮助您发现由意外的用户输入或操作引起的问题。
总之,C# 和 .NET 为单元测试提供了丰富的生态系统,拥有 xUnit 和 Moq 等各种强大的框架和库。遵循本文讨论的原则、模式和最佳实践,您可以开发全面且易于维护的单元测试,从而确保 C# 应用程序的质量、稳定性和可靠性。持续学习、实践和提升您的单元测试技能,成为一名更高效的开发人员。
文章来源:https://dev.to/bytehide/unit-testing-with-c-and-net-full-guide-5c7p