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));