I have the following code:
public async Task<Result<Guid>> HandleClient<T>(T client) where T : Client
{
var exists = await _dbContext.Clients.AnyAsync(c => c.ClientId == client.ClientId);
if(exists)
{
return Result.Failure<Guid>(new ConflictError(nameof(Client.ClientId), client.ClientId));
}
try
{
_dbContext.Clients.Add(client);
await _dbContext.SaveChangesAsync();
return Result.Success(client.Id);
}
catch(Exception e)
{
return Result.Failure<Guid>(
new ExceptionError($"Error when trying to create client '{client.ClientId}'", e));
}
}
I already have the following integration test that covers the "exists" case:
[Fact]
public async Task ShouldReturn409ConflictWhenClientIdIsNotUnique()
{
var clientId = Guid.CreateVersion7().ToString();
var existingClient = new PublicClientBuilder { ClientId = clientId }.Build();
await using var arrangeScope = _fixture.Services.CreateAsyncScope();
await using var arrangeDbContext = arrangeScope.ServiceProvider.GetRequiredService<MyDbContext>();
arrangeDbContext.Clients.Add(existingClient);
await arrangeDbContext.SaveChangesAsync(_ct);
var requestBody = new CreatePublicClientRequestBodyBuilder { ClientId = clientId }.Build();
var client = _fixture.CreateClient();
var request = new HttpRequestMessage(HttpMethod.Post, "clients") { Content = new JsonContent(requestBody) };
var response = await client.SendAsync(request, _ct);
response.StatusCode.ShouldBe(HttpStatusCode.Conflict);
var problemDetails = await response.ReadProblemDetails(_ct);
problemDetails.Title.ShouldBe("Conflict");
problemDetails.Detail.ShouldBe($"'{clientId}' already exists (ClientId)");
}
Now, I also want to test that my code catches exceptions and returns a Result.
My _fixture looks like this:
public class WebFixture : WebApplicationFactory<Program>, IAsyncLifetime
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder
.UseEnvironment("IntegrationTest")
.ConfigureAppConfiguration(configurationBuilder =>
{
configurationBuilder.Sources.Clear();
configurationBuilder.AddInMemoryCollection(TestConfiguration!);
configurationBuilder.AddEnvironmentVariables("TEST_");
});
}
public async ValueTask InitializeAsync()
{
await using var scope = Services.CreateAsyncScope();
await using var dbContext = scope.ServiceProvider.GetRequiredService<MyDbContext>();
await dbContext.Database.MigrateAsync();
}
}
I can then resolve MyDbContext like this in my tests:
await using var arrangeScope = _fixture.Services.CreateAsyncScope();
await using var arrangeDbContext = arrangeScope.ServiceProvider.GetRequiredService<MyDbContext>();
It's then possible to create a HttpClient like this and call my endpoint:
var client = _fixture.CreateClient();
Now I only needed a way to be able to configure MyDbContext to throw an exception when SaveChangesAsync is called.
The registration of MyDbContext looks like this:
public static void AddDatabase(this IServiceCollection services, IConfiguration configuration)
{
services.Configure<PostgresDatabaseOptions>(configuration.GetSection("Storage:Postgres"));
services.AddSingleton<PostgresDatabaseOptions>(x =>
x.GetRequiredService<IOptions<PostgresDatabaseOptions>>().Value);
services.AddSingleton<NpgsqlDataSource>(provider =>
{
var postgresOptions = provider.GetRequiredService<PostgresDatabaseOptions>();
var builder = new NpgsqlConnectionStringBuilder(postgresOptions.ConnectionString)
{
Pooling = true,
MinPoolSize = 2,
MaxPoolSize = 50
};
return NpgsqlDataSource.Create(builder.ConnectionString);
});
services.AddDbContext<MyDbContext, PostgresDbContext>((provider, options) =>
{
var dataSource = provider.GetRequiredService<NpgsqlDataSource>();
var interceptors = provider.GetServices<IInterceptor>();
options.UseNpgsql(dataSource).UseSnakeCaseNamingConvention().AddInterceptors(interceptors.ToArray());
});
}
As you can see, I'm using Postgres and then I configure MyDbContext. When configuring MyDbContext, I also resolve all IInterceptor implementations from the container and ensure it's used.
var interceptors = provider.GetServices<IInterceptor>();
options.UseNpgsql(dataSource).UseSnakeCaseNamingConvention().AddInterceptors(interceptors.ToArray());
I've created the following interceptor in my test project:
public class ThrowingInterceptor : SaveChangesInterceptor
{
public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData eventData,
InterceptionResult<int> result,
CancellationToken cancellationToken = default)
{
throw new Exception("Boom from test");
}
}
Now I only need a way to register my interceptor from my test to make sure that it's used.
I've created the following method in my WebFixture:
ublic HttpClient CreateClient(Action<IServiceCollection>? configureServices)
{
if(configureServices is null)
{
return CreateClient();
}
return WithWebHostBuilder(builder =>
{
builder.ConfigureServices(configureServices);
}).CreateClient();
}
Since it takes an Action<IServiceCollection>, it's possible to configure the IServiceCollection from the test like this:
var client = _fixture.CreateClient(services =>
{
services.AddScoped<IInterceptor, ThrowingInterceptor>();
});
The following test will now pass:
[Fact]
public async Task ShouldReturn500InternalServerErrorWhenExceptionHappens()
{
var clientId = Guid.CreateVersion7().ToString();
await using var arrangeScope = _fixture.Services.CreateAsyncScope();
await using var arrangeDbContext = arrangeScope.ServiceProvider.GetRequiredService<AwthDbContext>();
var requestBody = new CreatePublicClientRequestBodyBuilder { ClientId = clientId }.Build();
var request = new HttpRequestMessage(HttpMethod.Post, "clients") { Content = new JsonContent(requestBody) };
var client = _fixture.CreateClient(services =>
{
services.AddScoped<IInterceptor, ThrowingInterceptor>();
});
var response = await client.SendAsync(request, _ct);
response.StatusCode.ShouldBe(HttpStatusCode.InternalServerError);
var problemDetails = await response.ReadProblemDetails(_ct);
// Generic response since my API should not leak exception messages
problemDetails.Title.ShouldBe("An error occurred while processing your request.");
}