0
0
mirror of https://github.com/alex289/CleanArchitecture.git synced 2025-08-22 19:28:34 +00:00

feat: Use TestContainers for integration tests (#56)

This commit is contained in:
Alex 2024-04-25 20:36:36 +02:00 committed by GitHub
commit 6a53c747aa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 431 additions and 92 deletions

View File

@ -72,7 +72,7 @@ builder.Services.AddLogging(x => x.AddSimpleConsole(console =>
console.IncludeScopes = true;
}));
if (builder.Environment.IsProduction())
if (builder.Environment.IsProduction() || !string.IsNullOrWhiteSpace(builder.Configuration["RedisHostName"]))
{
builder.Services.AddStackExchangeRedisCache(options =>
{
@ -95,12 +95,8 @@ using (var scope = app.Services.CreateScope())
var domainStoreDbContext = services.GetRequiredService<DomainNotificationStoreDbContext>();
appDbContext.EnsureMigrationsApplied();
if (app.Environment.EnvironmentName != "Integration")
{
storeDbContext.EnsureMigrationsApplied();
domainStoreDbContext.EnsureMigrationsApplied();
}
storeDbContext.EnsureMigrationsApplied();
domainStoreDbContext.EnsureMigrationsApplied();
}
if (app.Environment.IsDevelopment())

View File

@ -13,6 +13,7 @@
"Audience": "CleanArchitectureClient",
"Secret": "sD3v061gf8BxXgmxcHssasjdlkasjd87439284)@#(*"
},
"RedisHostName": "",
"RabbitMQ": {
"Host": "localhost",
"Username": "guest",

View File

@ -5,6 +5,7 @@
"Microsoft.AspNetCore": "Warning"
}
},
"RedisHostName": "",
"RabbitMQ": {
"Host": "localhost",
"Username": "guest",

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,35 @@
using System;
using System.Threading.Tasks;
using CleanArchitecture.IntegrationTests.Infrastructure;
using Xunit;
namespace CleanArchitecture.IntegrationTests.Fixtures;
public sealed class AccessorFixture : IAsyncLifetime
{
public static string TestRunDbName { get; } = $"CleanArchitecture-Integration-{Guid.NewGuid()}";
public async Task DisposeAsync()
{
var db = DatabaseAccessor.GetOrCreateAsync(TestRunDbName);
await db.DisposeAsync();
var redis = RedisAccessor.GetOrCreateAsync();
await redis.DisposeAsync();
var rabbit = RabbitmqAccessor.GetOrCreateAsync();
await rabbit.DisposeAsync();
}
public async Task InitializeAsync()
{
var db = DatabaseAccessor.GetOrCreateAsync(TestRunDbName);
await db.InitializeAsync();
var redis = RedisAccessor.GetOrCreateAsync();
await redis.InitializeAsync();
var rabbit = RabbitmqAccessor.GetOrCreateAsync();
await rabbit.InitializeAsync();
}
}

View File

@ -1,27 +1,28 @@
using System;
using System.Net.Http;
using System.Threading.Tasks;
using CleanArchitecture.Domain.Constants;
using CleanArchitecture.Domain.Entities;
using CleanArchitecture.Domain.Enums;
using CleanArchitecture.Infrastructure.Database;
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,
AccessorFixture.TestRunDbName);
ServerClient = Factory.CreateClient();
ServerClient.Timeout = TimeSpan.FromMinutes(5);
@ -29,17 +30,39 @@ public class TestFixtureBase
protected virtual void SeedTestData(ApplicationDbContext context)
{
context.Users.Add(new User(
}
private async Task PrepareDatabaseAsync()
{
await Factory.RespawnDatabaseAsync();
using var scope = Factory.Services.CreateScope();
await using var dbContext = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
dbContext.Tenants.Add(new Tenant(
Ids.Seed.TenantId,
"Admin Tenant"));
dbContext.Users.Add(new User(
Ids.Seed.UserId,
Ids.Seed.TenantId,
"admin@email.com",
"Admin",
"User",
"$2a$12$Blal/uiFIJdYsCLTMUik/egLbfg3XhbnxBC6Sb5IKz2ZYhiU/MzL2",
UserRole.Admin));
dbContext.Users.Add(new User(
TestAuthenticationOptions.TestUserId,
Ids.Seed.TenantId,
TestAuthenticationOptions.Email,
TestAuthenticationOptions.FirstName,
TestAuthenticationOptions.LastName,
// !Password123#
"$2a$12$Blal/uiFIJdYsCLTMUik/egLbfg3XhbnxBC6Sb5IKz2ZYhiU/MzL2",
TestAuthenticationOptions.Password,
UserRole.Admin));
context.SaveChanges();
SeedTestData(dbContext);
await dbContext.SaveChangesAsync();
}
protected virtual void RegisterCustomServicesHandler(
@ -48,4 +71,14 @@ public class TestFixtureBase
IServiceProvider scopedServices)
{
}
public async Task InitializeAsync()
{
await PrepareDatabaseAsync();
}
public Task DisposeAsync()
{
return Task.CompletedTask;
}
}

View File

@ -10,6 +10,7 @@ public sealed class TestAuthenticationOptions : AuthenticationSchemeOptions
public const string Email = "integration@tests.com";
public const string FirstName = "Integration";
public const string LastName = "Tests";
public const string Password = "$2a$12$Blal/uiFIJdYsCLTMUik/egLbfg3XhbnxBC6Sb5IKz2ZYhiU/MzL2";
public static Guid TestUserId = new("561e4300-94d6-4c3f-adf5-31c1bdbc64df");
public ClaimsIdentity Identity { get; } = new(

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,35 @@ public sealed class CleanArchitectureWebApplicationFactory : WebApplicationFacto
base.ConfigureWebHost(builder);
_connection.Open();
var configuration = new ConfigurationBuilder()
.Build();
builder.ConfigureAppConfiguration(configurationBuilder =>
{
configurationBuilder.AddEnvironmentVariables();
var dbAccessor = DatabaseAccessor.GetOrCreateAsync(_instanceDatabaseName);
var redisAccessor = RedisAccessor.GetOrCreateAsync();
var rabbitAccessor = RabbitmqAccessor.GetOrCreateAsync();
// Overwrite default connection strings
configurationBuilder.AddInMemoryCollection([
new KeyValuePair<string, string?>(
"ConnectionStrings:DefaultConnection",
dbAccessor.GetConnectionString()),
new KeyValuePair<string, string?>(
"RedisHostName",
redisAccessor.GetConnectionString()),
new KeyValuePair<string, string?>(
"RabbitMQ:Host",
rabbitAccessor.GetConnectionString())
]);
configuration = configurationBuilder.Build();
});
builder.ConfigureServices(services =>
{
services.SetupTestDatabase<ApplicationDbContext>(_connection);
services.SetupTestDatabase<EventStoreDbContext>(_connection);
services.SetupTestDatabase<DomainNotificationStoreDbContext>(_connection);
if (_addTestAuthentication)
{
services.AddAuthentication(options =>
@ -65,22 +83,37 @@ 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 dbAccessor = DatabaseAccessor.GetOrCreateAsync(_instanceDatabaseName);
dbAccessor.CreateDatabase(scopedServices);
applicationDbContext.EnsureMigrationsApplied();
var redisAccessor = RedisAccessor.GetOrCreateAsync();
redisAccessor.RegisterRedis(services, configuration);
var creator2 = (RelationalDatabaseCreator)storeDbContext.Database
.GetService<IRelationalDatabaseCreator>();
creator2.CreateTables();
var rabbitAccessor = RabbitmqAccessor.GetOrCreateAsync();
rabbitAccessor.RegisterRabbitmq(services, configuration);
var creator3 = (RelationalDatabaseCreator)domainStoreDbContext
.Database.GetService<IRelationalDatabaseCreator>();
creator3.CreateTables();
_addCustomSeedDataHandler?.Invoke(applicationDbContext);
_registerCustomServicesHandler?.Invoke(services, sp, scopedServices);
});
}
public async Task RespawnDatabaseAsync()
{
var dbAccessor = DatabaseAccessor.GetOrCreateAsync(_instanceDatabaseName);
await dbAccessor.RespawnDatabaseAsync();
var redisAccessor = RedisAccessor.GetOrCreateAsync();
redisAccessor.ResetRedis();
}
public override async ValueTask DisposeAsync()
{
var dbAccessor = DatabaseAccessor.GetOrCreateAsync(_instanceDatabaseName);
await dbAccessor.DisposeAsync();
var redisAccessor = RedisAccessor.GetOrCreateAsync();
await redisAccessor.DisposeAsync();
var rabbitAccessor = RabbitmqAccessor.GetOrCreateAsync();
await rabbitAccessor.DisposeAsync();
}
}

View File

@ -0,0 +1,126 @@
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 = "234#AD224fD#ss";
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()
{
// Reset the database to its original state
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,64 @@
using System.Collections.Concurrent;
using System.Linq;
using System.Threading.Tasks;
using CleanArchitecture.Domain.Rabbitmq;
using CleanArchitecture.Domain.Rabbitmq.Extensions;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Testcontainers.RabbitMq;
using RabbitMqConfiguration = CleanArchitecture.Domain.Rabbitmq.RabbitMqConfiguration;
namespace CleanArchitecture.IntegrationTests.Infrastructure;
public sealed class RabbitmqAccessor
{
private static readonly ConcurrentDictionary<string, RabbitmqAccessor> s_accessors = new();
private static readonly RabbitMqContainer s_rabbitContainer = new RabbitMqBuilder()
.WithPortBinding(5672)
.Build();
public async Task InitializeAsync()
{
await s_rabbitContainer.StartAsync();
}
public async ValueTask DisposeAsync()
{
await s_rabbitContainer.DisposeAsync();
}
public string GetConnectionString()
{
return s_rabbitContainer.GetConnectionString();
}
public void RegisterRabbitmq(IServiceCollection serviceCollection, IConfiguration configuration)
{
var rabbitService = serviceCollection.FirstOrDefault(x =>
x.ServiceType == typeof(RabbitMqHandler));
if (rabbitService != null)
{
serviceCollection.Remove(rabbitService);
}
var rabbitConfig = serviceCollection.FirstOrDefault(x =>
x.ServiceType == typeof(RabbitMqConfiguration));
if (rabbitConfig != null)
{
serviceCollection.Remove(rabbitConfig);
}
serviceCollection.AddRabbitMqHandler(
configuration,
"RabbitMQ");
}
public static RabbitmqAccessor GetOrCreateAsync()
{
return s_accessors.GetOrAdd("rabbimq", _ => new RabbitmqAccessor());
}
}

View File

@ -0,0 +1,76 @@
using System.Collections.Concurrent;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using StackExchange.Redis;
using Testcontainers.Redis;
namespace CleanArchitecture.IntegrationTests.Infrastructure;
public sealed class RedisAccessor
{
private static readonly ConcurrentDictionary<string, RedisAccessor> s_accessors = new();
private static readonly RedisContainer s_redisContainer = new RedisBuilder()
.WithPortBinding(6379)
.Build();
public async Task InitializeAsync()
{
await s_redisContainer.StartAsync();
}
public async ValueTask DisposeAsync()
{
await s_redisContainer.DisposeAsync();
}
public string GetConnectionString()
{
return s_redisContainer.GetConnectionString();
}
public void RegisterRedis(IServiceCollection serviceCollection, IConfiguration configuration)
{
var distributedCache = serviceCollection.FirstOrDefault(x =>
x.ServiceType == typeof(IDistributedCache));
if (distributedCache != null)
{
serviceCollection.Remove(distributedCache);
}
serviceCollection.AddStackExchangeRedisCache(options =>
{
options.Configuration = configuration["RedisHostName"];
options.InstanceName = "clean-architecture";
});
}
public void ResetRedis()
{
var redis = ConnectionMultiplexer.Connect(GetConnectionString());
var db = redis.GetDatabase();
var endpoints = redis.GetEndPoints();
foreach (var endpoint in endpoints)
{
var server = redis.GetServer(endpoint);
var keys = server.Keys();
foreach (var key in keys)
{
db.KeyDelete(key);
}
}
redis.Close();
}
public static RedisAccessor GetOrCreateAsync()
{
return s_accessors.GetOrAdd("redis", _ => new RedisAccessor());
}
}

View File

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

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;