Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor: provide the lifetime in strategy #58

Merged
merged 2 commits into from
Aug 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 22 additions & 30 deletions .github/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,13 +193,19 @@ The library comes with 3 standard behaviors:

By default the library simply instantates the context and disposes the
instance after the test execution.
The DbContext instance of this strategy is scoped. This means that in
the test code, the `Database` property is not the same instance as the
one in the tested application.

#### `IDatabaseTestStrategy<TContext>.Transaction`

This behavior will execute each test in a separate transaction. This can
be used to prevent write operations to change the contents of the
database. Obviously requires a database engine that supports
transactions.
The DbContext instance of this strategy is singleton. This means that in
the test code, the `Database` property is the same instance as the one
in the tested application.

#### `IDatabaseTestStrategy<TContext>.DatabasePerTest`

Expand All @@ -210,8 +216,11 @@ APIs](https://learn.microsoft.com/en-us/ef/core/managing-schemas/ensure-created)
to understand how this might affect your test results). This allows test
parallelization when transaction isolation is not sufficient or
unavailable.
The DbContext instance of this strategy is scoped. This means that in
the test code, the `Database` property is not the same instance as the
one in the tested application.

You must combine this behavior with a random name
You MUST combine this behavior with a random name
interpolation in the connection string to run each test on it’s own
database in parallel. Otherwise the tests will try to access the same
database concurrently and will fail to drop it while other tests are
Expand All @@ -225,39 +234,22 @@ protected override void ConfigureDbContext(DbContextOptionsBuilder builder)
=> builder.UseSqlite($"Data Source={Guid.NewGuid()}.sqlite");
```

This beahvior WILL drop your database after each test !
CAUTION: This beahvior WILL drop your database after each test !

### Database context lifetime
### Cleaning the change tracker

By default, the library injects the DbContext as scoped service. If you
run the test using a transaction isolation level, the test code needs to
access the context instance to start and rollback the transactions.
In that case you need to tell the runtime to inject the DbContext as a
singleton service trough the `DatabaseLifetime` property:

```cs
protected override IDatabaseTestStrategy<Context> DatabaseTestStrategy
=> IDatabaseTestStrategy<Context>.Transaction;

protected override ServiceLifetime DatabaseLifetime
=> ServiceLifetime.Singleton;

protected override void ConfigureDbContext(DbContextOptionsBuilder builder)
=> builder.UseSqlite($"Data Source={Guid.NewGuid()}.sqlite");
```
The database test strategy determines the lifetime of the DbContext
instance in the DI container.

When running the tests with a singleton DbContext, any call to the
DbContext during the arrange phase (including calls to the HttpClient) might
clogger the Change Tracker with existing entities. In this situation the
DbContext's ChangeTracker might not be empty when the system under test is
called. This may in turn cause attaching entities to fail, whereas it would
have worked in a real request. In such case, the test writer should call
`Database.ChangeTracker.Clear()` at the end of the arrange phase.

The most common pattern for this is when arranging some entities in the
database then calling an update entity operation.

This operation is not needed if the DatabaseLifetime is set to scoped.
DbContext during the arrange phase (including calls via the HttpClient)
might clogger the Change Tracker with existing entities. In this situation
the DbContext's ChangeTracker might not be empty when the system under test
is called. This may in turn cause attaching entities to fail, whereas it
would have worked in a real request. In such case, the test writer should
call `Database.ChangeTracker.Clear()` at the end of the arrange phase.

Cleaning the change tracker is not needed for scoped or transient.

## OpenTelemetry integration

Expand Down
5 changes: 2 additions & 3 deletions src/ArwynFr.IntegrationTesting/DatabaseTestStrategy.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ internal sealed class DatabaseTestStrategy<TContext> : IDatabaseTestStrategy<TCo
private bool transaction = false;
private bool transient = false;

public ServiceLifetime Lifetime => transaction ? ServiceLifetime.Singleton : ServiceLifetime.Scoped;

public async Task DisposeAsync(TContext database)
{
if (transient)
Expand Down Expand Up @@ -47,7 +49,4 @@ private static Task UpdateDatabase(TContext context)
&& context.Database.GetMigrations().Any()
? context.Database.MigrateAsync()
: context.Database.EnsureCreatedAsync();

public bool IsLifetimeSupported(ServiceLifetime lifetime)
=> transaction ? lifetime == ServiceLifetime.Singleton : true;
}
2 changes: 1 addition & 1 deletion src/ArwynFr.IntegrationTesting/IDatabaseTestStrategy.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ public interface IDatabaseTestStrategy<TContext>
/// </summary>
public static IDatabaseTestStrategy<TContext> Transaction => new DatabaseTestStrategy<TContext>().WithTransaction();

bool IsLifetimeSupported(ServiceLifetime lifetime);
ServiceLifetime Lifetime { get; }
Task DisposeAsync(TContext database);
Task InitializeAsync(TContext database);

Expand Down
12 changes: 2 additions & 10 deletions src/ArwynFr.IntegrationTesting/IntegrationTestBase.Database.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,6 @@ public abstract class IntegrationTestBase<TProgram, TContext>(ITestOutputHelper

protected virtual IDatabaseTestStrategy<TContext> DatabaseTestStrategy => IDatabaseTestStrategy<TContext>.Default;

protected virtual ServiceLifetime DatabaseLifetime => ServiceLifetime.Scoped;

public override async Task DisposeAsync()
{
await DatabaseTestStrategy.DisposeAsync(Database);
Expand All @@ -30,16 +28,10 @@ public override async Task InitializeAsync()
protected override void ConfigureAppServices(IServiceCollection services)
{
base.ConfigureAppServices(services);

if (!DatabaseTestStrategy.IsLifetimeSupported(DatabaseLifetime))
{
throw new InvalidOperationException("Service lifetime not supported by the database strategy");
}

services.RemoveAll<TContext>();
services.RemoveAll<DbContextOptions<TContext>>();
services.AddDbContext<TContext>(ConfigureDbContext, DatabaseLifetime);
services.Add(new ServiceDescriptor(typeof(TContext), typeof(TContext), DatabaseLifetime));
services.AddDbContext<TContext>(ConfigureDbContext, DatabaseTestStrategy.Lifetime);
services.Add(new ServiceDescriptor(typeof(TContext), typeof(TContext), DatabaseTestStrategy.Lifetime));
}

protected abstract void ConfigureDbContext(DbContextOptionsBuilder builder);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
using ArwynFr.IntegrationTesting.Tests.Target;
using FluentAssertions;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Xunit;
using Xunit.Abstractions;

Expand All @@ -24,8 +23,6 @@ public override async Task InitializeAsync()
protected override IDatabaseTestStrategy<DummyDbContext> DatabaseTestStrategy
=> IDatabaseTestStrategy<DummyDbContext>.Transaction;

protected override ServiceLifetime DatabaseLifetime => ServiceLifetime.Singleton;

protected override void ConfigureDbContext(DbContextOptionsBuilder builder)
=> builder.UseSqlite($@"Data Source={Guid.NewGuid()}.sqlite");
}