ASP.NET Core 中的集成测试:DBContext 初始化陷阱





5.00/5 (5投票s)
本文解释了在按照官方教程实现 ASP.NET Core 集成测试后不久遇到的问题,以及解决方案。
引言
最近在设置一个新的 .NET Core Web API 项目时,我决定使用 XUnit 编写一些集成测试,遵循 本教程。
在编写第一个测试后不久,我在并行运行测试时遇到了问题。以下文字包含对问题的描述和建议的解决方案。 示例代码可在 https://github.com/majda-osmic/Analysis.XUnit.Parallel 找到。
安装
我创建了一个 CustomWebApplicationFactory
(与教程中的方式相同)
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);
}
// Add ApplicationDbContext using an in-memory database for testing.
services.AddDbContext<CustomerDbContext>
((_, context) => context.UseInMemoryDatabase("InMemoryDbForTesting"));
// Build the service provider.
var serviceProvider = services.BuildServiceProvider();
// Create a scope to obtain a reference to the database
// context (ApplicationDbContext).
using var scope = serviceProvider.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<CustomerDbContext>();
var logger = scope.ServiceProvider.GetRequiredService
<ILogger<CustomWebApplicationFactory<TStartup>>>();
// Ensure the database is created.
db.Database.EnsureCreated();
try
{
// Seed the database with test data.
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 包含一个非常简单的 Controller
,如下所示
[Route("[controller]")]
[ApiController]
public class CustomersController : ControllerBase
{
private readonly CustomerDbContext _context;
public CustomersController(CustomerDbContext context)
{
_context = context;
}
// GET: Customers
[HttpGet]
public async Task<ActionResult<IEnumerable<Customer>>> GetCustomers()
{
return await _context.Customers.ToListAsync();
}
//.....
}
现在,在设置好虚拟数据后,我编写了如下所示的测试方法
[Theory]
[InlineData("/Customers")]
public async Task Get_ShouldReturnCorrectData(string url)
{
// Arrange
var client = _factory.CreateClient();
// Act
var response = await client.GetAsync(url).ConfigureAwait(false);
// Assert
response.EnsureSuccessStatusCode(); // Status Code 200-299
var customers = await response.DeserializeContent
<List<Customer>>().ConfigureAwait(false);
Assert.Equal(3, customers.Count);
}
内存中的测试数据库已使用 3 个虚拟客户对象初始化,因此我们期望获得 3 个 customer
对象作为对 GET Customers
请求的响应,并且这正是测试运行时发生的情况;一切都按预期工作。
问题
在进行一些测试,创建另一个测试类并编写更多测试后,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)
{
// Arrange
var client = _factory.CreateClient();
// Act
var response = await client.GetAsync(url).ConfigureAwait(false);
// Assert
response.EnsureSuccessStatusCode(); // Status Code 200-299
var customers = await response.DeserializeContent
<List<Customer>>().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;
}
}
根据用例,可以确保每个 CustomWebApplicationFactory
实例都使用单独的数据库,或者简单地只有一个 CustomWebApplicationFactory
实例,在这种情况下,可以通过类嵌套来实现测试的逻辑分离。
历史
- 2019 年 11 月 3 日:初始版本