在进行软件开发的过程中,集成测试是一个不可或缺的环节。它能够帮助验证各个组件之间的交互是否符合预期。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;
}
}