XUnit集成测试问题解析

在进行软件开发的过程中,集成测试是一个不可或缺的环节。它能够帮助验证各个组件之间的交互是否符合预期。XUnit 是一个流行的.NET测试框架,它支持并行测试,可以显著提高测试执行的效率。然而,在享受并行测试带来的便利的同时,也可能会遇到一些挑战。本文将介绍一个在并行测试中遇到的具体问题,以及如何解决它。

在编写第一个测试用例后不久,在运行并行测试时遇到了一个问题。这个问题涉及到测试数据库的初始化。为了解决这个问题,创建了一个自定义的 WebApplicationFactory,类似于教程中的做法:

public class CustomWebApplicationFactory<TStartup> : WebApplicationFactory<TStartup> where TStartup : class { protected override void ConfigureWebHost(IWebHostBuilder builder) { builder.ConfigureServices(services => { var descriptor = services.SingleOrDefault(d => d.ServiceType == typeof(DbContextOptions<CustomerDbContext>)); if (descriptor != null) { services.Remove(descriptor); } services.AddDbContext<CustomerDbContext>((_, context) => context.UseInMemoryDatabase("InMemoryDbForTesting")); var serviceProvider = services.BuildServiceProvider(); using (var scope = serviceProvider.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService<CustomerDbContext>(); var logger = scope.ServiceProvider.GetRequiredService<ILogger<CustomWebApplicationFactory<TStartup>>>(); db.Database.EnsureCreated(); try { db.InitializeTestDatabase(); } catch (Exception ex) { logger.LogError(ex, "An error occurred seeding the database with test messages. Error: " + ex.Message); } }); }); } }

InitializeTestDatabase 扩展方法向数据库添加了一些虚拟的 Customer 数据:

public static CustomerDbContext InitializeTestDatabase(this CustomerDbContext context) { if (!context.Customers.Any()) { context.Customers.Add(new Customer { FirstName = "John", LastName = "Doe" }); context.Customers.Add(new Customer { FirstName = "Jane", LastName = "Doe" }); context.Customers.Add(new Customer { FirstName = "Max", LastName = "Mustermann" }); context.SaveChanges(); } return context; }

测试的API包含一个非常简单的控制器:

[Route("[controller]")] [ApiController] public class CustomersController : ControllerBase { private readonly CustomerDbContext _context; public CustomersController(CustomerDbContext context) { _context = context; } [HttpGet] public async Task

现在,有了这些虚拟数据,编写了一个测试方法:

[Theory] [InlineData("/Customers")] public async Task Get_ShouldReturnCorrectData(string url) { var client = _factory.CreateClient(); var response = await client.GetAsync(url).ConfigureAwait(false); response.EnsureSuccessStatusCode(); var customers = await response.DeserializeContent().ConfigureAwait(false); Assert.Equal(3, customers.Count); }

内存测试数据库初始化了3个虚拟客户对象,因此期望在发送GET Customers请求时得到3个客户对象作为响应,当单独运行测试时,一切如预期那样工作。

遇到的问题

在编写更多的测试用例并创建另一个测试类后,Get_ShouldReturnCorrectData突然开始失败,并显示以下消息:

开始调试测试,但这次它工作了。事实证明,只有在与其他所有测试一起执行时,测试才会失败。在深入代码,试图弄清楚可能发生了什么时,意识到 Customer 模型类看起来像这样:

public class Customer { [Key] public int ID { get; set; } public string FirstName { get; set; } = string.Empty; public string LastName { get; set; } = string.Empty; }

InitializeTestDatabase 方法中,忘记了设置添加的对象的 ID 属性。修复了这个问题,测试变绿了:

public static CustomerDbContext InitializeTestDatabase(this CustomerDbContext context) { if (!context.Customers.Any()) { context.Customers.Add(new Customer { ID = 1, FirstName = "John", LastName = "Doe" }); context.Customers.Add(new Customer { ID = 2, FirstName = "Jane", LastName = "Doe" }); context.Customers.Add(new Customer { ID = 3, FirstName = "Max", LastName = "Mustermann" }); context.SaveChanges(); } return context; }

但是,为什么呢?在Visual Studio中启用所有公共运行时异常并调试所有测试后,调用 InitializeTestDatabase 方法时得到了以下异常:

显然,InitializeTestDatabase 方法被调用了不止一次,测试在设置了ID属性后变绿的原因是 ArgumentException 实际上被 try/catch 块包围的方法调用所吞噬。

那么这些多次调用来自哪里呢?为什么 Customer 对象被添加到 DBSet 中,尽管 InitializeTestDatabase 方法只有在集合中没有项目时才这样做:

if (!context.Customers.Any()) { // ... }

实际包含测试方法的测试固定装置看起来像这样:

public class CustomerTests : IClassFixture<CustomWebApplicationFactory<Startup>> { private readonly CustomWebApplicationFactory<Startup> _factory; public CustomerTests(CustomWebApplicationFactory<Startup> factory) { _factory = factory; } [Theory] [InlineData("/Customers")] public async Task Get_ShouldReturnCorrectData(string url) { var client = _factory.CreateClient(); var response = await client.GetAsync(url).ConfigureAwait(false); response.EnsureSuccessStatusCode(); var customers = await response.DeserializeContent().ConfigureAwait(false); Assert.Equal(3, customers.Count); } }

在添加了第二个测试类,它也继承自 IClassFixture<CustomWebApplicationFactory<Startup>> 后,CustomWebApplicationFactory 中的 ConfigureWebHost 方法被调用了两次:每个测试固定装置实例一次。由于所有测试都在并行设置和运行,所以在检查 Customers DbSet 中是否有任何项目时发生了经典的同步问题。本能地,由于一切都被设置了两次,人们会认为传递给 InitializeTestDatabase 方法的 DbContext 对象应该是独立的,但它们两者都访问了同一个内存数据库,这是由于这行代码:

services.AddDbContext<CustomerDbContext>((_, context) => context.UseInMemoryDatabase("InMemoryDbForTesting"));

解决方案

这个问题最明显的解决方案,也是在项目中选择的解决方案,就是简单地锁定 InitializeTestDatabase 方法。

private static object _customerContextLock = new object(); public static CustomerDbContext InitializeTestDatabase(this CustomerDbContext context) { lock (_customerContextLock) { if (!context.Customers.Any()) { context.Customers.Add(new Customer { ID = 1, FirstName = "John", LastName = "Doe" }); context.Customers.Add(new Customer { ID = 2, FirstName = "Jane", LastName = "Doe" }); context.Customers.Add(new Customer { ID = 3, FirstName = "Max", LastName = "Mustermann" }); context.SaveChanges(); } return context; } }
沪ICP备2024098111号-1
上海秋旦网络科技中心:上海市奉贤区金大公路8218号1幢 联系电话:17898875485