在 Xunit e2e 测试中正确播种 InMemoryDatabase

Correctly seeding the InMemoryDatabase on Xunit e2e tests

我有一个 .NET 5 解决方案,其中包含一个 API 项目和两个基于 XUnit 的独立测试项目(一个是裸单元测试,另一个是 integration/e2e 测试)。

作为端到端测试的一部分,我在数据库中植入了一些测试数据。

到昨天,所有测试都成功了。今天,我在我的套件中添加了更多测试,测试开始表现得不一致:

请注意今天 Azure DevOps 在欧洲地区遇到了一个事件

错误是不同的。在一种情况下,调用数据库 COUNT 的 REST 方法应该是 return 6 returns 0(!),而在另一种情况下,我有异常

System.ArgumentException : An item with the same key has already been added. Key: 1
at System.Collections.Generic.Dictionary`2.TryInsert(TKey key, TValue value, InsertionBehavior behavior)
at System.Collections.Generic.Dictionary`2.Add(TKey key, TValue value)
at Microsoft.EntityFrameworkCore.InMemory.Storage.Internal.InMemoryTable`1.Create(IUpdateEntry entry)
at Microsoft.EntityFrameworkCore.InMemory.Storage.Internal.InMemoryStore.ExecuteTransaction(IList`1 entries, IDiagnosticsLogger`1 updateLogger)
at Microsoft.EntityFrameworkCore.InMemory.Storage.Internal.InMemoryDatabase.SaveChanges(IList`1 entries)
at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.StateManager.SaveChanges(IList`1 entriesToSave)
at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.StateManager.SaveChanges(DbContext _, Boolean acceptAllChangesOnSuccess)
at Microsoft.EntityFrameworkCore.Storage.NonRetryingExecutionStrategy.Execute[TState,TResult](TState state, Func`3 operation, Func`3 verifySucceeded)
at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.StateManager.SaveChanges(Boolean acceptAllChangesOnSuccess)
at Microsoft.EntityFrameworkCore.DbContext.SaveChanges(Boolean acceptAllChangesOnSuccess)
at Microsoft.EntityFrameworkCore.DbContext.SaveChanges()
at Web.TestUtilities.Seeder.Seed(SendGridManagerContext dbContext) in D:\a\s\TestUtilities\Seeder.cs:line 27
at Web.WebApplicationFactory.InitDatabase() in D:\a\s\WebApplicationFactory.cs:line 164
at TestFixtureBase..ctor(WebApplicationFactory testFactory, ITestOutputHelper outputHelper) in D:\a\s\TestFixtureBase.cs:line 27
at Web.Tests.ControllerTests..ctor(WebApplicationFactory testFactory, ITestOutputHelper outputHelper) in D:\a\s\Tests\ControllerTests.cs:line 19

(我稍微编辑了堆栈跟踪)

从这两个错误的关系来看,我怀疑是我做种数据库的方法不对。

决战到此。

我创建了一个 WebApplicationFactory class

public class MyWebApplicationFactory : WebApplicationFactory<Startup>
{
    public ITestOutputHelper Output { protected get; set; }

    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        base.ConfigureWebHost(builder);
        builder.UseUrls("https://localhost:5001")
            .ConfigureLogging(logging => logging
                .ClearProviders()
                .AddXUnit(Output)
                .AddSimpleConsole())
            .ConfigureTestServices(services =>
            {
                services.AddLogging(log =>
                    log.AddXUnit(Output ?? throw new Exception(
                        $"{nameof(Output)} stream must be set prior to invoking configuration. It should be done in the test base fixture")));

                services.Remove(services.SingleOrDefault(service =>
                    service.ServiceType == typeof(DbContextOptions<MyDbContext>)));

                services.AddDbContext<MyDbContext>(options =>
                    options.UseInMemoryDatabase("sg_inmemory"));

                services.Configure<JwtBearerOptions>(.......


                services.AddSingleton(.......
            })
            ;
    }


    protected override IHostBuilder CreateHostBuilder()
    {
        return base.CreateHostBuilder()
            .ConfigureLogging(log =>
                log.AddXUnit()
            );
    }

    public HttpClient CreateHttpClientAuthenticatedUsingMicrosoftOidc()
    {

    }

    public async Task<HttpClient> CreateHttpClientAuthenticatedUsingPrivateOidcAsync(
        CancellationToken cancellationToken = default)
    {

    }

    public void InitDatabase()
    {
        using var scope = Services.CreateScope();
        using var dbContext = scope.ServiceProvider.GetRequiredService<MyDbContext>();
        dbContext.Database.EnsureCreated();
        dbContext.Seed(); //Extension method defined elsewhere
    }

    public void DestroyDatabase()
    {
        using var scope = Services.CreateScope();
        using var dbContext = scope.ServiceProvider.GetRequiredService<MyDbContext>();
        dbContext.Database.EnsureDeleted();
    }
}

为了真正种子数据库我创建了自己的扩展方法

    public static void Seed(this MyDbContext dbContext)
    {
        using var tx = new TransactionScope();

        dbContext.MyEntity.AddRange(GetSeedData());
        dbContext.SaveChanges();
        tx.Complete();
    }

为了在测试中调用 DB init/destroy 循环,我利用构造函数和处理器

public class ControllerTests : TestFixtureBase
{
    public ControllerTests(MyWebApplicationFactory testFactory, ITestOutputHelper outputHelper)
        : base(testFactory, outputHelper)
    {
    }
    // Let's talk about test code later
}

这个class继承自同一个夹具

public abstract class TestFixtureBase : IClassFixture<MyWebApplicationFactory>, IDisposable
{
    private CancellationTokenSource _cancellationTokenSource => Debugger.IsAttached
        ? new CancellationTokenSource()
        : new CancellationTokenSource(TimeSpan.FromMinutes(2));

    protected MtWebApplicationFactory TestFactory { get; }
    protected HttpClient PublicHttpClient => TestFactory.CreateClient();
    protected CancellationToken CancellationToken => _cancellationTokenSource.Token;

    protected TestFixtureBase(MyWebApplicationFactory testFactory,
        ITestOutputHelper outputHelper)
    {
        TestFactory = testFactory;
        TestFactory.Output = outputHelper;
        TestFactory.InitDatabase();
    }


    ~TestFixtureBase() => Dispose(false);


    public void Dispose()
    {
        Dispose(disposing: true);
        GC.SuppressFinalize(this);
    }


    protected virtual void Dispose(bool disposing)
    {
        if (disposing)
        {
            TestFactory.DestroyDatabase();
            PublicHttpClient.Dispose();
            _cancellationTokenSource.Dispose();
        }
    }
}

新添加的测试类似于现有测试。 请注意失败的测试是在我推送之前通过的旧测试,我只添加功能

    [Fact]
    public async Task TestStatistics_ReturnsNull() // tests that REST controller returns empty JSON array []
    {
        var client = TestFactory.CreateHttpClientAuthenticatedUsingMicrosoftOidc();
        var message = await client.GetAsync(
            "/api/v1/secure/Controller/statistics/?Environment.IsNull=true",
            CancellationToken); //shall return empty

        message.EnsureSuccessStatusCode();

        var result =
            await message.Content.ReadFromJsonAsync<List<StatisticsDTO>>(
                cancellationToken: CancellationToken);

        Assert.NotNull(result);
        Assert.Empty(result);
    }

    [Fact]
    public async Task TestCount_NoFilter()
    {
        var client = TestFactory.CreateHttpClientAuthenticatedUsingMicrosoftOidc();
        var message = await client.GetAsync("/api/v1/secure/Controller/count",
            CancellationToken);

        message.EnsureSuccessStatusCode();

        var result = await message.Content.ReadFromJsonAsync<int>();

        int expected = Seeder.GetSeedData().Count(); //The seeder generates 6 mock records today

        Assert.Equal(expected, result); //Worked till last push
    }

我的调查和问题

我怀疑由于异步性,可能会在某个时候测试 运行s 恰好在另一个处理测试已经清除数据库之后,并且对于重复键错误我很难理解它.

我有根据的猜测是我在为数据库做种时做错了。

目前我的要求是 REST 应用程序在其内存数据库中准备好一些模拟记录的情况下启动。虽然数据库在内存中并且是短暂的,但我尝试做有根据的练习来清理数据库,因为这段代码将作为示例的一部分与开发人员-学生分享,以便教给他们正确的模式。请允许我坚持清除内存数据库。

最后,管道代码没有什么特别的(我一开始就做了dotnet restore

- task: DotNetCoreCLI@2
  displayName: "Execute tests"
  inputs:
    command: 'test'
    projects: |
      *Tests/*.csproj
    arguments: '--no-restore'

确实,解决方案是在每次测试中使用始终不同 数据库标识符。

根据@Fabio 的评论,每次调用 lambda 表达式 services.AddDbContext<SendGridManagerContext>(options => options.UseInMemoryDatabase(???)); 时我都必须生成一个数据库名称,但这恰好经常被调用,因为对象有一个 prototype scope,这篇文章是关于 Spring for Java,但同样的原则适用。

事实上,在那种情况下,每次实例化 DbContext 时都会重新生成 guid。

解决方案?生成一个随机 ID,但在整个测试过程中将其固定。

正确的地方是测试工厂里面class

public class MyWebApplicationFactory : WebApplicationFactory<Startup>
{
    public ITestOutputHelper Output { protected get; set; }
    private readonly string dataContextName = $"myCtx_{Guid.NewGuid()}"; //Even plain Guid is ok, as soon as it's unique

    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        base.ConfigureWebHost(builder);
        builder.UseUrls("https://localhost:5001")
            .ConfigureLogging(...)
            .ConfigureTestServices(services =>
            {   
                services.AddDbContext<MyDbContext>(options =>
                    options.UseInMemoryDatabase(dataContextName));
            })
            ;
    }

}