Skip to content

Commit ee205ea

Browse files
committed
fix: dbcontext scoped dependencies support
+semver: minor
1 parent a7654e9 commit ee205ea

File tree

7 files changed

+108
-49
lines changed

7 files changed

+108
-49
lines changed

.github/USAGE.md

Lines changed: 80 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -58,28 +58,29 @@ fails, you get all the tested application logs in the test output.
5858

5959
### `ConfigureAppServices`
6060

61-
Override dependency injection services of the tested application.
62-
63-
If your test code and the tested application need to access the same
64-
instance of a service, you need to inject this instance using a
65-
Singleton lifetime. Accessing the DI container outside of the client
66-
call will return an unscoped provider as scopes are created and disposed
67-
by the framework for each request.
61+
This method allows to override dependency injection services of the
62+
tested application. This is useful to inject class doubles. Whenever the
63+
tested application will try to resolve a service declared here, they will
64+
get instances of the `FakeService` instead of the original types:
6865

6966
```cs
70-
// Override a service with custom implementation in the tested app
67+
// Override services with custom implementations
7168
protected override void ConfigureAppServices(IServiceCollection services)
72-
=> services.AddSingleton<IMyService, FakeService>();
73-
74-
[Fact]
75-
public async Task OnTest()
7669
{
77-
// Access the injected service from the test code
78-
var service = Services.GetRequiredService<IMyService>();
79-
service.SetValue(expected);
70+
services.AddScoped<IMyScopedService, FakeService>();
71+
services.AddSingleton<IMySingletonService, FakeService>();
72+
services.AddTransient<IMyTransientService, FakeService>();
8073
}
8174
```
8275

76+
You can also overide the lifetime of the service:
77+
78+
```cs
79+
// Override a scoped service with a singleton object
80+
protected override void ConfigureAppServices(IServiceCollection services)
81+
=> services.AddSingleton<IMyScopedService>(new FakeInstance());
82+
```
83+
8384
## Accessing the tested application
8485

8586
### `Client`
@@ -104,14 +105,47 @@ currently available to the tested application.
104105

105106
### `Services`
106107

107-
This property grants you access to the DI service provider of the tested
108-
application.
108+
This property grants you an access to the DI service container of the
109+
tested application:
110+
111+
```cs
112+
[Fact] public void Test()
113+
{
114+
var service = Services.GetService<IMyService>();
115+
}
116+
```
117+
118+
Scopes are handled by the framework at the request level, so the test
119+
and the tested application cannot share a common scope. This means that
120+
if you resolve a scoped service from the test, you will get a different
121+
instance than the one used in the tested application.
122+
123+
```cs
124+
[Fact] public void Test()
125+
{
126+
// these two instances are not the same !
127+
var expected = Services.GetService<IMyScopedService>().GetHashCode();
128+
var actual = await Client.GetScopedServiceHashCode();
129+
actual.Should().Be(expected); // FAIL
130+
}
131+
```
132+
133+
If you need the test class and the tested application to share object
134+
instances, you need to override their lifetime to singleton. Beware
135+
of impacts due to the usage of a singleton object accross multiple
136+
request.
109137

110-
If your test code and the tested application need to access the same
111-
instance of a service, you need to inject this instance using a
112-
Singleton lifetime. Accessing the DI container outside of the client
113-
call will return an unscoped provider as scopes are created and disposed
114-
by the framework for each request.
138+
```cs
139+
// Override a scoped service with a singleton instance
140+
protected override void ConfigureAppServices(IServiceCollection services)
141+
=> services.AddSingleton<IMyScopedService, FakeService>();
142+
143+
[Fact] public void Test()
144+
{
145+
var expected = Services.GetService<IMyScopedService>().GetHashCode();
146+
var actual = await Client.GetScopedServiceHashCode(); // SUCCESS
147+
}
148+
```
115149

116150
## Extending the behavior of the test class
117151

@@ -138,8 +172,8 @@ that only uses the entrypoint.
138172
You will need to override the abstract `ConfigureDbContext` method to
139173
tell the dependency injection library how to configure your context. A
140174
context instance will be generated per test and injected in your target
141-
app as a singleton. You can access the same context instance in your
142-
test through the `Database` property.
175+
app. You can access the same context instance in your test through the
176+
`Database` property.
143177

144178
```cs
145179
protected override void ConfigureDbContext(DbContextOptionsBuilder builder)
@@ -193,23 +227,37 @@ protected override void ConfigureDbContext(DbContextOptionsBuilder builder)
193227

194228
This beahvior WILL drop your database after each test !
195229

196-
### Cleaning the ChangeTracker
230+
### Database context lifetime
231+
232+
By default, the library injects the DbContext as scoped service. If you
233+
run the test using a transaction isolation level, the test code needs to
234+
access the context instance to start and rollback the transactions.
235+
In that case you need to tell the runtime to inject the DbContext as a
236+
singleton service trough the `DatabaseLifetime` property:
237+
238+
```cs
239+
protected override IDatabaseTestStrategy<Context> DatabaseTestStrategy
240+
=> IDatabaseTestStrategy<Context>.Transaction;
241+
242+
protected override ServiceLifetime DatabaseLifetime
243+
=> ServiceLifetime.Singleton;
244+
245+
protected override void ConfigureDbContext(DbContextOptionsBuilder builder)
246+
=> builder.UseSqlite($"Data Source={Guid.NewGuid()}.sqlite");
247+
```
197248

198-
When running normally, dotnet web applications will use a different scoped
199-
instance of the DbContext for each HTTP request. The test library uses a
200-
singleton instance, otherwise it would not be possible to access the same
201-
instance from within the test context. As a consequence, any call to the
249+
When running the tests with a singleton DbContext, any call to the
202250
DbContext during the arrange phase (including calls to the HttpClient) might
203251
clogger the Change Tracker with existing entities. In this situation the
204252
DbContext's ChangeTracker might not be empty when the system under test is
205253
called. This may in turn cause attaching entities to fail, whereas it would
206-
have worked in a real request.
254+
have worked in a real request. In such case, the test writer should call
255+
`Database.ChangeTracker.Clear()` at the end of the arrange phase.
207256

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

211-
In such case, the test writer should call `Database.ChangeTracker.Clear()`
212-
at the end of the arrange phase.
260+
This operation is not needed if the DatabaseLifetime is set to scoped.
213261

214262
## OpenTelemetry integration
215263

src/ArwynFr.IntegrationTesting/DatabaseTestStrategy.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using Microsoft.EntityFrameworkCore;
2+
using Microsoft.Extensions.DependencyInjection;
23

34
namespace ArwynFr.IntegrationTesting;
45

@@ -46,4 +47,7 @@ private static Task UpdateDatabase(TContext context)
4647
&& context.Database.GetMigrations().Any()
4748
? context.Database.MigrateAsync()
4849
: context.Database.EnsureCreatedAsync();
50+
51+
public bool IsLifetimeSupported(ServiceLifetime lifetime)
52+
=> transaction ? lifetime == ServiceLifetime.Singleton : true;
4953
}

src/ArwynFr.IntegrationTesting/IDatabaseTestStrategy.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using Microsoft.EntityFrameworkCore;
2+
using Microsoft.Extensions.DependencyInjection;
23

34
namespace ArwynFr.IntegrationTesting;
45

@@ -21,6 +22,7 @@ public interface IDatabaseTestStrategy<TContext>
2122
/// </summary>
2223
public static IDatabaseTestStrategy<TContext> Transaction => new DatabaseTestStrategy<TContext>().WithTransaction();
2324

25+
bool IsLifetimeSupported(ServiceLifetime lifetime);
2426
Task DisposeAsync(TContext database);
2527
Task InitializeAsync(TContext database);
2628

src/ArwynFr.IntegrationTesting/IntegrationTestBase.Database.cs

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,20 @@
11
using Microsoft.EntityFrameworkCore;
22
using Microsoft.Extensions.DependencyInjection;
3-
using System.Linq.Expressions;
3+
using Microsoft.Extensions.DependencyInjection.Extensions;
44
using Xunit.Abstractions;
55

66
namespace ArwynFr.IntegrationTesting;
77

8-
public abstract class IntegrationTestBase<TProgram, TContext> : IntegrationTestBase<TProgram>
8+
public abstract class IntegrationTestBase<TProgram, TContext>(ITestOutputHelper output) : IntegrationTestBase<TProgram>(output)
99
where TProgram : class
1010
where TContext : DbContext
1111
{
12-
protected IntegrationTestBase(ITestOutputHelper output) : base(output) => Expression.Empty();
13-
1412
protected TContext Database => Services.GetRequiredService<TContext>();
1513

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

16+
protected virtual ServiceLifetime DatabaseLifetime => ServiceLifetime.Scoped;
17+
1818
public override async Task DisposeAsync()
1919
{
2020
await DatabaseTestStrategy.DisposeAsync(Database);
@@ -30,10 +30,16 @@ public override async Task InitializeAsync()
3030
protected override void ConfigureAppServices(IServiceCollection services)
3131
{
3232
base.ConfigureAppServices(services);
33-
services.AddSingleton(new ServiceCollection()
34-
.AddDbContext<TContext>(ConfigureDbContext, ServiceLifetime.Singleton)
35-
.BuildServiceProvider()
36-
.GetRequiredService<TContext>());
33+
34+
if (!DatabaseTestStrategy.IsLifetimeSupported(DatabaseLifetime))
35+
{
36+
throw new InvalidOperationException("Service lifetime not supported by the database strategy");
37+
}
38+
39+
services.RemoveAll<TContext>();
40+
services.RemoveAll<DbContextOptions<TContext>>();
41+
services.AddDbContext<TContext>(ConfigureDbContext, DatabaseLifetime);
42+
services.Add(new ServiceDescriptor(typeof(TContext), typeof(TContext), DatabaseLifetime));
3743
}
3844

3945
protected abstract void ConfigureDbContext(DbContextOptionsBuilder builder);

src/ArwynFr.IntegrationTesting/IntegrationTestBase.cs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,14 @@ public abstract class IntegrationTestBase<TProgram> : IAsyncLifetime
1616
{
1717
private readonly XUnitLoggerProvider provider;
1818

19+
private readonly WebApplicationFactory<TProgram> factory;
20+
1921
protected IntegrationTestBase(ITestOutputHelper output)
2022
{
2123
provider = new XUnitLoggerProvider(output);
22-
var factory = new WebApplicationFactory<TProgram>().WithWebHostBuilder(ConfigureWebHostBuilder);
24+
factory = new WebApplicationFactory<TProgram>().WithWebHostBuilder(ConfigureWebHostBuilder);
2325
Client = factory.CreateClient();
24-
Services = factory.Services;
26+
Services = factory.Services.CreateScope().ServiceProvider;
2527
Configuration = factory.Services.GetRequiredService<IConfiguration>();
2628
}
2729

@@ -38,6 +40,7 @@ protected IntegrationTestBase(ITestOutputHelper output)
3840
public virtual Task DisposeAsync()
3941
{
4042
provider.Dispose();
43+
factory.Dispose();
4144
return Task.CompletedTask;
4245
}
4346

test/ArwynFr.IntegrationTesting.Tests/EntityFrameworkCore/DatabaseTransactionStrategyTests.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using ArwynFr.IntegrationTesting.Tests.Target;
22
using FluentAssertions;
33
using Microsoft.EntityFrameworkCore;
4+
using Microsoft.Extensions.DependencyInjection;
45
using Xunit;
56
using Xunit.Abstractions;
67

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

27+
protected override ServiceLifetime DatabaseLifetime => ServiceLifetime.Singleton;
28+
2629
protected override void ConfigureDbContext(DbContextOptionsBuilder builder)
2730
=> builder.UseSqlite($@"Data Source={Guid.NewGuid()}.sqlite");
2831
}

test/ArwynFr.IntegrationTesting.Tests/OtelTests.cs

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,5 @@ public async Task OTEL_activties_captured()
1414
Activities.Any(activity => activity.DisplayName == ActivityHelper.ActivityName).Should().BeTrue();
1515
}
1616

17-
[Fact]
18-
public async Task ASPNET_OTEL_activities_captured()
19-
{
20-
await Client.GetAsync("/service");
21-
Activities.Any().Should().BeTrue();
22-
}
23-
2417
protected override string[] OpenTelemetrySourceNames => [ActivityHelper.Source.Name];
2518
}

0 commit comments

Comments
 (0)