并发编程-12.异步单元测试,并发和并行代码
单元测试异步代码
单元测试异步代码需要与编写良好的异步 C# 代码相同的方法。如果您需要复习如何使用异步方法,可以查看第 5 章。
在为异步方法编写单元测试时,您将使用await
关键字等待该方法完成。 这要求您的单元测试方法是异步的并返回任务。 就像其他 C# 代码一样,不允许创建 async void
方法。 我们来看一个非常简单的测试方法:
[Fact]
private async Task GetBookAsync_Returns_A_Book()
{
// Arrange
BookService bookService = new();
var bookId = 123;
// Act
var book = await bookService.GetBookAsync(bookId);
// Assert
Assert.NotNull(book);
Assert.Equal(bookId, book.Id);
}
这可能看起来像您为同步代码编写的大多数测试。 只有几个区别:
• 首先,测试方法是异步的并返回Task。
• 其次,对GetBookAsync
的调用使用await
关键字来等待结果。否则,此测试将遵循典型的Arrange-Act-Assert 模式并按照您通常的方式测试结果。
让我们创建一个简单的项目来在 Visual Studio 中尝试此操作并查看结果:
- 首先在 Visual Studio 中创建一个名为
AsyncUnitTesting
的新类库项目:
图 12.1 – 创建一个新的类库项目
- 接下来,我们将向
AsyncUnitTesting
解决方案添加一个测试项目。 右键单击“解决方案资源管理器”中的解决方案文件,然后单击“添加|” 新项目。 选择 xUnit Test Project 模板并将项目命名为AsyncUnitTesting.Tests
:
图 12.2 – 将 xUnit Test 项目添加到解决方案
-
在
AsyncUnitTesting
项目中,将 Class1.cs 文件重命名为BookOrderService.cs
。当 Visual Studio 询问是否要重命名 Class1 的所有用途时,选择“是”。 -
打开
BookOrderService
类并添加名为GetCustomerOrdersAsync
的异步方法:
public async Task<List<string>>
GetCustomerOrdersAsync(int customerId)
{
if (customerId < 1)
{
throw new ArgumentException("Customer ID must be greater than zero.", nameof (customerId));
}
var orders = new List<string>
{
customerId + "1",
customerId + "2",
customerId + "3",
customerId + "4",
customerId + "5",
customerId + "6"
};
// Simulate time to fetch orders
await Task.Delay(1500);
return orders;
}
此方法将 customerId
作为参数并返回包含订单号的 List<string>
。 如果提供的 customerId
小于 1,则抛出 ArgumentException
。 否则,将创建一个包含六个订单号的列表,并以 customerId
为前缀。 注入 1500 毫秒的 Task.Delay
后,订单将返回到调用方法。
- 接下来,右键单击
AsyncUnitTesting.Tests
项目,然后单击添加 | 项目参考。 在“引用管理器”对话框中,选中AsyncUnitTesting
项目的框,然后单击“确定”。 - 现在,将
UnitTest1
类重命名为BookOrderServiceTests
并在 Visual Studio 编辑器中打开该文件。 - 是时候开始添加测试了。 让我们从测试快乐路径开始。 添加名为
GetCustomerOrdersAsync_Returns_Orders_For_Valid_CustomerId
的测试方法:
[Fact]
public async Task GetCustomerOrdersAsync_Returns_Orders_For_Valid_CustomerId()
{
var service = new BookOrderService();
int customerId = 3;
var orders = await service.GetCustomerOrdersAsync(customerId);
Assert.NotNull(orders);
Assert.True(orders.Any());
Assert.StartsWith(customerId.ToString(),orders[0]);
}
使用 customerId 3 调用 GetCustomerOrdersAsync
后,我们的代码具有三个断言:
- 首先,我们检查订单列表是否不为空。
- 其次,我们检查列表中是否包含一些项目。
- 最后,我们检查第一个订单是否以 customerId 开头。
- 单击测试| 运行所有测试以确保该测试通过。
- 让我们使用新的
customerId
编写相同的测试,但不使用async
和wait
。 假设您有一些无法重构的遗留测试代码,并且您必须测试GetCustomerOrdersAsync
方法。 该代码如下所示:
[Fact]
public void GetCustomerOrdersAsync_Returns_Orders_For_Valid_CustomerId_Sync()
{
var service = new BookOrderService();
int customerId = 5;
List<string> orders = service.GetCustomer
OrdersAsync(customerId).GetAwaiter() .GetResult();
Assert.NotNull(orders);
Assert.True(orders.Any());
Assert.StartsWith(customerId.ToString(), orders[0]);
}
测试方法不是异步的并且返回 void
。 我们不是使用await 来允许GetCustomerOrdersAsync
运行完成,而是调用GetAwaiter().GetResult()
。 代码的设置和断言部分保持不变。
- 单击“测试”| 运行所有测试以确保我们的两个测试都是绿色的(通过)。
- 最后,我们将测试异常情况。 创建另一个测试,但将负的
customerId
传递给被测方法。 对GetCustomerOrdersAsync
的整个调用将包装在Assert.ThrowsAsync<ArgumentException>
调用中:
[Fact]
public async Task GetCustomerOrdersAsync_Throws_Exception_For_Invalid_CustomerId()
{
var service = new BookOrderService();
await Assert.ThrowsAsync<ArgumentException>(async() => await service.GetCustomerOrdersAsync (-2));
}
- 最后一次执行“运行所有测试”并确保它们全部通过:
图 12.3 – 在测试资源管理器中查看三个通过的测试
现在,我们已经通过了 GetCustomerOrdersAsync
方法的三个单元测试。 前两个本质上是测试相同的东西,但它们演示了两种不同的编写测试的方法。 在大多数情况下,您将使用异步方法。 最终测试提供了引发 ArgumentException
的代码的测试覆盖率。 如果您使用 Visual Studio Enterprise 版本或第三方工具(例如 dotCover),您可以使用他们的可视化工具来查看代码的哪些部分被单元测试覆盖,哪些部分没有。
单元测试并发代码
在本节中,我们将改编第 9 章中的示例,以添加单元测试覆盖率。 当您的代码使用 async 和await 时,添加可靠的测试覆盖率非常简单。 在示例的最后,我们将检查使用 SpinLock
结构等待执行断言的替代方法。
让我们为 ConcurrentOrderQueue
项目创建一个 xUnit.net 单元测试项目并添加几个测试:
-
首先复制第 9 章中的
ConcurrentOrderQueue
项目。 -
在 Visual Studio 中打开
ConcurrentOrderQueue
解决方案。 -
右键单击“解决方案资源管理器”中的解决方案文件,然后单击“添加”|“ 新项目。 添加一个名为
ConcurrentOrderQueue.Tests
的 xUnit 单元测试项目。 确保将新项目添加到ConcurrentOrderQueue
文件夹中。 -
如果您的新测试项目也显示为
ConcurrentOrderQueue
项目下的文件夹,请右键单击ConcurrentOrderQueue.Tests
文件夹并选择从项目中排除。 -
将新项目中的项目引用添加到
ConcurrentOrderQueue
项目中,并将 UnitTest1 类重命名为OrderServiceTests
。 -
为了控制使用哪些
CustomerId
值来生成订单列表,我们将为OrderService
类中的公共EnqueueOrders
方法创建一个新的重载:
public async Task EnqueueOrders(List<int> customerIds)
{
var tasks = new List<Task>();
foreach (int id in customerIds)
{
tasks.Add(EnqueueOrders(id));
}
await Task.WhenAll(tasks);
}
此方法采用 customerId
列表,并为每个列表调用私有 EnqueueOrders
方法,将每次调用中的 Task
添加到 List<Task>
中,以便在退出该方法之前等待。
7. 现在我们可以通过调用这个新的重载来优化 EnqueueOrders
的无参数版本:
public async Task EnqueueOrders()
{
await EnqueueOrders(new List<int> { 1, 2 });
}
8.在OrderServiceTests
类中创建一个新的单元测试方法来测试EnqueueOrders
:
[Fact]
public async Task EnqueueOrders_Creates_Orders_For_All_Customers()
{
var orderService = new OrderService();
var orderNumbers = new List<int> { 2, 5, 9 };
await orderService.EnqueueOrders(orderNumbers);
var orders = orderService.DequeueOrders();
Assert.NotNull(orders);
Assert.True(orders.Any());
Assert.Contains(orders, o => o.CustomerId == 2);
Assert.Contains(orders, o => o.CustomerId == 5);
Assert.Contains(orders, o => o.CustomerId == 9);
}
该测试将使用三个客户 ID 调用 EnqueueOrders
。 EnqueueOrders
和 DequeueOrders
完成后,我们断言订单集合不为 null
,包含一些订单,并且包含具有所有三个客户 ID 的订单。
9. 运行新测试并确保其通过。
这涵盖了使用 ConcurrentQueue
进行测试的系统的基础知识。 让我们考虑另一个场景,我们正在使用代码,但在测试中不能使用 async
和 wait
。 也许被测试的方法不是异步的。 我们可以使用的工具之一是 SpinWait
结构。 该结构体包含一些为我们的代码中的等待提供非锁定机制的方法。 我们将使用 SpinWait.WaitUntil()
等待所有订单都已排队。
以下步骤将演示当您无法显式等待方法完成时如何可靠地测试方法的结果:
- 首先向
OrderService
类添加一个新的公共变量,以公开其订单已排队的客户数量:
public int EnqueueCount = 0;
- 接下来,在私有
EnqueueOrders
方法末尾递增EnqueueCount
:
private async Task EnqueueOrders(int customerId)
{
for (int i = 1; i < 6; i++)
{
...
}
EnqueueCount++;
}
- 现在,创建一个要从我们的新测试中调用的
EnqueueOrdersSync
公共方法。 它将类似于公共EnqueueOrders
方法。 上一个示例和这个示例之间的区别在于它不是异步的,它将EnqueueCount
重置为0
,并且不等待任务完成:
public void EnqueueOrdersSync(List<int> customerIds)
{
EnqueueCount = 0;
var tasks = new List<Task>();
foreach (int id in customerIds)
{
tasks.Add(EnqueueOrders(id));
}
}
4.接下来,我们将创建一个新的同步测试方法来测试EnqueueOrdersSync
:
[Fact]
public void EnqueueOrders_Creates_Orders_For_All_Customers_SpinWait()
{
var orderService = new OrderService();
var orderNumbers = new List<int> { 2, 5, 9 };
orderService.EnqueueOrdersSync(orderNumbers);
SpinWait.SpinUntil(() => orderService.EnqueueCount == orderNumbers.Count);
var orders = orderService.DequeueOrders();
Assert.NotNull(orders);
Assert.True(orders.Any());
Assert.Contains(orders, o => o.CustomerId == 2);
Assert.Contains(orders, o => o.CustomerId == 5);
Assert.Contains(orders, o => o.CustomerId == 9);
}
前面的代码片段突出显示了这些差异。 SpinWait.SpinUntil
将在不锁定的情况下等待,直到 orderService.EnqueueCount
值与 orderNumbers.Count
匹配。 如果您想确保它不会永远旋转,可以使用重载来提供 TimeSpan
或以毫秒为单位的超时时间。
5. 再次运行测试并确保它们都通过。 我们现在有单元测试方法,用于测试可用于在 OrderService
类中对订单进行排队的两种方法。 在您自己的项目中,您可以添加更多场景来增加类的测试覆盖率。 您应该始终进行测试,例如代码如何处理无效输入。
在对多线程代码进行单元测试时,请务必记住,如果您不使用 async
和 wait 或其他同步方法,您的测试将不可靠。 进行不可靠的测试与根本没有测试一样糟糕。 请务必谨慎设计和开发单元测试。 最好尽可能使用 async/await 以获得最大的可靠性。
在下一节中,我们将为使用 Parallel.ForEach
和 Parallel.ForEachAsyn
c 方法的代码构建一些单元测试。
单元测试并行代码
为使用 Parallel.Invoke
、Parallel.For
、Parallel.ForEach
和 Parallel.ForEachAsync
的代码创建单元测试相对简单。 虽然它们可以在条件合适时并行运行进程,但它们相对于调用代码同步运行。 除非将 Parallel.ForEach
包装在 Task.Run
语句中,否则代码流将不会继续,直到循环的所有迭代完成。
测试使用并行循环的代码时需要考虑的一个警告是预期的异常类型。 如果在这些构造之一的主体内引发异常,则周围的代码必须捕获 AggregateException
。 此异常规则的例外是 Parallel.ForEachAsync
。 因为它是用 async/await
调用的,所以必须处理 Exception
而不是 AggregateException
。 让我们创建一个示例来说明这些场景:
- 在 Visual Studio 中创建一个名为
ParallelExample
的新类库项目。 - 重命名 Class1
TextService
并在该类中创建一个名为ProcessText
的方法:
public List<string> ProcessText(List<string> textValues)
{
List<string> result = new();
Parallel.ForEach(textValues, (txt) =>
{
if (string.IsNullOrEmpty(txt))
{
throw new Exception("Strings cannot be empty");
}
result.Add(string.Concat(txt, Environment.TickCount));
});
return result;
}
此方法接受字符串列表,并将 Environment.TickCount
附加到 Parallel.ForEach
循环内的每个值。 如果任何字符串为 null
或空,将抛出异常。
3. 接下来,创建 ProcessText
的异步版本并将其命名为 ProcessTextAsync
。异步版本使用 Parallel.ForEachAsync
执行相同的操作:
public async Task<List<string>> ProcessTextAsync(List<string> textValues)
{
List<string> result = new();
await Parallel.ForEachAsync(textValues, async(txt, _) =>
{
if (string.IsNullOrEmpty(txt))
{
throw new Exception("Strings cannot be empty");
}
result.Add(string.Concat(txt, Environment.TickCount));
await Task.Delay(100);
});
return result;
}
-
将新的 xUnit Test 项目添加到解决方案并将其命名为
ParallelExample.Tests
。 -
将
UnitTest1
类重命名为TextServiceTests
,并将 Project 引用添加到ParallelExample
项目。 -
接下来,我们将添加两个单元测试来测试
ProcessText
方法:
[Fact]
public void ProcessText_Returns_Expected_Strings()
{
var service = new TextService();
var fruits = new List<string> { "apple", "orange","banana", "peach", "cherry" };
var results = service.ProcessText(fruits);
Assert.Equal(fruits.Count, results.Count);
}
[Fact]
public void ProcessText_Throws_Exception_For_Empty_String()
{
var service = new TextService();
var fruits = new List<string> { "apple", "orange","banana", "peach", "" };
Assert.Throws<AggregateException>(() =>service.ProcessText(fruits));
}
第一个测试使用包含水果名称的五个字符串值的列表调用 ProcessText
。断言检查 results.Count
是否与fruits.Count
匹配。第二个测试进行相同的调用,但其中一个水果字符串值为空。 此测试将确保被测试方法中的 Parallel.ForEach
循环引发 AggregateException
。
7. 添加两个测试。 这两个测试将在 ProcessTextAsync
方法上运行相同的断言。 这里的区别是 Assert.ThrowsAsync
必须检查异常
而不是 AggregateExceptoin
因为我们使用的是 async/await
:
[Fact]
public async Task ProcessTextAsync_Returns_Expected_Strings()
{
var service = new TextService();
var fruits = new List<string> { "apple", "orange", "banana", "peach", "cherry" };
var results = await service.ProcessTextAsync(fruits);
Assert.Equal(fruits.Count, results.Count);
}
[Fact]
public async Task ProcessTextAsync_Throws_Exception_For_Empty_String()
{
var service = new TextService();
var fruits = new List<string> { "apple", "orange","banana", "peach", "" };
await Assert.ThrowsAsync<Exception>(async () =>await service.ProcessTextAsync(fruits));
}
- 使用“文本资源管理器”窗口中的“在视图中运行所有测试”按钮运行所有四个测试。 如果该窗口在 Visual Studio 中不可见,您可以从“查看”|“打开”。 测试资源管理器。 所有测试都应该通过:
图 12.4 – TextServiceTests 类中通过的四个测试
现在,您对 TextService 类中处理文本的每个方法都有两个测试。 他们正在成功测试有效和无效的输入数据。 自己花一些时间研究如何扩展测试覆盖范围。 还可以使用哪些其他类型的输入?
通过单元测试检查内存泄漏
内存泄漏绝不是多线程代码所独有的,但它们肯定会发生。 应用程序中执行的代码越多,某些对象泄漏的可能性就越大。 开发流行的 .NET 工具 ReSharper 和 Rider 的公司还开发了一个名为 dotMemory 的工具,用于分析内存泄漏。 虽然这些工具不是免费的,但 JetBrains 确实免费提供内存单元测试工具。 它被称为点存储单元。
在本节中,我们将创建一个 dotMemory
单元测试来检查我们是否泄漏了某个对象。您可以在命令行上使用 .NET 免费运行这些 dotMemory 单元测试,方法是在此处下载独立测试运行程序:https://www.jetbrains.com/dotmemory/unit/。
让我们创建一个示例,演示如何创建单元测试来确定被测试代码是否在内存中泄漏对象:
- 首先创建一个名为
MemoryExample
的新类库项目。 - 重命名Class1 WorkService 并添加另一个名为Worker 的类。 将以下代码添加到 Worker 类中。 此类中的
DoWork
方法将处理WorkService
中的TimerElapsed
事件:
public class Worker : IDisposable
{
public void Dispose()
{
// dispose objects here
}
public void DoWork(object? sender, System.Timers.ElapsedEventArgs e)
{
Parallel.For(0, 5, (x) =>
{
Thread.Sleep(100);
});
}
}
此类实现了 IDisposable,因此我们可以在其他地方将其与 using 语句一起使用。
3. 在WorkService
类中添加一个WorkWithTimer
方法:
public void WorkWithTimer()
{
using var worker = new Worker();
var timer = new System.Timers.Timer(1000);
timer.Elapsed += worker.DoWork;
timer.Start();
Thread.Sleep(5000);
}
这段代码存在一些问题,会阻止工作对象从内存中释放。 计时器对象不会停止或释放,并且 Elapsed 事件永远不会被取消挂钩。 当我们检查是否有泄漏时,我们应该发现一些泄漏。
4. 将一个新的 xUnit Test 项目添加到名为 MemoryExample.Tests
的解决方案中。
5. 添加对 MemoryExample
的项目引用,并添加对 JetBrains.dotMemoryUnit
的 NuGet 包引用:
图 12.5 – 引用 dotMemoryUnit NuGet 包
- 将MemoryExample.Tests中的UnitTest1类重命名为WorkServiceMemoryTests,并添加以下代码:
using JetBrains.dotMemoryUnit;
[assembly: SuppressXUnitOutputExceptionAttribute]
namespace MemoryExample.Tests
{
public class WorkServiceMemoryTests
{
[Fact]
public void WorkWithSquares_Releases_Memory_From_Bitmaps()
{
var service = new WorkService();
service.WorkWithTimer();
GC.Collect();
// Make sure there are no Worker
objects in memory
dotMemory.Check(m => Assert.Equal(0, m.GetObjects(o =>o.Type.Is<Worker>()).ObjectsCount));
}
}
}
前面的代码片段中突出显示了几行。 必须添加程序集属性,以在将 xUnit.net 与 dotMemory Unit 一起使用时抑制控制台运行程序中的错误。在调用测试下的方法 WorkWithTimer 后,我们将调用 GC.Collect 来尝试从内存中清除所有未使用的托管对象。 最后调用dotMemory.Check判断内存中是否还有剩余的Worker类型的对象。
7. 从下载并解压 dotMemory Unit 命令行工具的文件夹中,在 PowerShell 或 Windows 命令行中运行以下命令。 如果您使用 PowerShell,则需要 .\ 字符:
.\dotMemoryUnit.exe "c:\Program Files\dotnet\dotnet.exe"
– test "c:\dev\net6.0\MemoryExample.Tests.dll"
.NET 的路径在您的系统上应该相同。 您需要将 MemoryExample.Tests.dll 的路径替换为您自己的该 DLL 所在的输出路径。 测试应该失败,内存中剩余一个 Worker 对象,并且您的输出将如下所示:
图 12.6 – 检查失败的 dotMemoryUnit 测试运行
- 为了解决该问题,请对 WorkService.WorkWithTimer 方法进行以下更改:
public void WorkWithTimer()
{
using var worker = new Worker();
using var timer = new System.Timers.Timer(1000);
timer.Elapsed += worker.DoWork;
timer.Start();
Thread.Sleep(5000);
timer.Stop();
timer.Elapsed -= worker.DoWork;
}
为了确保释放工作对象实例,我们在 using 语句中初始化计时器,在计时器完成时停止计时器,并取消挂接计时器。Elapsed 事件处理程序。
- 现在,再次执行 dotMemory Unit 命令。 现在测试应该成功:
图 12.7 – dotMemoryUnit 测试成功运行
这个示例和内存单元测试部分就到此结束。 如果您想了解有关 dotMemory Unit 的更多信息,可以在这里找到其文档:https://www.jetbrains.com/help/dotmemory-unit/Introduction.html。 命令行工具还可以部署到持续集成 (CI) 构建服务器,以作为 CI 构建过程的一部分执行这些测试。