From 2c7748d877836342caff35032bc5186ca9b399c9 Mon Sep 17 00:00:00 2001 From: alex289 Date: Wed, 24 Apr 2024 21:41:13 +0200 Subject: [PATCH] feat: Use TestContainers for integration tests --- .../CleanArchitecture.IntegrationTests.csproj | 7 +- ...ctionalTestsServiceCollectionExtensions.cs | 46 ------- .../Fixtures/DatabaseFixture.cs | 23 ++++ .../Fixtures/TestFixtureBase.cs | 40 ++++-- .../CleanArchitectureWebApplicationFactory.cs | 65 ++++----- .../Infrastructure/DatabaseAccessor.cs | 125 ++++++++++++++++++ .../IntegrationTestsCollection.cs | 10 ++ .../gRPC/GetTenantsByIdsTests.cs | 3 + .../gRPC/GetUsersByIdsTests.cs | 3 + 9 files changed, 230 insertions(+), 92 deletions(-) delete mode 100644 CleanArchitecture.IntegrationTests/Extensions/FunctionalTestsServiceCollectionExtensions.cs create mode 100644 CleanArchitecture.IntegrationTests/Fixtures/DatabaseFixture.cs create mode 100644 CleanArchitecture.IntegrationTests/Infrastructure/DatabaseAccessor.cs create mode 100644 CleanArchitecture.IntegrationTests/IntegrationTestsCollection.cs diff --git a/CleanArchitecture.IntegrationTests/CleanArchitecture.IntegrationTests.csproj b/CleanArchitecture.IntegrationTests/CleanArchitecture.IntegrationTests.csproj index 71a3270..992b24b 100644 --- a/CleanArchitecture.IntegrationTests/CleanArchitecture.IntegrationTests.csproj +++ b/CleanArchitecture.IntegrationTests/CleanArchitecture.IntegrationTests.csproj @@ -10,10 +10,13 @@ - - + + + + + diff --git a/CleanArchitecture.IntegrationTests/Extensions/FunctionalTestsServiceCollectionExtensions.cs b/CleanArchitecture.IntegrationTests/Extensions/FunctionalTestsServiceCollectionExtensions.cs deleted file mode 100644 index f3c9b08..0000000 --- a/CleanArchitecture.IntegrationTests/Extensions/FunctionalTestsServiceCollectionExtensions.cs +++ /dev/null @@ -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(this IServiceCollection services, - DbConnection connection) where TContext : DbContext - { - var descriptor = services.SingleOrDefault(d => d.ServiceType == typeof(DbContextOptions)); - if (descriptor is not null) - services.Remove(descriptor); - - services.AddScoped(p => - DbContextOptionsFactory( - p, - (_, options) => options - .ConfigureWarnings(b => b.Log(CoreEventId.ManyServiceProvidersCreatedWarning)) - .UseLazyLoadingProxies() - .UseSqlite(connection))); - - return services; - } - - private static DbContextOptions DbContextOptionsFactory( - IServiceProvider applicationServiceProvider, - Action optionsAction) - where TContext : DbContext - { - var builder = new DbContextOptionsBuilder( - new DbContextOptions(new Dictionary())); - - builder.UseApplicationServiceProvider(applicationServiceProvider); - - optionsAction.Invoke(applicationServiceProvider, builder); - - return builder.Options; - } -} \ No newline at end of file diff --git a/CleanArchitecture.IntegrationTests/Fixtures/DatabaseFixture.cs b/CleanArchitecture.IntegrationTests/Fixtures/DatabaseFixture.cs new file mode 100644 index 0000000..a6c9ee0 --- /dev/null +++ b/CleanArchitecture.IntegrationTests/Fixtures/DatabaseFixture.cs @@ -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(); + } +} \ No newline at end of file diff --git a/CleanArchitecture.IntegrationTests/Fixtures/TestFixtureBase.cs b/CleanArchitecture.IntegrationTests/Fixtures/TestFixtureBase.cs index 6a8d633..c193326 100644 --- a/CleanArchitecture.IntegrationTests/Fixtures/TestFixtureBase.cs +++ b/CleanArchitecture.IntegrationTests/Fixtures/TestFixtureBase.cs @@ -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 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(); + + 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; + } } \ No newline at end of file diff --git a/CleanArchitecture.IntegrationTests/Infrastructure/CleanArchitectureWebApplicationFactory.cs b/CleanArchitecture.IntegrationTests/Infrastructure/CleanArchitectureWebApplicationFactory.cs index da21af7..c599b7d 100644 --- a/CleanArchitecture.IntegrationTests/Infrastructure/CleanArchitectureWebApplicationFactory.cs +++ b/CleanArchitecture.IntegrationTests/Infrastructure/CleanArchitectureWebApplicationFactory.cs @@ -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( + "ConnectionStrings:DefaultConnection", + accessor.GetConnectionString()) + ]); + }); builder.ConfigureServices(services => { - services.SetupTestDatabase(_connection); - services.SetupTestDatabase(_connection); - services.SetupTestDatabase(_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(); - var storeDbContext = scopedServices.GetRequiredService(); - var domainStoreDbContext = scopedServices.GetRequiredService(); + var accessor = DatabaseAccessor.GetOrCreateAsync(_instanceDatabaseName); + var applicationDbContext = accessor.CreateDatabase(scopedServices); - applicationDbContext.EnsureMigrationsApplied(); - - var creator2 = (RelationalDatabaseCreator)storeDbContext.Database - .GetService(); - creator2.CreateTables(); - - var creator3 = (RelationalDatabaseCreator)domainStoreDbContext - .Database.GetService(); - 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(); + } } \ No newline at end of file diff --git a/CleanArchitecture.IntegrationTests/Infrastructure/DatabaseAccessor.cs b/CleanArchitecture.IntegrationTests/Infrastructure/DatabaseAccessor.cs new file mode 100644 index 0000000..20b6f74 --- /dev/null +++ b/CleanArchitecture.IntegrationTests/Infrastructure/DatabaseAccessor.cs @@ -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 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(); + + lock (_databaseCreationLock) + { + if (_databaseCreated) + { + return applicationDbContext; + } + + applicationDbContext.EnsureMigrationsApplied(); + + var eventsContext = scopedServices.GetRequiredService(); + eventsContext.EnsureMigrationsApplied(); + + var notificationsContext = scopedServices.GetRequiredService(); + 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]; + } +} \ No newline at end of file diff --git a/CleanArchitecture.IntegrationTests/IntegrationTestsCollection.cs b/CleanArchitecture.IntegrationTests/IntegrationTestsCollection.cs new file mode 100644 index 0000000..5fce773 --- /dev/null +++ b/CleanArchitecture.IntegrationTests/IntegrationTestsCollection.cs @@ -0,0 +1,10 @@ +using CleanArchitecture.IntegrationTests.Fixtures; +using Xunit; + +namespace CleanArchitecture.IntegrationTests; + +[CollectionDefinition("IntegrationTests", DisableParallelization = true)] +public sealed class IntegrationTestsCollection : + ICollectionFixture +{ +} \ No newline at end of file diff --git a/CleanArchitecture.IntegrationTests/gRPC/GetTenantsByIdsTests.cs b/CleanArchitecture.IntegrationTests/gRPC/GetTenantsByIdsTests.cs index d6c2e8b..bef8dbf 100644 --- a/CleanArchitecture.IntegrationTests/gRPC/GetTenantsByIdsTests.cs +++ b/CleanArchitecture.IntegrationTests/gRPC/GetTenantsByIdsTests.cs @@ -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 { private readonly GetTenantsByIdsTestFixture _fixture; diff --git a/CleanArchitecture.IntegrationTests/gRPC/GetUsersByIdsTests.cs b/CleanArchitecture.IntegrationTests/gRPC/GetUsersByIdsTests.cs index 6576493..2353300 100644 --- a/CleanArchitecture.IntegrationTests/gRPC/GetUsersByIdsTests.cs +++ b/CleanArchitecture.IntegrationTests/gRPC/GetUsersByIdsTests.cs @@ -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 { private readonly GetUsersByIdsTestFixture _fixture;