在 Xunit e2e 测试中正确播种 InMemoryDatabase
Correctly seeding the InMemoryDatabase on Xunit e2e tests
我有一个 .NET 5 解决方案,其中包含一个 API 项目和两个基于 XUnit 的独立测试项目(一个是裸单元测试,另一个是 integration/e2e 测试)。
作为端到端测试的一部分,我在数据库中植入了一些测试数据。
到昨天,所有测试都成功了。今天,我在我的套件中添加了更多测试,测试开始表现得不一致:
- 运行 Visual Studio 本地的完整套件成功,所以我自信地推到 Azure DevOps 进行流水线处理
- 运行
dotnet test
局部成功
- 运行新加的测试(3)单独当然成功了
- 在 Azure DevOps 上,一些旧测试失败。有趣的是,两个连续的 运行s 导致相同的测试失败。我无法 运行 第三次执行,因为我耗尽了所有管道预算
请注意今天 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));
})
;
}
}
我有一个 .NET 5 解决方案,其中包含一个 API 项目和两个基于 XUnit 的独立测试项目(一个是裸单元测试,另一个是 integration/e2e 测试)。
作为端到端测试的一部分,我在数据库中植入了一些测试数据。
到昨天,所有测试都成功了。今天,我在我的套件中添加了更多测试,测试开始表现得不一致:
- 运行 Visual Studio 本地的完整套件成功,所以我自信地推到 Azure DevOps 进行流水线处理
- 运行
dotnet test
局部成功 - 运行新加的测试(3)单独当然成功了
- 在 Azure DevOps 上,一些旧测试失败。有趣的是,两个连续的 运行s 导致相同的测试失败。我无法 运行 第三次执行,因为我耗尽了所有管道预算
请注意今天 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));
})
;
}
}