0
0
mirror of https://github.com/alex289/CleanArchitecture.git synced 2025-06-29 18:21:08 +00:00

feat: Use TestContainers for integration tests

This commit is contained in:
alex289 2024-04-24 21:41:13 +02:00
parent 35d7482ade
commit 2c7748d877
No known key found for this signature in database
GPG Key ID: 573F77CD2D87F863
9 changed files with 230 additions and 92 deletions

View File

@ -10,10 +10,13 @@
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.4" />
<PackageReference Include="Microsoft.Data.Sqlite.Core" Version="8.0.4" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Proxies" Version="8.0.4" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.9.0" />
<PackageReference Include="Respawn" Version="6.2.1" />
<PackageReference Include="Testcontainers" Version="3.8.0" />
<PackageReference Include="Testcontainers.MsSql" Version="3.8.0" />
<PackageReference Include="Testcontainers.RabbitMq" Version="3.8.0" />
<PackageReference Include="Testcontainers.Redis" Version="3.8.0" />
<PackageReference Include="xunit" Version="2.7.0" />
<PackageReference Include="Xunit.Priority" Version="1.1.6" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.7">

View File

@ -1,46 +0,0 @@
using System;
using System.Collections.Generic;
using System.Data.Common;
using System.Linq;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.Extensions.DependencyInjection;
namespace CleanArchitecture.IntegrationTests.Extensions;
public static class FunctionalTestsServiceCollectionExtensions
{
public static IServiceCollection SetupTestDatabase<TContext>(this IServiceCollection services,
DbConnection connection) where TContext : DbContext
{
var descriptor = services.SingleOrDefault(d => d.ServiceType == typeof(DbContextOptions<TContext>));
if (descriptor is not null)
services.Remove(descriptor);
services.AddScoped(p =>
DbContextOptionsFactory<TContext>(
p,
(_, options) => options
.ConfigureWarnings(b => b.Log(CoreEventId.ManyServiceProvidersCreatedWarning))
.UseLazyLoadingProxies()
.UseSqlite(connection)));
return services;
}
private static DbContextOptions<TContext> DbContextOptionsFactory<TContext>(
IServiceProvider applicationServiceProvider,
Action<IServiceProvider, DbContextOptionsBuilder> optionsAction)
where TContext : DbContext
{
var builder = new DbContextOptionsBuilder<TContext>(
new DbContextOptions<TContext>(new Dictionary<Type, IDbContextOptionsExtension>()));
builder.UseApplicationServiceProvider(applicationServiceProvider);
optionsAction.Invoke(applicationServiceProvider, builder);
return builder.Options;
}
}

View File

@ -0,0 +1,23 @@
using System;
using System.Threading.Tasks;
using CleanArchitecture.IntegrationTests.Infrastructure;
using Xunit;
namespace CleanArchitecture.IntegrationTests.Fixtures;
public sealed class DatabaseFixture : IAsyncLifetime
{
public static string TestRunDbName { get; } = $"CleanArchitecture-Integration-{Guid.NewGuid()}";
public async Task DisposeAsync()
{
var db = DatabaseAccessor.GetOrCreateAsync(TestRunDbName);
await db.DisposeAsync();
}
public async Task InitializeAsync()
{
var db = DatabaseAccessor.GetOrCreateAsync(TestRunDbName);
await db.InitializeAsync();
}
}

View File

@ -1,5 +1,6 @@
using System;
using System.Net.Http;
using System.Threading.Tasks;
using CleanArchitecture.Domain.Constants;
using CleanArchitecture.Domain.Entities;
using CleanArchitecture.Domain.Enums;
@ -8,20 +9,21 @@ using CleanArchitecture.IntegrationTests.Infrastructure;
using CleanArchitecture.IntegrationTests.Infrastructure.Auth;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection;
using Xunit;
namespace CleanArchitecture.IntegrationTests.Fixtures;
public class TestFixtureBase
public class TestFixtureBase : IAsyncLifetime
{
public HttpClient ServerClient { get; }
protected WebApplicationFactory<Program> Factory { get; }
protected CleanArchitectureWebApplicationFactory Factory { get; }
public TestFixtureBase(bool useTestAuthentication = true)
{
Factory = new CleanArchitectureWebApplicationFactory(
SeedTestData,
RegisterCustomServicesHandler,
useTestAuthentication);
useTestAuthentication,
DatabaseFixture.TestRunDbName);
ServerClient = Factory.CreateClient();
ServerClient.Timeout = TimeSpan.FromMinutes(5);
@ -29,17 +31,17 @@ public class TestFixtureBase
protected virtual void SeedTestData(ApplicationDbContext context)
{
context.Users.Add(new User(
TestAuthenticationOptions.TestUserId,
Ids.Seed.TenantId,
TestAuthenticationOptions.Email,
TestAuthenticationOptions.FirstName,
TestAuthenticationOptions.LastName,
// !Password123#
"$2a$12$Blal/uiFIJdYsCLTMUik/egLbfg3XhbnxBC6Sb5IKz2ZYhiU/MzL2",
UserRole.Admin));
}
context.SaveChanges();
private async Task PrepareDatabaseAsync()
{
await Factory.RespawnDatabaseAsync();
using var scope = Factory.Services.CreateScope();
await using var dbContext = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
SeedTestData(dbContext);
await dbContext.SaveChangesAsync();
}
protected virtual void RegisterCustomServicesHandler(
@ -48,4 +50,14 @@ public class TestFixtureBase
IServiceProvider scopedServices)
{
}
public async Task InitializeAsync()
{
await PrepareDatabaseAsync();
}
public Task DisposeAsync()
{
return Task.CompletedTask;
}
}

View File

@ -1,13 +1,11 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using CleanArchitecture.Infrastructure.Database;
using CleanArchitecture.Infrastructure.Extensions;
using CleanArchitecture.IntegrationTests.Extensions;
using CleanArchitecture.IntegrationTests.Infrastructure.Auth;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace CleanArchitecture.IntegrationTests.Infrastructure;
@ -21,20 +19,19 @@ public sealed class CleanArchitectureWebApplicationFactory : WebApplicationFacto
ServiceProvider serviceProvider,
IServiceProvider scopedServices);
private readonly AddCustomSeedDataHandler? _addCustomSeedDataHandler;
private readonly bool _addTestAuthentication;
private readonly string _instanceDatabaseName;
private readonly SqliteConnection _connection = new("DataSource=:memory:");
private readonly bool _addTestAuthentication;
private readonly RegisterCustomServicesHandler? _registerCustomServicesHandler;
public CleanArchitectureWebApplicationFactory(
AddCustomSeedDataHandler? addCustomSeedDataHandler,
RegisterCustomServicesHandler? registerCustomServicesHandler,
bool addTestAuthentication)
bool addTestAuthentication,
string instanceDatabaseName)
{
_addCustomSeedDataHandler = addCustomSeedDataHandler;
_registerCustomServicesHandler = registerCustomServicesHandler;
_addTestAuthentication = addTestAuthentication;
_instanceDatabaseName = instanceDatabaseName;
}
protected override void ConfigureWebHost(IWebHostBuilder builder)
@ -43,14 +40,22 @@ public sealed class CleanArchitectureWebApplicationFactory : WebApplicationFacto
base.ConfigureWebHost(builder);
_connection.Open();
builder.ConfigureAppConfiguration(configurationBuilder =>
{
configurationBuilder.AddEnvironmentVariables();
var accessor = DatabaseAccessor.GetOrCreateAsync(_instanceDatabaseName);
// Overwrite default connection string to our test instance db
configurationBuilder.AddInMemoryCollection([
new KeyValuePair<string, string?>(
"ConnectionStrings:DefaultConnection",
accessor.GetConnectionString())
]);
});
builder.ConfigureServices(services =>
{
services.SetupTestDatabase<ApplicationDbContext>(_connection);
services.SetupTestDatabase<EventStoreDbContext>(_connection);
services.SetupTestDatabase<DomainNotificationStoreDbContext>(_connection);
if (_addTestAuthentication)
{
services.AddAuthentication(options =>
@ -65,22 +70,22 @@ public sealed class CleanArchitectureWebApplicationFactory : WebApplicationFacto
using var scope = sp.CreateScope();
var scopedServices = scope.ServiceProvider;
var applicationDbContext = scopedServices.GetRequiredService<ApplicationDbContext>();
var storeDbContext = scopedServices.GetRequiredService<EventStoreDbContext>();
var domainStoreDbContext = scopedServices.GetRequiredService<DomainNotificationStoreDbContext>();
var accessor = DatabaseAccessor.GetOrCreateAsync(_instanceDatabaseName);
var applicationDbContext = accessor.CreateDatabase(scopedServices);
applicationDbContext.EnsureMigrationsApplied();
var creator2 = (RelationalDatabaseCreator)storeDbContext.Database
.GetService<IRelationalDatabaseCreator>();
creator2.CreateTables();
var creator3 = (RelationalDatabaseCreator)domainStoreDbContext
.Database.GetService<IRelationalDatabaseCreator>();
creator3.CreateTables();
_addCustomSeedDataHandler?.Invoke(applicationDbContext);
_registerCustomServicesHandler?.Invoke(services, sp, scopedServices);
});
}
public async Task RespawnDatabaseAsync()
{
var accessor = DatabaseAccessor.GetOrCreateAsync(_instanceDatabaseName);
await accessor.RespawnDatabaseAsync();
}
public override async ValueTask DisposeAsync()
{
var accessor = DatabaseAccessor.GetOrCreateAsync(_instanceDatabaseName);
await accessor.DisposeAsync();
}
}

View File

@ -0,0 +1,125 @@
using System;
using System.Collections.Concurrent;
using System.Threading.Tasks;
using CleanArchitecture.Infrastructure.Database;
using CleanArchitecture.Infrastructure.Extensions;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.DependencyInjection;
using Respawn;
using Testcontainers.MsSql;
namespace CleanArchitecture.IntegrationTests.Infrastructure;
public sealed class DatabaseAccessor
{
private static readonly ConcurrentDictionary<string, DatabaseAccessor> s_accessors = new();
private readonly string _instanceDatabaseName;
private bool _databaseCreated = false;
private readonly object _databaseCreationLock = new();
private const string _dbPassword = "12345678##as";
private static readonly MsSqlContainer s_dbContainer = new MsSqlBuilder()
.WithPassword(_dbPassword)
.WithPortBinding(1433)
.Build();
public DatabaseAccessor(string instanceName)
{
_instanceDatabaseName = instanceName;
}
public async Task InitializeAsync()
{
await s_dbContainer.StartAsync();
}
public ApplicationDbContext CreateDatabase(IServiceProvider scopedServices)
{
var applicationDbContext = scopedServices.GetRequiredService<ApplicationDbContext>();
lock (_databaseCreationLock)
{
if (_databaseCreated)
{
return applicationDbContext;
}
applicationDbContext.EnsureMigrationsApplied();
var eventsContext = scopedServices.GetRequiredService<EventStoreDbContext>();
eventsContext.EnsureMigrationsApplied();
var notificationsContext = scopedServices.GetRequiredService<DomainNotificationStoreDbContext>();
notificationsContext.EnsureMigrationsApplied();
}
_databaseCreated = true;
return applicationDbContext;
}
public async ValueTask DisposeAsync()
{
var dropScript = $@"
USE MASTER;
ALTER DATABASE [{_instanceDatabaseName}]
SET multi_user WITH ROLLBACK IMMEDIATE;
ALTER DATABASE [{_instanceDatabaseName}]
SET SINGLE_USER WITH ROLLBACK IMMEDIATE;
DROP DATABASE [{_instanceDatabaseName}];";
await using (var con = new SqlConnection(GetConnectionString()))
{
await con.OpenAsync();
var cmd = new SqlCommand(dropScript, con);
await cmd.ExecuteNonQueryAsync();
}
await s_dbContainer.DisposeAsync();
}
public async Task RespawnDatabaseAsync()
{
var connectionString = GetConnectionString();
var respawn = await Respawner.CreateAsync(
connectionString,
new RespawnerOptions
{
TablesToIgnore = ["__EFMigrationsHistory"]
});
await respawn.ResetAsync(connectionString);
}
public string GetConnectionString()
{
var conBuilder = new SqlConnectionStringBuilder()
{
DataSource = s_dbContainer.Hostname,
InitialCatalog = _instanceDatabaseName,
IntegratedSecurity = false,
Password = _dbPassword,
UserID = "sa",
TrustServerCertificate = true
};
return conBuilder.ToString();
}
public static DatabaseAccessor GetOrCreateAsync(string instanceName)
{
if (!s_accessors.TryGetValue(instanceName, out _))
{
var accessor = new DatabaseAccessor(instanceName);
s_accessors.TryAdd(instanceName, accessor);
}
return s_accessors[instanceName];
}
}

View File

@ -0,0 +1,10 @@
using CleanArchitecture.IntegrationTests.Fixtures;
using Xunit;
namespace CleanArchitecture.IntegrationTests;
[CollectionDefinition("IntegrationTests", DisableParallelization = true)]
public sealed class IntegrationTestsCollection :
ICollectionFixture<DatabaseFixture>
{
}

View File

@ -5,9 +5,12 @@ using CleanArchitecture.IntegrationTests.Fixtures.gRPC;
using CleanArchitecture.Proto.Tenants;
using FluentAssertions;
using Xunit;
using Xunit.Priority;
namespace CleanArchitecture.IntegrationTests.gRPC;
[Collection("IntegrationTests")]
[TestCaseOrderer(PriorityOrderer.Name, PriorityOrderer.Assembly)]
public sealed class GetTenantsByIdsTests : IClassFixture<GetTenantsByIdsTestFixture>
{
private readonly GetTenantsByIdsTestFixture _fixture;

View File

@ -4,9 +4,12 @@ using CleanArchitecture.IntegrationTests.Fixtures.gRPC;
using CleanArchitecture.Proto.Users;
using FluentAssertions;
using Xunit;
using Xunit.Priority;
namespace CleanArchitecture.IntegrationTests.gRPC;
[Collection("IntegrationTests")]
[TestCaseOrderer(PriorityOrderer.Name, PriorityOrderer.Assembly)]
public sealed class GetUsersByIdsTests : IClassFixture<GetUsersByIdsTestFixture>
{
private readonly GetUsersByIdsTestFixture _fixture;