You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

多ICollectionFixture下WebApplicationFactory数据库初始化重复PK问题

解决xUnit多测试集并发初始化测试数据的主键冲突问题

问题根源

你遇到的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:全局初始化+事务回滚

只初始化一次基础测试数据,每个测试方法通过事务包裹,测试结束后回滚操作,避免污染基础数据:

  1. 先在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();
}
  1. 在测试类中实现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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.13 04:13:16