C
C#2w ago
Canyon

How can I improve my TestContainers with EF tests?

Hi, all: I've been trying to figure out my favorite way to implement test containers with Entity Framework contexts. I was running into an issue where my tests would persist their database changes between each testcase. To remedy this, I created a transactional test scope that rollsback the changes after each testcase. For this implementation, I have three files: 1) TestFactory.cs: Implements an IAsyncLifetime which my test test classes inherit from, e.x. public class GuildSettingsRepositoryTests(TestFactory factory) : IClassFixture<TestFactory>. It contains the PostgreSqlContainer, an IHost, and offers a CreateTransactionalScopeAsync method my testcases call. 2) TransactionalTestScope.cs: Implements an IAsyncLifetime which rolls back transactions at the end of the scope. Exposes GetRequiredService<T> which testcases call to retreive the testable service. 3) GuildSettingsRepositoryTests.cs: Test class with the previously mentioned definition. Here is a sample of a testcase implementation:
[Fact]
public async Task GetByGuildIdAsync_WithNonExistentGuildId_ShouldReturnNull()
{
await using var scope = await _factory.CreateTransactionalScopeAsync();
var guildStorage = scope.GetRequiredService<IGuildSettingsStorage>();
const long nonExistentGuildId = 999999999999999999L;

var result = await guildStorage.GetByGuildIdAsync(nonExistentGuildId);

Assert.Null(result);
}
[Fact]
public async Task GetByGuildIdAsync_WithNonExistentGuildId_ShouldReturnNull()
{
await using var scope = await _factory.CreateTransactionalScopeAsync();
var guildStorage = scope.GetRequiredService<IGuildSettingsStorage>();
const long nonExistentGuildId = 999999999999999999L;

var result = await guildStorage.GetByGuildIdAsync(nonExistentGuildId);

Assert.Null(result);
}
Issue This works, but calling CreateTransactionalScopeAsync and GetRequiredService each testcase seems redundant and, frankly, incorrect. How can I simplify my testcase boilerplate code or prevent my testcase context updates from persisting between tests?
65 Replies
Canyon
CanyonOP2w ago
TestFactory.cs
public sealed class TestFactory : IAsyncLifetime
{
private readonly PostgreSqlContainer _container = new PostgreSqlBuilder()
.WithImage("postgres:17")
.WithCleanUp(true)
.Build();

public IHost Host { get; private set; } = default!;

public IServiceProvider Services => Host.Services;

public async ValueTask InitializeAsync()
{
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Test");

await _container.StartAsync();

Host = WordBotHost.Build(
args: [],
configureConfig: cfg =>
{
cfg.AddInMemoryCollection(new Dictionary<string, string?>
{
["ConnectionStrings:DefaultConnection"] = _container.GetConnectionString()
});
});

await Services.EnsureDatabaseCreatedAsync();
}

public async ValueTask DisposeAsync()
{
if (Host is IAsyncDisposable asyncDisp)
await asyncDisp.DisposeAsync();
await _container.DisposeAsync();
}

public async Task<TransactionalTestScope> CreateTransactionalScopeAsync(bool autoRollback = true)
{
var scope = Services.CreateScope();
var ctx = scope.ServiceProvider.GetRequiredService<WordBotDbContext>();
var tx = await ctx.Database.BeginTransactionAsync();
return new TransactionalTestScope(scope, tx, autoRollback);
}
}
public sealed class TestFactory : IAsyncLifetime
{
private readonly PostgreSqlContainer _container = new PostgreSqlBuilder()
.WithImage("postgres:17")
.WithCleanUp(true)
.Build();

public IHost Host { get; private set; } = default!;

public IServiceProvider Services => Host.Services;

public async ValueTask InitializeAsync()
{
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Test");

await _container.StartAsync();

Host = WordBotHost.Build(
args: [],
configureConfig: cfg =>
{
cfg.AddInMemoryCollection(new Dictionary<string, string?>
{
["ConnectionStrings:DefaultConnection"] = _container.GetConnectionString()
});
});

await Services.EnsureDatabaseCreatedAsync();
}

public async ValueTask DisposeAsync()
{
if (Host is IAsyncDisposable asyncDisp)
await asyncDisp.DisposeAsync();
await _container.DisposeAsync();
}

public async Task<TransactionalTestScope> CreateTransactionalScopeAsync(bool autoRollback = true)
{
var scope = Services.CreateScope();
var ctx = scope.ServiceProvider.GetRequiredService<WordBotDbContext>();
var tx = await ctx.Database.BeginTransactionAsync();
return new TransactionalTestScope(scope, tx, autoRollback);
}
}
TransactionalTestScope.cs
public sealed class TransactionalTestScope(IServiceScope scope, IDbContextTransaction transaction, bool autoRollback) : IAsyncDisposable
{
private readonly IServiceScope _scope = scope;
private readonly IDbContextTransaction _transaction = transaction;
private readonly bool _autoRollback = autoRollback;
private bool _completed;

public IServiceProvider Services => _scope.ServiceProvider;

public T GetRequiredService<T>() where T : notnull => Services.GetRequiredService<T>();

public async Task CommitAsync()
{
await _transaction.CommitAsync();
_completed = true;
}

public async ValueTask DisposeAsync()
{
if (!_completed && _autoRollback)
await _transaction.RollbackAsync();
await _transaction.DisposeAsync();
_scope.Dispose();
}
}
public sealed class TransactionalTestScope(IServiceScope scope, IDbContextTransaction transaction, bool autoRollback) : IAsyncDisposable
{
private readonly IServiceScope _scope = scope;
private readonly IDbContextTransaction _transaction = transaction;
private readonly bool _autoRollback = autoRollback;
private bool _completed;

public IServiceProvider Services => _scope.ServiceProvider;

public T GetRequiredService<T>() where T : notnull => Services.GetRequiredService<T>();

public async Task CommitAsync()
{
await _transaction.CommitAsync();
_completed = true;
}

public async ValueTask DisposeAsync()
{
if (!_completed && _autoRollback)
await _transaction.RollbackAsync();
await _transaction.DisposeAsync();
_scope.Dispose();
}
}
Not sure if the entire GuildSettingsRepositoryTests.cs is needed so I'll cut it for brevity
mg
mg2w ago
personally, i use Respawn and call ResetAsync() in every test valid part of the arrange stage of arrange, act, assert imo
Unknown User
Unknown User2w ago
Message Not Public
Sign In & Join Server To View
Canyon
CanyonOP2w ago
I was under the impression I had to create my own IAsyncLifetime for setup and teardown of the MSSQL container. I use the transactions to rollback changes made to the database.
Unknown User
Unknown User2w ago
Message Not Public
Sign In & Join Server To View
Canyon
CanyonOP2w ago
Ideally my fixture/container persists through the tests but my database has one instance per test. I don't understand how to get that to work though.
Unknown User
Unknown User2w ago
Message Not Public
Sign In & Join Server To View
Canyon
CanyonOP2w ago
I think I'm not explaining correctly or I have a wrong understanding somewhere. For definitions: TestFactory - my WebApplicationFactory equivalent. I'm under the impression that this gets its own container. PostgreSqlContainer - my database container. I'm under the impression this gets its own container. Ideally this persists through tests because of the overhead ServiceProvider.GetRequiredService<WordBotDbContext>(); - my database context. Ideally this is cleared out every test. I can try something like
public class GuildSettingsRepositoryTests(WebApplicationFactory<Program> factory) : IClassFixture<WebApplicationFactory<Program>>
{
private readonly IGuildSettingsStorage guildStorage = factory.Services.GetRequiredService<IGuildSettingsStorage>();
...
public class GuildSettingsRepositoryTests(WebApplicationFactory<Program> factory) : IClassFixture<WebApplicationFactory<Program>>
{
private readonly IGuildSettingsStorage guildStorage = factory.Services.GetRequiredService<IGuildSettingsStorage>();
...
But I'm not sure how to get the PostgreSqlContainer connection string into the context At the end of the day I'm just trying to test my app with TestContainers and Entity Framework, so I'm open to any kind of changes
Unknown User
Unknown User2w ago
Message Not Public
Sign In & Join Server To View
dan in a can
dan in a can2w ago
I like using Respawn with Testcontainers, it's basically perfect for this exact use case. I have an article about how to set it up here, I've done some of my own (very basic) performance testing and even completely clearing the db after every single test I never had issues https://daninacan.com/resetting-your-test-database-in-c-with-respawn/
Unknown User
Unknown User2w ago
Message Not Public
Sign In & Join Server To View
dan in a can
dan in a can2w ago
Trying to remember but I think my perf test was just inserting a single record in my test then telling respawn to clear the db, and I'd loop it 100/1k/10k times. I think even on 10k it only took around 60 secs
Unknown User
Unknown User2w ago
Message Not Public
Sign In & Join Server To View
dan in a can
dan in a can2w ago
Ideally your tests can all be 100% isolated from one another
Unknown User
Unknown User2w ago
Message Not Public
Sign In & Join Server To View
dan in a can
dan in a can2w ago
maybe I'm misunderstanding the question
Unknown User
Unknown User2w ago
Message Not Public
Sign In & Join Server To View
dan in a can
dan in a can2w ago
right
Unknown User
Unknown User2w ago
Message Not Public
Sign In & Join Server To View
dan in a can
dan in a can2w ago
but if respawn clears it out fast enough that you can just 100% wipe clean and start over between each tests isnt that also good enough?
Unknown User
Unknown User2w ago
Message Not Public
Sign In & Join Server To View
dan in a can
dan in a can2w ago
I just had the one instance in my perf test though
Unknown User
Unknown User2w ago
Message Not Public
Sign In & Join Server To View
dan in a can
dan in a can2w ago
I think I would only move to multiple container instances if the tests were taking too long, or if different parts of the suite had very different and time consuming seeding or something like that correct
Unknown User
Unknown User2w ago
Message Not Public
Sign In & Join Server To View
dan in a can
dan in a can2w ago
I'd start with just wiping the db and starting fresh on every single test
Unknown User
Unknown User2w ago
Message Not Public
Sign In & Join Server To View
dan in a can
dan in a can2w ago
state bleed can be very frustrating to diagnose well your classes are going to execute in parallel in xunit at least
Unknown User
Unknown User2w ago
Message Not Public
Sign In & Join Server To View
dan in a can
dan in a can2w ago
Oh yeah, I see now You are correct You would probably have to isolate that by marking your own xunit collection
Unknown User
Unknown User2w ago
Message Not Public
Sign In & Join Server To View
dan in a can
dan in a can2w ago
Ideally, your tables won't span lots of test classes like that... But I get irl not always ideal
Unknown User
Unknown User2w ago
Message Not Public
Sign In & Join Server To View
dan in a can
dan in a can2w ago
xunit runs test collections in parallel, and tests within each collection sequentially. By default, each class is its own collection, which is why classes run in parallel. You can override that though and create your own [CollectionDefiniton]s and mark multiple classes to use the same db container hopefully im making sense lol it is midnight over here
Unknown User
Unknown User2w ago
Message Not Public
Sign In & Join Server To View
Canyon
CanyonOP2w ago
BTW I'm meaning to test this tomorrow when I'm back at my desktop
Unknown User
Unknown User2w ago
Message Not Public
Sign In & Join Server To View
Canyon
CanyonOP2w ago
Honestly LGTM!
Unknown User
Unknown User2w ago
Message Not Public
Sign In & Join Server To View
Canyon
CanyonOP2w ago
64 gb should suffice
Unknown User
Unknown User2w ago
Message Not Public
Sign In & Join Server To View
dan in a can
dan in a can2w ago
Yeah, with default xunit behavior, it can't run in parallel due to reset if you have multiple collections running on the same tables. However, you can also correct that behavior by creating your own xunit collections and tagging your test classes
Unknown User
Unknown User2w ago
Message Not Public
Sign In & Join Server To View
dan in a can
dan in a can2w ago
to have say like 20 test classes use only 4 db containers
Unknown User
Unknown User2w ago
Message Not Public
Sign In & Join Server To View
dan in a can
dan in a can2w ago
:Ok:
Unknown User
Unknown User2w ago
Message Not Public
Sign In & Join Server To View
dan in a can
dan in a can2w ago
dream big
Canyon
CanyonOP2w ago
I feel like this part of TestContainers needs some work but it's pretty far out in terms of requirements, so I'm not surprised there isn't a 'one and done' solution to it
Unknown User
Unknown User2w ago
Message Not Public
Sign In & Join Server To View
dan in a can
dan in a can2w ago
it would be a nice addition, I'm guessing they consider it out of scope for the library
Unknown User
Unknown User2w ago
Message Not Public
Sign In & Join Server To View
dan in a can
dan in a can2w ago
oohh I haven't used that
Unknown User
Unknown User2w ago
Message Not Public
Sign In & Join Server To View
dan in a can
dan in a can2w ago
and hopefully I won't have to :YEP:
Unknown User
Unknown User2w ago
Message Not Public
Sign In & Join Server To View
dan in a can
dan in a can2w ago
also unless you have something really specific you dont need the with username and password methods
Unknown User
Unknown User2w ago
Message Not Public
Sign In & Join Server To View
dan in a can
dan in a can2w ago
or the database one the builders come with sensible defaults for some of the values
dan in a can
dan in a can2w ago
GitHub
testcontainers-dotnet/src/Testcontainers.PostgreSql/PostgreSqlBuild...
A library to support tests with throwaway instances of Docker containers for all compatible .NET Standard versions. - testcontainers/testcontainers-dotnet
Unknown User
Unknown User2w ago
Message Not Public
Sign In & Join Server To View
dan in a can
dan in a can2w ago
not gonna hurt anything to have them, probably just dont need them
Unknown User
Unknown User2w ago
Message Not Public
Sign In & Join Server To View
dan in a can
dan in a can2w ago
no problem, curious if you come across an epiphany :Ok:
Unknown User
Unknown User2w ago
Message Not Public
Sign In & Join Server To View

Did you find this page helpful?