多ICollectionFixture下WebApplicationFactory数据库初始化重复PK问题
问题根源
你遇到的PostgreSQL 23505异常,本质是客户端的存在检查与插入操作非原子导致的并发冲突:多个CustomWebApplicationFactory实例在并行运行时,同时执行PopulateTestData,都通过了"用户是否存在"的检查,随后同时插入同一主键的用户,触发唯一约束报错。单独运行时无并发竞争,所以不会出现问题。
原子性插入的直接解决方案
要避免客户端的非原子操作,最可靠的方式是把存在检查和插入合并为数据库层面的原子操作,利用PostgreSQL的原生语法实现:
使用INSERT ... ON CONFLICT DO NOTHING
直接通过EF Core执行原生SQL,将检查和插入合并为一步,完全由数据库保证原子性:
private async Task PopulateTestData(ApplicationDbContext context) { var testUserId = "固定测试用户ID"; var testUserName = "test@example.com"; // 用原生SQL执行原子插入,主键冲突时不做任何操作 await context.Database.ExecuteSqlRawAsync( @"INSERT INTO ""AspNetUsers"" (""Id"", ""UserName"", ""NormalizedUserName"", ""Email"", ""NormalizedEmail"") VALUES (@p0, @p1, @p2, @p3, @p4) ON CONFLICT (""Id"") DO NOTHING", testUserId, testUserName, testUserName.ToUpper(), testUserName, testUserName.ToUpper()); }
这种方式不需要修改测试结构,直接修复PopulateTestData的原子性问题,适合不想调整现有测试架构的场景。
更优的测试数据初始化设计方案
如果想从根源避免并发冲突,同时提升测试的隔离性和可维护性,推荐以下几种方案:
方案1:为每个测试集合分配独立数据库
让每个CustomWebApplicationFactory实例对应一个唯一的测试数据库,彻底隔离不同集合的测试数据:
public class CustomWebApplicationFactory<TProgram> : WebApplicationFactory<TProgram> where TProgram : class { private readonly string _uniqueDbName; public CustomWebApplicationFactory() { // 生成唯一数据库名 _uniqueDbName = $"TestDb_{Guid.NewGuid():N}"; } protected override void ConfigureWebHost(IWebHostBuilder builder) { builder.ConfigureServices(services => { // 替换原有的DbContext配置 var dbDescriptor = services.SingleOrDefault(d => d.ServiceType == typeof(DbContextOptions<ApplicationDbContext>)); if (dbDescriptor != null) services.Remove(dbDescriptor); services.AddDbContext<ApplicationDbContext>(options => { options.UseNpgsql($"Host=localhost;Database={_uniqueDbName};Username=postgres;Password=yourpassword"); }); }); } protected override async Task InitializeAsync() { using var scope = Services.CreateScope(); var context = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>(); // 创建当前实例专属的数据库 await context.Database.EnsureCreatedAsync(); // 填充测试数据(无需担心重复,数据库是独立的) await PopulateTestData(context); await base.InitializeAsync(); } protected override async Task DisposeAsync(bool disposing) { using var scope = Services.CreateScope(); var context = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>(); // 测试结束后删除专属数据库 await context.Database.EnsureDeletedAsync(); await base.DisposeAsync(disposing); } }
优点:完全隔离各集合的测试数据,不存在任何并发冲突;测试之间互不影响,故障排查更简单。
缺点:创建/删除数据库会带来一定性能开销,适合测试数量不多的场景。
方案2:全局初始化+事务回滚
只初始化一次基础测试数据,每个测试方法通过事务包裹,测试结束后回滚操作,避免污染基础数据:
- 先在
CustomWebApplicationFactory的InitializeAsync中一次性初始化所有基础测试数据(只执行一次,不管多少测试集合):
protected override async Task InitializeAsync() { using var scope = Services.CreateScope(); var context = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>(); await context.Database.EnsureCreatedAsync(); // 只初始化一次基础数据,利用原子插入避免重复 await PopulateTestData(context); await base.InitializeAsync(); }
- 在测试类中实现
IAsyncLifetime,为每个测试启动事务并回滚:
public class UserTests : IClassFixture<CustomWebApplicationFactory<Program>>, IAsyncLifetime { private readonly ApplicationDbContext _context; private IDbContextTransaction _transaction; public UserTests(CustomWebApplicationFactory<Program> factory) { var scope = factory.Services.CreateScope(); _context = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>(); } public async Task InitializeAsync() { // 测试开始前启动事务 _transaction = await _context.Database.BeginTransactionAsync(); } public async Task DisposeAsync() { // 测试结束后回滚事务,恢复基础数据状态 await _transaction.RollbackAsync(); await _transaction.DisposeAsync(); await _context.DisposeAsync(); } // 测试方法示例 [Fact] public async Task GetUser_ReturnsCorrectUser() { // 测试操作会在事务内执行,不会修改基础数据 var user = await _context.Users.FindAsync("固定测试用户ID"); Assert.NotNull(user); } }
优点:只初始化一次数据,测试执行效率高;事务回滚保证测试隔离性。
缺点:不支持会修改数据库结构(DDL)的测试;需要确保所有测试操作都在事务范围内。
方案3:使用单例Factory实例
如果所有测试集合可以共用同一个测试数据库,可将CustomWebApplicationFactory改为单例,确保InitializeAsync只执行一次:
public class CustomWebApplicationFactory<TProgram> : WebApplicationFactory<TProgram>, IDisposable where TProgram : class { private static readonly Lazy<CustomWebApplicationFactory<TProgram>> _instance = new Lazy<CustomWebApplicationFactory<TProgram>>(() => new CustomWebApplicationFactory<TProgram>()); public static CustomWebApplicationFactory<TProgram> Instance => _instance.Value; private CustomWebApplicationFactory() { } protected override async Task InitializeAsync() { // 仅在首次实例化时执行初始化 using var scope = Services.CreateScope(); var context = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>(); await context.Database.EnsureCreatedAsync(); await PopulateTestData(context); await base.InitializeAsync(); } // 重写Dispose,避免单例被提前释放 protected override void Dispose(bool disposing) { // 空实现,由应用域结束时自动清理 } }
然后测试集合使用这个单例Factory:
[CollectionDefinition("SharedTestCollection")] public class SharedTestCollection : ICollectionFixture<CustomWebApplicationFactory<Program>> { // 集合定义,无需实现任何成员 }
优点:完全避免并发初始化的问题,测试效率最高。
缺点:所有测试共用同一数据库,测试之间可能互相污染,需要在每个测试后清理数据(比如用事务回滚)。
内容的提问来源于stack exchange,提问作者khteh

