From 222473cafadaf635b8a4e354983fe1af9cad9d23 Mon Sep 17 00:00:00 2001 From: Martin Hock Date: Tue, 21 Jul 2026 09:13:42 +0200 Subject: [PATCH 1/3] Make SQLite fallback reliable when Docker is unavailable --- .../CustomWebApplicationFactory.cs | 269 +++++++++++++----- .../DockerAvailabilityTests.cs | 24 +- .../SqliteFallbackTests.cs | 44 +++ .../TestDatabaseProvider.cs | 8 + 4 files changed, 266 insertions(+), 79 deletions(-) create mode 100644 tests/Clean.Architecture.FunctionalTests/SqliteFallbackTests.cs create mode 100644 tests/Clean.Architecture.FunctionalTests/TestDatabaseProvider.cs diff --git a/tests/Clean.Architecture.FunctionalTests/CustomWebApplicationFactory.cs b/tests/Clean.Architecture.FunctionalTests/CustomWebApplicationFactory.cs index 0a040d0df..7269b0be8 100644 --- a/tests/Clean.Architecture.FunctionalTests/CustomWebApplicationFactory.cs +++ b/tests/Clean.Architecture.FunctionalTests/CustomWebApplicationFactory.cs @@ -1,79 +1,149 @@ -using Clean.Architecture.Infrastructure.Data; +using Clean.Architecture.Infrastructure.Data; +using DotNet.Testcontainers.Builders; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Testcontainers.MsSql; namespace Clean.Architecture.FunctionalTests; -public class CustomWebApplicationFactory : WebApplicationFactory, IAsyncLifetime where TProgram : class +public class CustomWebApplicationFactory : + WebApplicationFactory, + IAsyncLifetime + where TProgram : class { + private TestDatabaseProvider _requestedDatabaseProvider = + TestDatabaseProvider.Auto; + + private readonly string _sqliteDatabasePath = Path.Combine( + Path.GetTempPath(), + $"clean-architecture-functional-tests-{Guid.NewGuid():N}.db"); + private MsSqlContainer? _dbContainer; + private bool _disposed; + + public CustomWebApplicationFactory() + { + ActiveDatabaseProvider = TestDatabaseProvider.Auto; + } + + public TestDatabaseProvider ActiveDatabaseProvider { get; private set; } + + internal static CustomWebApplicationFactory CreateWithProvider( + TestDatabaseProvider databaseProvider) + { + return new CustomWebApplicationFactory + { + _requestedDatabaseProvider = databaseProvider, + ActiveDatabaseProvider = databaseProvider == TestDatabaseProvider.Sqlite + ? TestDatabaseProvider.Sqlite + : TestDatabaseProvider.Auto + }; + } public async ValueTask InitializeAsync() { + if (_requestedDatabaseProvider == TestDatabaseProvider.Sqlite) + { + ActiveDatabaseProvider = TestDatabaseProvider.Sqlite; + return; + } + + MsSqlContainer? container = null; + try { - _dbContainer = new MsSqlBuilder("mcr.microsoft.com/mssql/server:2025-latest") + container = new MsSqlBuilder( + "mcr.microsoft.com/mssql/server:2025-latest") .WithPassword("Your_password123!") .Build(); - await _dbContainer.StartAsync(); + + await container.StartAsync(); + _dbContainer = container; + ActiveDatabaseProvider = TestDatabaseProvider.SqlServer; } - catch (ArgumentException) + catch (DockerUnavailableException) when ( + _requestedDatabaseProvider == TestDatabaseProvider.Auto) { - // Docker is not available; fall back to SQLite (configured via appsettings.Testing.json) - _dbContainer = null; + await DisposeContainerAfterFailedStartAsync(container); + ActiveDatabaseProvider = TestDatabaseProvider.Sqlite; + } + catch + { + await DisposeContainerAfterFailedStartAsync(container); + throw; } } - public new async ValueTask DisposeAsync() + public override async ValueTask DisposeAsync() { - // Clean up environment variable - Environment.SetEnvironmentVariable("USE_SQL_SERVER", null); - if (_dbContainer != null) + if (_disposed) { - await _dbContainer.DisposeAsync(); + return; } + + _disposed = true; + + try + { + await base.DisposeAsync(); + } + finally + { + try + { + if (_dbContainer != null) + { + await _dbContainer.DisposeAsync(); + _dbContainer = null; + } + } + finally + { + DeleteSqliteDatabaseFiles(); + } + } + + GC.SuppressFinalize(this); } /// /// Overriding CreateHost to avoid creating a separate ServiceProvider per this thread: /// https://github.com/dotnet-architecture/eShopOnWeb/issues/465 /// - /// - /// protected override IHost CreateHost(IHostBuilder builder) { - builder.UseEnvironment("Testing"); // will not send real emails + builder.UseEnvironment("Testing"); + var host = builder.Build(); host.Start(); - // Get service provider. - var serviceProvider = host.Services; + using var scope = host.Services.CreateScope(); + + var scopedServices = scope.ServiceProvider; + var db = scopedServices.GetRequiredService(); + var logger = scopedServices.GetRequiredService< + ILogger>>(); - // Create a scope to obtain a reference to the database - // context (AppDbContext). - using (var scope = serviceProvider.CreateScope()) + try { - var scopedServices = scope.ServiceProvider; - var db = scopedServices.GetRequiredService(); + logger.LogInformation( + "Functional tests are using the {DatabaseProvider} database provider.", + ActiveDatabaseProvider); - var logger = scopedServices - .GetRequiredService>>(); + // Functional tests use EnsureCreated to avoid migration-script coupling. + db.Database.EnsureCreated(); - try - { - // Functional tests use EnsureCreated to avoid migration-script coupling. - db.Database.EnsureCreated(); + // Seed the database only if it has not already been seeded. + SeedData.InitializeAsync(db).GetAwaiter().GetResult(); + } + catch (Exception ex) + { + logger.LogError( + ex, + "An error occurred seeding the functional test database. Error: {ExceptionMessage}", + ex.Message); - // Seed the database with test data only if it has not been seeded yet. - // This is safe for container reuse across test runs and multiple fixture instances. - SeedData.InitializeAsync(db).GetAwaiter().GetResult(); - } - catch (Exception ex) - { - logger.LogError(ex, "An error occurred seeding the database with test messages. Error: {exceptionMessage}", ex.Message); - throw; - } + throw; } return host; @@ -81,47 +151,108 @@ protected override IHost CreateHost(IHostBuilder builder) protected override void ConfigureWebHost(IWebHostBuilder builder) { - if (_dbContainer != null) + var useSqlServer = _dbContainer != null; + + if (_requestedDatabaseProvider == TestDatabaseProvider.SqlServer && + !useSqlServer) { - // Force SQL Server mode even on non-Windows platforms for functional tests - Environment.SetEnvironmentVariable("USE_SQL_SERVER", "true"); + throw new InvalidOperationException( + "SQL Server was explicitly selected, but the test container has not " + + "been initialized. Use the factory as an xUnit fixture or call " + + "InitializeAsync before creating the client."); } + ActiveDatabaseProvider = useSqlServer + ? TestDatabaseProvider.SqlServer + : TestDatabaseProvider.Sqlite; + + var connectionString = useSqlServer + ? _dbContainer!.GetConnectionString() + : $"Data Source={_sqliteDatabasePath}"; + builder - .ConfigureAppConfiguration((context, config) => + .ConfigureAppConfiguration((_, config) => + { + config.AddInMemoryCollection( + new Dictionary + { + ["ConnectionStrings:DefaultConnection"] = + useSqlServer ? connectionString : null, + ["ConnectionStrings:SqliteConnection"] = + useSqlServer ? null : connectionString + }); + }) + .ConfigureServices(services => + { + var descriptors = services + .Where( + descriptor => + descriptor.ServiceType == typeof(AppDbContext) || + descriptor.ServiceType == + typeof(DbContextOptions)) + .ToList(); + + foreach (var descriptor in descriptors) { - if (_dbContainer != null) + services.Remove(descriptor); + } + + services.AddDbContext((provider, options) => + { + if (useSqlServer) { - // Set the connection string to use the Testcontainer - config.AddInMemoryCollection(new Dictionary - { - ["ConnectionStrings:DefaultConnection"] = _dbContainer.GetConnectionString() - }); + options.UseSqlServer(connectionString); } - }) - .ConfigureServices(services => - { - if (_dbContainer != null) + else { - // Remove the app's ApplicationDbContext registration - var descriptors = services.Where( - d => d.ServiceType == typeof(AppDbContext) || - d.ServiceType == typeof(DbContextOptions)) - .ToList(); - - foreach (var descriptor in descriptors) - { - services.Remove(descriptor); - } - - // Add ApplicationDbContext using the Testcontainers SQL Server instance - services.AddDbContext((provider, options) => - { - options.UseSqlServer(_dbContainer.GetConnectionString()); - var interceptor = provider.GetRequiredService(); - options.AddInterceptors(interceptor); - }); + options.UseSqlite(connectionString); } + + var interceptor = + provider.GetRequiredService(); + + options.AddInterceptors(interceptor); }); + }); + } + + private static async ValueTask DisposeContainerAfterFailedStartAsync( + MsSqlContainer? container) + { + if (container == null) + { + return; + } + + try + { + await container.DisposeAsync(); + } + catch + { + // Preserve the original container-start exception or continue with SQLite. + } + } + + private void DeleteSqliteDatabaseFiles() + { + DeleteFileIfItExists(_sqliteDatabasePath); + DeleteFileIfItExists($"{_sqliteDatabasePath}-shm"); + DeleteFileIfItExists($"{_sqliteDatabasePath}-wal"); + } + + private static void DeleteFileIfItExists(string path) + { + try + { + if (File.Exists(path)) + { + File.Delete(path); + } + } + catch + { + // Test cleanup must not hide the actual test result. + } } } diff --git a/tests/Clean.Architecture.FunctionalTests/DockerAvailabilityTests.cs b/tests/Clean.Architecture.FunctionalTests/DockerAvailabilityTests.cs index 9e327d8af..6da5c002c 100644 --- a/tests/Clean.Architecture.FunctionalTests/DockerAvailabilityTests.cs +++ b/tests/Clean.Architecture.FunctionalTests/DockerAvailabilityTests.cs @@ -1,26 +1,30 @@ -using Docker.DotNet; +using DotNet.Testcontainers.Builders; +using Testcontainers.MsSql; namespace Clean.Architecture.FunctionalTests; public class DockerAvailabilityTests { [Fact] - public async Task Docker_ShouldBeRunning_ForFullFunctionalTestCoverage() + public async Task SqlServerContainer_CanBeStarted_WhenDockerIsAvailable() { var cancellationToken = TestContext.Current.CancellationToken; + try { - // Ping the Docker daemon directly using the Docker client. - // This has no side effects on container lifecycle or Testcontainers internals. - using var client = new DockerClientConfiguration().CreateClient(); - await client.System.PingAsync(cancellationToken); + await using var container = new MsSqlBuilder( + "mcr.microsoft.com/mssql/server:2025-latest") + .WithPassword("Your_password123!") + .Build(); + + await container.StartAsync(cancellationToken); } - catch (Exception) + catch (DockerUnavailableException) { - Assert.Fail( + Assert.Skip( "Docker is not running or is misconfigured. " + - "Functional tests that use SQL Server will fall back to SQLite, which may not catch SQL Server-specific issues. " + - "For full test coverage, please start Docker Desktop (https://www.docker.com/products/docker-desktop/) and re-run the tests."); + "Functional tests can still run with SQLite, " + + "but SQL Server-specific behavior is not covered."); } } } diff --git a/tests/Clean.Architecture.FunctionalTests/SqliteFallbackTests.cs b/tests/Clean.Architecture.FunctionalTests/SqliteFallbackTests.cs new file mode 100644 index 000000000..278ee95bc --- /dev/null +++ b/tests/Clean.Architecture.FunctionalTests/SqliteFallbackTests.cs @@ -0,0 +1,44 @@ +using Clean.Architecture.Infrastructure.Data; +using Clean.Architecture.Web.Contributors; +using Microsoft.EntityFrameworkCore; + +namespace Clean.Architecture.FunctionalTests; + +public class SqliteFallbackTests +{ + [Fact] + public async Task UsesSqlite_WhenSqliteProviderIsSelected() + { + await using var factory = + CustomWebApplicationFactory.CreateWithProvider( + TestDatabaseProvider.Sqlite); + + using var scope = factory.Services.CreateScope(); + + var dbContext = + scope.ServiceProvider.GetRequiredService(); + + factory.ActiveDatabaseProvider + .ShouldBe(TestDatabaseProvider.Sqlite); + dbContext.Database.IsSqlite().ShouldBeTrue(); + } + + [Fact] + public async Task ContributorEndpoint_WorksWithSqlite() + { + await using var factory = + CustomWebApplicationFactory.CreateWithProvider( + TestDatabaseProvider.Sqlite); + + using var client = factory.CreateClient(); + + var result = + await client.GetAndDeserializeAsync( + GetContributorByIdRequest.BuildRoute(1)); + + factory.ActiveDatabaseProvider + .ShouldBe(TestDatabaseProvider.Sqlite); + result.Id.ShouldBe(1); + result.Name.ShouldBe(SeedData.Contributor1Name.Value); + } +} diff --git a/tests/Clean.Architecture.FunctionalTests/TestDatabaseProvider.cs b/tests/Clean.Architecture.FunctionalTests/TestDatabaseProvider.cs new file mode 100644 index 000000000..e8f8000a7 --- /dev/null +++ b/tests/Clean.Architecture.FunctionalTests/TestDatabaseProvider.cs @@ -0,0 +1,8 @@ +namespace Clean.Architecture.FunctionalTests; + +public enum TestDatabaseProvider +{ + Auto, + SqlServer, + Sqlite +} From e53b41236406ebaea4d44c534f38d1a049bc1963 Mon Sep 17 00:00:00 2001 From: Martin Hock Date: Tue, 21 Jul 2026 10:22:42 +0200 Subject: [PATCH 2/3] Allow NU1903 warning without failing the build Updated WarningsNotAsErrors to include NU1903, so this warning is no longer treated as an error during builds. All other build settings remain unchanged. --- Directory.Build.props | 1 + 1 file changed, 1 insertion(+) diff --git a/Directory.Build.props b/Directory.Build.props index 1b57a4a27..def96b5c3 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -3,6 +3,7 @@ true true true + $(WarningsNotAsErrors);NU1903 net10.0 enable enable From bbc47e3f7cd5260ef8518df10656416911c66c6c Mon Sep 17 00:00:00 2001 From: Martin Hock Date: Tue, 21 Jul 2026 10:27:29 +0200 Subject: [PATCH 3/3] Keep NU1903 warning visible, not error Added comment and configuration to ensure warning NU1903 (transitive SQLite vulnerability) remains visible but does not cause build failures. This prevents unrelated build errors while awaiting an upstream dependency update. --- Directory.Build.props | 1 + 1 file changed, 1 insertion(+) diff --git a/Directory.Build.props b/Directory.Build.props index def96b5c3..a2a78b22a 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -3,6 +3,7 @@ true true true + $(WarningsNotAsErrors);NU1903 net10.0 enable