From 4c7f38d57b0a626709d2452eb465de855f14ee1e Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Tue, 15 Sep 2026 22:11:19 +1000 Subject: [PATCH] Allow [SharedDb] and [PooledDb] on classes and assemblies, add [NewDb] [SharedDb] and [PooledDb] could only be applied to test methods. They can now also be applied to a test class or the assembly. The nearest attribute wins: method over class over assembly. [NewDb] opts a method or class out of a class or assembly attribute and uses a new database per test, which is still the default when no attribute applies. Attribute resolution moves from the four framework test bases into DbAttributeReader in EfLocalDb. More than one of the three attributes on the same method, class, or assembly throws, and every level is checked even when a nearer level decides. DbAttributeReaderTests covers each level, the overrides, inheritance and the conflicts. Each framework gets tests for class-level [PooledDb] with method-level [SharedDb] and [NewDb] overrides, and for the throw when both [PooledDb] and [SharedDb] are on a method. --- pages/ef-mstest-usage.md | 8 +- pages/ef-nunit-usage.md | 8 +- pages/ef-tunit-usage.md | 8 +- pages/ef-xunitv3-usage.md | 8 +- pages/mdsource/pooled-db.include.md | 6 +- pages/mdsource/shared-db.include.md | 2 + .../ClassLevelPooledDbTests.cs | 21 +++ .../PooledAndSharedDbTests.cs | 16 ++ src/EfLocalDb.MSTest/LocalDbTestBase.cs | 10 +- src/EfLocalDb.MSTest/NewDbAttribute.cs | 9 + src/EfLocalDb.MSTest/PooledDbAttribute.cs | 7 +- src/EfLocalDb.MSTest/SharedDbAttribute.cs | 10 +- .../ClassLevelPooledDbTests.cs | 22 +++ .../PooledAndSharedDbTests.cs | 17 ++ src/EfLocalDb.NUnit/EfLocalDb.NUnit.csproj | 1 + src/EfLocalDb.NUnit/LocalDbTestBase.cs | 10 +- .../ClassLevelPooledDbTests.cs | 20 ++ .../PooledAndSharedDbTests.cs | 16 ++ src/EfLocalDb.TUnit/EfLocalDb.TUnit.csproj | 1 + src/EfLocalDb.TUnit/LocalDbTestBase.cs | 10 +- src/EfLocalDb.Tests/DbAttributeReaderTests.cs | 174 ++++++++++++++++++ .../ClassLevelPooledDbTests.cs | 20 ++ .../PooledAndSharedDbTests.cs | 15 ++ .../EfLocalDb.Xunit.V3.csproj | 1 + src/EfLocalDb.Xunit.V3/LocalDbTestBase.cs | 10 +- src/EfLocalDb/DbAttributeReader.cs | 64 +++++++ src/EfLocalDb/DbMode.cs | 6 + 27 files changed, 460 insertions(+), 40 deletions(-) create mode 100644 src/EfLocalDb.MSTest.Tests/ClassLevelPooledDbTests.cs create mode 100644 src/EfLocalDb.MSTest.Tests/PooledAndSharedDbTests.cs create mode 100644 src/EfLocalDb.MSTest/NewDbAttribute.cs create mode 100644 src/EfLocalDb.NUnit.Tests/ClassLevelPooledDbTests.cs create mode 100644 src/EfLocalDb.NUnit.Tests/PooledAndSharedDbTests.cs create mode 100644 src/EfLocalDb.TUnit.Tests/ClassLevelPooledDbTests.cs create mode 100644 src/EfLocalDb.TUnit.Tests/PooledAndSharedDbTests.cs create mode 100644 src/EfLocalDb.Tests/DbAttributeReaderTests.cs create mode 100644 src/EfLocalDb.Xunit.V3.Tests/ClassLevelPooledDbTests.cs create mode 100644 src/EfLocalDb.Xunit.V3.Tests/PooledAndSharedDbTests.cs create mode 100644 src/EfLocalDb/DbAttributeReader.cs create mode 100644 src/EfLocalDb/DbMode.cs diff --git a/pages/ef-mstest-usage.md b/pages/ef-mstest-usage.md index 6f2ec4d6..3addc434 100644 --- a/pages/ef-mstest-usage.md +++ b/pages/ef-mstest-usage.md @@ -648,6 +648,8 @@ public async Task VerifyEntity_Queryable() Mark test methods with `[SharedDb]` to share a single database across all query-only tests. Instead of cloning the template for each test, a shared database is created once and reused. This eliminates per-test DB creation overhead for tests that only read data. +`[SharedDb]` can also be applied to a test class, or to the assembly with `[assembly: SharedDb]`. The nearest attribute wins: a method attribute overrides a class attribute, which overrides an assembly attribute. Mark a test method or class with `[NewDb]` to opt it out and create a database per test. Applying more than one of `[PooledDb]`, `[SharedDb]` and `[NewDb]` to the same method, class, or assembly throws. + The shared database is read-only and any write throws, not only `SaveChanges`: `ExecuteUpdate`, `ExecuteDelete`, `ExecuteSqlRaw` and hand-written commands are blocked too. Tests that need to write should use `[PooledDb]` instead. @@ -673,6 +675,8 @@ public class SharedDbTests : LocalDbTestBase Mark test methods with `[PooledDb]` to lease a database from a fixed pool instead of creating one per test. The pool is built once from the template, and each test leases a database for its duration, writes inside a transaction, and rolls that transaction back on release so the next test sees the template state again. +`[PooledDb]` can also be applied to a test class, or to the assembly with `[assembly: PooledDb]`. The nearest attribute wins: a method attribute overrides a class attribute, which overrides an assembly attribute. Mark a test method or class with `[NewDb]` to opt it out and create a database per test. Applying more than one of `[PooledDb]`, `[SharedDb]` and `[NewDb]` to the same method, class, or assembly throws. + Two costs disappear. The per-test file copy and attach is gone, and — usually the larger one — so is repeated query plan compilation: SQL Server keys the plan cache by database, so a database per test means every query is compiled afresh for every test and no plan is ever reused. A small pool lets those plans be reused for the rest of the run. Pool size is `LocalDbSettings.PoolSize`, configurable via the `LocalDBPoolSize` environment variable and defaulting to `Environment.ProcessorCount`. It bounds how many pooled tests run concurrently, since a database is leased to one test at a time. Set it to `1` to serialise pooled tests onto a single database. @@ -681,9 +685,9 @@ Not suited to every test: * Tests that need their changes committed, or that assert on state outside their own transaction. * Tests that assert on a timeline of changes. Inside one transaction every system-versioned temporal row shares the transaction start time, so a sequence of state changes collapses into a single instant. - * On failure the database cannot be inspected, since the transaction is rolled back. When debugging, temporarily remove the attribute. + * On failure the database cannot be inspected, since the transaction is rolled back. When debugging, temporarily remove the attribute, or mark the test `[NewDb]` if the attribute is on its class or assembly. -Those tests should be left to create a database per test. +Those tests should be left to create a database per test, marked `[NewDb]` if `[PooledDb]` is applied to their class or assembly. diff --git a/pages/ef-nunit-usage.md b/pages/ef-nunit-usage.md index f356eb0f..b8bc1b4e 100644 --- a/pages/ef-nunit-usage.md +++ b/pages/ef-nunit-usage.md @@ -657,6 +657,8 @@ public async Task VerifyEntity_Queryable() Mark test methods with `[SharedDb]` to share a single database across all query-only tests. Instead of cloning the template for each test, a shared database is created once and reused. This eliminates per-test DB creation overhead for tests that only read data. +`[SharedDb]` can also be applied to a test class, or to the assembly with `[assembly: SharedDb]`. The nearest attribute wins: a method attribute overrides a class attribute, which overrides an assembly attribute. Mark a test method or class with `[NewDb]` to opt it out and create a database per test. Applying more than one of `[PooledDb]`, `[SharedDb]` and `[NewDb]` to the same method, class, or assembly throws. + The shared database is read-only and any write throws, not only `SaveChanges`: `ExecuteUpdate`, `ExecuteDelete`, `ExecuteSqlRaw` and hand-written commands are blocked too. Tests that need to write should use `[PooledDb]` instead. @@ -683,6 +685,8 @@ public class SharedDbTests : Mark test methods with `[PooledDb]` to lease a database from a fixed pool instead of creating one per test. The pool is built once from the template, and each test leases a database for its duration, writes inside a transaction, and rolls that transaction back on release so the next test sees the template state again. +`[PooledDb]` can also be applied to a test class, or to the assembly with `[assembly: PooledDb]`. The nearest attribute wins: a method attribute overrides a class attribute, which overrides an assembly attribute. Mark a test method or class with `[NewDb]` to opt it out and create a database per test. Applying more than one of `[PooledDb]`, `[SharedDb]` and `[NewDb]` to the same method, class, or assembly throws. + Two costs disappear. The per-test file copy and attach is gone, and — usually the larger one — so is repeated query plan compilation: SQL Server keys the plan cache by database, so a database per test means every query is compiled afresh for every test and no plan is ever reused. A small pool lets those plans be reused for the rest of the run. Pool size is `LocalDbSettings.PoolSize`, configurable via the `LocalDBPoolSize` environment variable and defaulting to `Environment.ProcessorCount`. It bounds how many pooled tests run concurrently, since a database is leased to one test at a time. Set it to `1` to serialise pooled tests onto a single database. @@ -691,9 +695,9 @@ Not suited to every test: * Tests that need their changes committed, or that assert on state outside their own transaction. * Tests that assert on a timeline of changes. Inside one transaction every system-versioned temporal row shares the transaction start time, so a sequence of state changes collapses into a single instant. - * On failure the database cannot be inspected, since the transaction is rolled back. When debugging, temporarily remove the attribute. + * On failure the database cannot be inspected, since the transaction is rolled back. When debugging, temporarily remove the attribute, or mark the test `[NewDb]` if the attribute is on its class or assembly. -Those tests should be left to create a database per test. +Those tests should be left to create a database per test, marked `[NewDb]` if `[PooledDb]` is applied to their class or assembly. diff --git a/pages/ef-tunit-usage.md b/pages/ef-tunit-usage.md index 9af94708..3c857f7c 100644 --- a/pages/ef-tunit-usage.md +++ b/pages/ef-tunit-usage.md @@ -656,6 +656,8 @@ public async Task VerifyEntity_Queryable() Mark test methods with `[SharedDb]` to share a single database across all query-only tests. Instead of cloning the template for each test, a shared database is created once and reused. This eliminates per-test DB creation overhead for tests that only read data. +`[SharedDb]` can also be applied to a test class, or to the assembly with `[assembly: SharedDb]`. The nearest attribute wins: a method attribute overrides a class attribute, which overrides an assembly attribute. Mark a test method or class with `[NewDb]` to opt it out and create a database per test. Applying more than one of `[PooledDb]`, `[SharedDb]` and `[NewDb]` to the same method, class, or assembly throws. + The shared database is read-only and any write throws, not only `SaveChanges`: `ExecuteUpdate`, `ExecuteDelete`, `ExecuteSqlRaw` and hand-written commands are blocked too. Tests that need to write should use `[PooledDb]` instead. @@ -680,6 +682,8 @@ public class SharedDbTests : LocalDbTestBase Mark test methods with `[PooledDb]` to lease a database from a fixed pool instead of creating one per test. The pool is built once from the template, and each test leases a database for its duration, writes inside a transaction, and rolls that transaction back on release so the next test sees the template state again. +`[PooledDb]` can also be applied to a test class, or to the assembly with `[assembly: PooledDb]`. The nearest attribute wins: a method attribute overrides a class attribute, which overrides an assembly attribute. Mark a test method or class with `[NewDb]` to opt it out and create a database per test. Applying more than one of `[PooledDb]`, `[SharedDb]` and `[NewDb]` to the same method, class, or assembly throws. + Two costs disappear. The per-test file copy and attach is gone, and — usually the larger one — so is repeated query plan compilation: SQL Server keys the plan cache by database, so a database per test means every query is compiled afresh for every test and no plan is ever reused. A small pool lets those plans be reused for the rest of the run. Pool size is `LocalDbSettings.PoolSize`, configurable via the `LocalDBPoolSize` environment variable and defaulting to `Environment.ProcessorCount`. It bounds how many pooled tests run concurrently, since a database is leased to one test at a time. Set it to `1` to serialise pooled tests onto a single database. @@ -688,9 +692,9 @@ Not suited to every test: * Tests that need their changes committed, or that assert on state outside their own transaction. * Tests that assert on a timeline of changes. Inside one transaction every system-versioned temporal row shares the transaction start time, so a sequence of state changes collapses into a single instant. - * On failure the database cannot be inspected, since the transaction is rolled back. When debugging, temporarily remove the attribute. + * On failure the database cannot be inspected, since the transaction is rolled back. When debugging, temporarily remove the attribute, or mark the test `[NewDb]` if the attribute is on its class or assembly. -Those tests should be left to create a database per test. +Those tests should be left to create a database per test, marked `[NewDb]` if `[PooledDb]` is applied to their class or assembly. diff --git a/pages/ef-xunitv3-usage.md b/pages/ef-xunitv3-usage.md index d97c9d04..5d10f086 100644 --- a/pages/ef-xunitv3-usage.md +++ b/pages/ef-xunitv3-usage.md @@ -655,6 +655,8 @@ public async Task VerifyEntity_Queryable() Mark test methods with `[SharedDb]` to share a single database across all query-only tests. Instead of cloning the template for each test, a shared database is created once and reused. This eliminates per-test DB creation overhead for tests that only read data. +`[SharedDb]` can also be applied to a test class, or to the assembly with `[assembly: SharedDb]`. The nearest attribute wins: a method attribute overrides a class attribute, which overrides an assembly attribute. Mark a test method or class with `[NewDb]` to opt it out and create a database per test. Applying more than one of `[PooledDb]`, `[SharedDb]` and `[NewDb]` to the same method, class, or assembly throws. + The shared database is read-only and any write throws, not only `SaveChanges`: `ExecuteUpdate`, `ExecuteDelete`, `ExecuteSqlRaw` and hand-written commands are blocked too. Tests that need to write should use `[PooledDb]` instead. @@ -679,6 +681,8 @@ public class SharedDbTests : LocalDbTestBase Mark test methods with `[PooledDb]` to lease a database from a fixed pool instead of creating one per test. The pool is built once from the template, and each test leases a database for its duration, writes inside a transaction, and rolls that transaction back on release so the next test sees the template state again. +`[PooledDb]` can also be applied to a test class, or to the assembly with `[assembly: PooledDb]`. The nearest attribute wins: a method attribute overrides a class attribute, which overrides an assembly attribute. Mark a test method or class with `[NewDb]` to opt it out and create a database per test. Applying more than one of `[PooledDb]`, `[SharedDb]` and `[NewDb]` to the same method, class, or assembly throws. + Two costs disappear. The per-test file copy and attach is gone, and — usually the larger one — so is repeated query plan compilation: SQL Server keys the plan cache by database, so a database per test means every query is compiled afresh for every test and no plan is ever reused. A small pool lets those plans be reused for the rest of the run. Pool size is `LocalDbSettings.PoolSize`, configurable via the `LocalDBPoolSize` environment variable and defaulting to `Environment.ProcessorCount`. It bounds how many pooled tests run concurrently, since a database is leased to one test at a time. Set it to `1` to serialise pooled tests onto a single database. @@ -687,9 +691,9 @@ Not suited to every test: * Tests that need their changes committed, or that assert on state outside their own transaction. * Tests that assert on a timeline of changes. Inside one transaction every system-versioned temporal row shares the transaction start time, so a sequence of state changes collapses into a single instant. - * On failure the database cannot be inspected, since the transaction is rolled back. When debugging, temporarily remove the attribute. + * On failure the database cannot be inspected, since the transaction is rolled back. When debugging, temporarily remove the attribute, or mark the test `[NewDb]` if the attribute is on its class or assembly. -Those tests should be left to create a database per test. +Those tests should be left to create a database per test, marked `[NewDb]` if `[PooledDb]` is applied to their class or assembly. diff --git a/pages/mdsource/pooled-db.include.md b/pages/mdsource/pooled-db.include.md index 754d6859..6f254a2b 100644 --- a/pages/mdsource/pooled-db.include.md +++ b/pages/mdsource/pooled-db.include.md @@ -1,5 +1,7 @@ Mark test methods with `[PooledDb]` to lease a database from a fixed pool instead of creating one per test. The pool is built once from the template, and each test leases a database for its duration, writes inside a transaction, and rolls that transaction back on release so the next test sees the template state again. +`[PooledDb]` can also be applied to a test class, or to the assembly with `[assembly: PooledDb]`. The nearest attribute wins: a method attribute overrides a class attribute, which overrides an assembly attribute. Mark a test method or class with `[NewDb]` to opt it out and create a database per test. Applying more than one of `[PooledDb]`, `[SharedDb]` and `[NewDb]` to the same method, class, or assembly throws. + Two costs disappear. The per-test file copy and attach is gone, and — usually the larger one — so is repeated query plan compilation: SQL Server keys the plan cache by database, so a database per test means every query is compiled afresh for every test and no plan is ever reused. A small pool lets those plans be reused for the rest of the run. Pool size is `LocalDbSettings.PoolSize`, configurable via the `LocalDBPoolSize` environment variable and defaulting to `Environment.ProcessorCount`. It bounds how many pooled tests run concurrently, since a database is leased to one test at a time. Set it to `1` to serialise pooled tests onto a single database. @@ -8,6 +10,6 @@ Not suited to every test: * Tests that need their changes committed, or that assert on state outside their own transaction. * Tests that assert on a timeline of changes. Inside one transaction every system-versioned temporal row shares the transaction start time, so a sequence of state changes collapses into a single instant. - * On failure the database cannot be inspected, since the transaction is rolled back. When debugging, temporarily remove the attribute. + * On failure the database cannot be inspected, since the transaction is rolled back. When debugging, temporarily remove the attribute, or mark the test `[NewDb]` if the attribute is on its class or assembly. -Those tests should be left to create a database per test. +Those tests should be left to create a database per test, marked `[NewDb]` if `[PooledDb]` is applied to their class or assembly. diff --git a/pages/mdsource/shared-db.include.md b/pages/mdsource/shared-db.include.md index 070553ea..28fa790b 100644 --- a/pages/mdsource/shared-db.include.md +++ b/pages/mdsource/shared-db.include.md @@ -1,3 +1,5 @@ Mark test methods with `[SharedDb]` to share a single database across all query-only tests. Instead of cloning the template for each test, a shared database is created once and reused. This eliminates per-test DB creation overhead for tests that only read data. +`[SharedDb]` can also be applied to a test class, or to the assembly with `[assembly: SharedDb]`. The nearest attribute wins: a method attribute overrides a class attribute, which overrides an assembly attribute. Mark a test method or class with `[NewDb]` to opt it out and create a database per test. Applying more than one of `[PooledDb]`, `[SharedDb]` and `[NewDb]` to the same method, class, or assembly throws. + The shared database is read-only and any write throws, not only `SaveChanges`: `ExecuteUpdate`, `ExecuteDelete`, `ExecuteSqlRaw` and hand-written commands are blocked too. Tests that need to write should use `[PooledDb]` instead. diff --git a/src/EfLocalDb.MSTest.Tests/ClassLevelPooledDbTests.cs b/src/EfLocalDb.MSTest.Tests/ClassLevelPooledDbTests.cs new file mode 100644 index 00000000..1caf0faa --- /dev/null +++ b/src/EfLocalDb.MSTest.Tests/ClassLevelPooledDbTests.cs @@ -0,0 +1,21 @@ +[TestClass] +[PooledDb] +public class ClassLevelPooledDbTests : LocalDbTestBase +{ + [TestMethod] + public void UsesPooledDb() => + Assert.IsNotNull(Database.Transaction); + + [TestMethod] + [SharedDb] + public void MethodOverridesClass() => + Assert.AreEqual("Shared", Database.Name); + + [TestMethod] + [NewDb] + public void NewDbOptsOut() + { + Assert.IsNull(Database.Transaction); + Assert.AreNotEqual("Shared", Database.Name); + } +} diff --git a/src/EfLocalDb.MSTest.Tests/PooledAndSharedDbTests.cs b/src/EfLocalDb.MSTest.Tests/PooledAndSharedDbTests.cs new file mode 100644 index 00000000..ed109577 --- /dev/null +++ b/src/EfLocalDb.MSTest.Tests/PooledAndSharedDbTests.cs @@ -0,0 +1,16 @@ +[TestClass] +public class PooledAndSharedDbTests : LocalDbTestBase +{ + // SetUp would throw before the test body runs, so skip it here and + // invoke the base implementation from within the test. + public override Task SetUp() => Task.CompletedTask; + + [TestMethod] + [PooledDb] + [SharedDb] + public async Task Throws() + { + var exception = await Assert.ThrowsExactlyAsync(() => base.SetUp()); + Assert.AreEqual("[PooledDb], [SharedDb] and [NewDb] are mutually exclusive. Use only one on a test method.", exception.Message); + } +} diff --git a/src/EfLocalDb.MSTest/LocalDbTestBase.cs b/src/EfLocalDb.MSTest/LocalDbTestBase.cs index 20ca72b2..371cd0f3 100644 --- a/src/EfLocalDb.MSTest/LocalDbTestBase.cs +++ b/src/EfLocalDb.MSTest/LocalDbTestBase.cs @@ -48,13 +48,9 @@ public virtual Task SetUp() var methodInfo = GetType() .GetMethods(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) .First(_ => _.Name == TestContext.TestName && !_.IsGenericMethod); - isSharedDb = methodInfo.GetCustomAttribute() != null; - isPooledDb = methodInfo.GetCustomAttribute() != null; - - if (isPooledDb && isSharedDb) - { - throw new("[PooledDb] and [SharedDb] are mutually exclusive. Use only one on a test method."); - } + var mode = DbAttributeReader.Read(methodInfo, GetType()); + isSharedDb = mode == DbMode.Shared; + isPooledDb = mode == DbMode.Pooled; QueryFilter.Enable(); return Reset(); diff --git a/src/EfLocalDb.MSTest/NewDbAttribute.cs b/src/EfLocalDb.MSTest/NewDbAttribute.cs new file mode 100644 index 00000000..aa45184f --- /dev/null +++ b/src/EfLocalDb.MSTest/NewDbAttribute.cs @@ -0,0 +1,9 @@ +namespace EfLocalDb; + +/// +/// Runs the test against a new database built from the template. This is the default when no +/// attribute applies, so it is only needed to opt a method or class out of a +/// or applied to its class or assembly. +/// +[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)] +public sealed class NewDbAttribute : Attribute; diff --git a/src/EfLocalDb.MSTest/PooledDbAttribute.cs b/src/EfLocalDb.MSTest/PooledDbAttribute.cs index ff6b8053..8ff4272d 100644 --- a/src/EfLocalDb.MSTest/PooledDbAttribute.cs +++ b/src/EfLocalDb.MSTest/PooledDbAttribute.cs @@ -8,6 +8,11 @@ namespace EfLocalDb; /// concurrently. Not suited to tests that need their changes committed, or that assert on state /// outside their own transaction. /// +/// +/// Can be applied to a test method, a test class, or the assembly. The nearest wins, so a method +/// marked in a class uses a shared +/// database. Use to opt a method or class out. +/// /// -[AttributeUsage(AttributeTargets.Method)] +[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class | AttributeTargets.Assembly)] public sealed class PooledDbAttribute : Attribute; diff --git a/src/EfLocalDb.MSTest/SharedDbAttribute.cs b/src/EfLocalDb.MSTest/SharedDbAttribute.cs index 7e0dccfd..56d3725e 100644 --- a/src/EfLocalDb.MSTest/SharedDbAttribute.cs +++ b/src/EfLocalDb.MSTest/SharedDbAttribute.cs @@ -1,4 +1,12 @@ namespace EfLocalDb; -[AttributeUsage(AttributeTargets.Method)] +/// +/// Runs the test against a single read-only database shared by all tests. +/// +/// Can be applied to a test method, a test class, or the assembly. The nearest wins, so a method +/// marked in a class uses a pooled +/// database. Use to opt a method or class out. +/// +/// +[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class | AttributeTargets.Assembly)] public sealed class SharedDbAttribute : Attribute; diff --git a/src/EfLocalDb.NUnit.Tests/ClassLevelPooledDbTests.cs b/src/EfLocalDb.NUnit.Tests/ClassLevelPooledDbTests.cs new file mode 100644 index 00000000..d213b104 --- /dev/null +++ b/src/EfLocalDb.NUnit.Tests/ClassLevelPooledDbTests.cs @@ -0,0 +1,22 @@ +[TestFixture] +[PooledDb] +public class ClassLevelPooledDbTests : + LocalDbTestBase +{ + [Test] + public void UsesPooledDb() => + IsNotNull(Database.Transaction); + + [Test] + [SharedDb] + public void MethodOverridesClass() => + AreEqual("Shared", Database.Name); + + [Test] + [NewDb] + public void NewDbOptsOut() + { + IsNull(Database.Transaction); + AreNotEqual("Shared", Database.Name); + } +} diff --git a/src/EfLocalDb.NUnit.Tests/PooledAndSharedDbTests.cs b/src/EfLocalDb.NUnit.Tests/PooledAndSharedDbTests.cs new file mode 100644 index 00000000..ed920079 --- /dev/null +++ b/src/EfLocalDb.NUnit.Tests/PooledAndSharedDbTests.cs @@ -0,0 +1,17 @@ +[TestFixture] +public class PooledAndSharedDbTests : + LocalDbTestBase +{ + // SetUp would throw before the test body runs, so skip it here and + // invoke the base implementation from within the test. + public override Task SetUp() => Task.CompletedTask; + + [Test] + [PooledDb] + [SharedDb] + public void Throws() + { + var exception = ThrowsAsync(() => base.SetUp())!; + AreEqual("[PooledDb], [SharedDb] and [NewDb] are mutually exclusive. Use only one on a test method.", exception.Message); + } +} diff --git a/src/EfLocalDb.NUnit/EfLocalDb.NUnit.csproj b/src/EfLocalDb.NUnit/EfLocalDb.NUnit.csproj index ba2c4d06..41cad747 100644 --- a/src/EfLocalDb.NUnit/EfLocalDb.NUnit.csproj +++ b/src/EfLocalDb.NUnit/EfLocalDb.NUnit.csproj @@ -11,6 +11,7 @@ + diff --git a/src/EfLocalDb.NUnit/LocalDbTestBase.cs b/src/EfLocalDb.NUnit/LocalDbTestBase.cs index 92501675..20d22375 100644 --- a/src/EfLocalDb.NUnit/LocalDbTestBase.cs +++ b/src/EfLocalDb.NUnit/LocalDbTestBase.cs @@ -49,13 +49,9 @@ public virtual Task SetUp() #pragma warning disable CS0618 // Type or member is obsolete var methodInfo = test.Method!.MethodInfo; #pragma warning restore CS0618 // Type or member is obsolete - isSharedDb = methodInfo.GetCustomAttribute() != null; - isPooledDb = methodInfo.GetCustomAttribute() != null; - - if (isPooledDb && isSharedDb) - { - throw new("[PooledDb] and [SharedDb] are mutually exclusive. Use only one on a test method."); - } + var mode = DbAttributeReader.Read(methodInfo, GetType()); + isSharedDb = mode == DbMode.Shared; + isPooledDb = mode == DbMode.Pooled; QueryFilter.Enable(); return Reset(); diff --git a/src/EfLocalDb.TUnit.Tests/ClassLevelPooledDbTests.cs b/src/EfLocalDb.TUnit.Tests/ClassLevelPooledDbTests.cs new file mode 100644 index 00000000..dd3c6c34 --- /dev/null +++ b/src/EfLocalDb.TUnit.Tests/ClassLevelPooledDbTests.cs @@ -0,0 +1,20 @@ +[PooledDb] +public class ClassLevelPooledDbTests : LocalDbTestBase +{ + [Test] + public async Task UsesPooledDb() => + await Assert.That(Database.Transaction).IsNotNull(); + + [Test] + [SharedDb] + public async Task MethodOverridesClass() => + await Assert.That(Database.Name).IsEqualTo("Shared"); + + [Test] + [NewDb] + public async Task NewDbOptsOut() + { + await Assert.That(Database.Transaction).IsNull(); + await Assert.That(Database.Name).IsNotEqualTo("Shared"); + } +} diff --git a/src/EfLocalDb.TUnit.Tests/PooledAndSharedDbTests.cs b/src/EfLocalDb.TUnit.Tests/PooledAndSharedDbTests.cs new file mode 100644 index 00000000..ad8bc2cc --- /dev/null +++ b/src/EfLocalDb.TUnit.Tests/PooledAndSharedDbTests.cs @@ -0,0 +1,16 @@ +public class PooledAndSharedDbTests : LocalDbTestBase +{ + // SetUp would throw before the test body runs, so skip it here and + // invoke the base implementation from within the test. + public override Task SetUp() => Task.CompletedTask; + + [Test] + [PooledDb] + [SharedDb] + public async Task Throws() + { + var exception = (await Assert.ThrowsExactlyAsync(() => base.SetUp()))!; + await Assert.That(exception.Message) + .IsEqualTo("[PooledDb], [SharedDb] and [NewDb] are mutually exclusive. Use only one on a test method."); + } +} diff --git a/src/EfLocalDb.TUnit/EfLocalDb.TUnit.csproj b/src/EfLocalDb.TUnit/EfLocalDb.TUnit.csproj index bf7cec20..3a96fdab 100644 --- a/src/EfLocalDb.TUnit/EfLocalDb.TUnit.csproj +++ b/src/EfLocalDb.TUnit/EfLocalDb.TUnit.csproj @@ -11,6 +11,7 @@ + diff --git a/src/EfLocalDb.TUnit/LocalDbTestBase.cs b/src/EfLocalDb.TUnit/LocalDbTestBase.cs index acf0f61e..3f27eb46 100644 --- a/src/EfLocalDb.TUnit/LocalDbTestBase.cs +++ b/src/EfLocalDb.TUnit/LocalDbTestBase.cs @@ -48,13 +48,9 @@ public virtual Task SetUp() var methodInfo = testDetails.ClassType .GetMethods(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) .First(_ => _.Name == testDetails.MethodName && !_.IsGenericMethod); - isSharedDb = methodInfo.GetCustomAttribute() != null; - isPooledDb = methodInfo.GetCustomAttribute() != null; - - if (isPooledDb && isSharedDb) - { - throw new("[PooledDb] and [SharedDb] are mutually exclusive. Use only one on a test method."); - } + var mode = DbAttributeReader.Read(methodInfo, testDetails.ClassType); + isSharedDb = mode == DbMode.Shared; + isPooledDb = mode == DbMode.Pooled; // AsyncLocal values must be set in the Before hook and propagated via AddAsyncLocalValues CombinationCallback.SetInstance(this); diff --git a/src/EfLocalDb.Tests/DbAttributeReaderTests.cs b/src/EfLocalDb.Tests/DbAttributeReaderTests.cs new file mode 100644 index 00000000..3242899d --- /dev/null +++ b/src/EfLocalDb.Tests/DbAttributeReaderTests.cs @@ -0,0 +1,174 @@ +using System.Reflection.Emit; + +[TestFixture] +public class DbAttributeReaderTests +{ + [AttributeUsage(AttributeTargets.All)] + public sealed class SharedAttribute : Attribute; + + [AttributeUsage(AttributeTargets.All)] + public sealed class PooledAttribute : Attribute; + + [AttributeUsage(AttributeTargets.All)] + public sealed class NewAttribute : Attribute; + + public class Plain + { + public static void None() + { + } + + [Shared] + public static void Shared() + { + } + + [Pooled] + public static void Pooled() + { + } + + [Shared, Pooled] + public static void SharedAndPooled() + { + } + + [Pooled, New] + public static void PooledAndNew() + { + } + } + + [Shared] + public class SharedClass + { + public static void None() + { + } + + [Pooled] + public static void Pooled() + { + } + + [New] + public static void New() + { + } + } + + [New] + public class NewClass + { + public static void None() + { + } + + [Pooled] + public static void Pooled() + { + } + } + + [Shared, Pooled] + public class BothClass + { + [Pooled] + public static void Pooled() + { + } + } + + public class DerivedFromSharedClass : SharedClass; + + static Assembly noneAssembly = typeof(DbAttributeReaderTests).Assembly; + static Assembly pooledAssembly = BuildAssembly(typeof(PooledAttribute)); + static Assembly bothAssembly = BuildAssembly(typeof(SharedAttribute), typeof(PooledAttribute)); + + static Assembly BuildAssembly(params Type[] attributes) => + AssemblyBuilder.DefineDynamicAssembly( + new($"DbAttributeReaderTests_{Guid.NewGuid():N}"), + AssemblyBuilderAccess.Run, + attributes.Select(_ => new CustomAttributeBuilder(_.GetConstructor(Type.EmptyTypes)!, []))); + + static DbMode Read(string method, Assembly assembly) => + DbAttributeReader.Read( + typeof(T).GetMethod(method, BindingFlags.Public | BindingFlags.Static | BindingFlags.FlattenHierarchy)!, + typeof(T), + assembly); + + [Test] + public void NoAttributes() => + AreEqual(DbMode.New, Read("None", noneAssembly)); + + [Test] + public void MethodLevel() + { + AreEqual(DbMode.Shared, Read("Shared", noneAssembly)); + AreEqual(DbMode.Pooled, Read("Pooled", noneAssembly)); + } + + [Test] + public void ClassLevel() => + AreEqual(DbMode.Shared, Read("None", noneAssembly)); + + [Test] + public void ClassLevelIsInherited() => + AreEqual(DbMode.Shared, Read("None", noneAssembly)); + + [Test] + public void AssemblyLevel() => + AreEqual(DbMode.Pooled, Read("None", pooledAssembly)); + + [Test] + public void MethodOverridesClass() => + AreEqual(DbMode.Pooled, Read("Pooled", noneAssembly)); + + [Test] + public void MethodOverridesAssembly() => + AreEqual(DbMode.Shared, Read("Shared", pooledAssembly)); + + [Test] + public void ClassOverridesAssembly() => + AreEqual(DbMode.Shared, Read("None", pooledAssembly)); + + [Test] + public void NewMethodOptsOutOfClass() => + AreEqual(DbMode.New, Read("New", noneAssembly)); + + [Test] + public void NewClassOptsOutOfAssembly() => + AreEqual(DbMode.New, Read("None", pooledAssembly)); + + [Test] + public void MethodOverridesNewClass() => + AreEqual(DbMode.Pooled, Read("Pooled", noneAssembly)); + + [Test] + public void SharedAndPooledOnMethodThrows() + { + var exception = Throws(() => Read("SharedAndPooled", noneAssembly))!; + AreEqual("[PooledDb], [SharedDb] and [NewDb] are mutually exclusive. Use only one on a test method.", exception.Message); + } + + [Test] + public void PooledAndNewOnMethodThrows() + { + var exception = Throws(() => Read("PooledAndNew", noneAssembly))!; + AreEqual("[PooledDb], [SharedDb] and [NewDb] are mutually exclusive. Use only one on a test method.", exception.Message); + } + + [Test] + public void BothOnClassThrows() + { + var exception = Throws(() => Read("Pooled", noneAssembly))!; + AreEqual("[PooledDb], [SharedDb] and [NewDb] are mutually exclusive. Use only one on a test class.", exception.Message); + } + + [Test] + public void BothOnAssemblyThrows() + { + var exception = Throws(() => Read("Pooled", bothAssembly))!; + AreEqual("[PooledDb], [SharedDb] and [NewDb] are mutually exclusive. Use only one on an assembly.", exception.Message); + } +} diff --git a/src/EfLocalDb.Xunit.V3.Tests/ClassLevelPooledDbTests.cs b/src/EfLocalDb.Xunit.V3.Tests/ClassLevelPooledDbTests.cs new file mode 100644 index 00000000..0d11f1ff --- /dev/null +++ b/src/EfLocalDb.Xunit.V3.Tests/ClassLevelPooledDbTests.cs @@ -0,0 +1,20 @@ +[PooledDb] +public class ClassLevelPooledDbTests : LocalDbTestBase +{ + [Fact] + public void UsesPooledDb() => + Assert.NotNull(Database.Transaction); + + [Fact] + [SharedDb] + public void MethodOverridesClass() => + Assert.Equal("Shared", Database.Name); + + [Fact] + [NewDb] + public void NewDbOptsOut() + { + Assert.Null(Database.Transaction); + Assert.NotEqual("Shared", Database.Name); + } +} diff --git a/src/EfLocalDb.Xunit.V3.Tests/PooledAndSharedDbTests.cs b/src/EfLocalDb.Xunit.V3.Tests/PooledAndSharedDbTests.cs new file mode 100644 index 00000000..805926c2 --- /dev/null +++ b/src/EfLocalDb.Xunit.V3.Tests/PooledAndSharedDbTests.cs @@ -0,0 +1,15 @@ +public class PooledAndSharedDbTests : LocalDbTestBase +{ + // InitializeAsync would throw before the test body runs, so skip it here and + // invoke the base implementation from within the test. + public override ValueTask InitializeAsync() => ValueTask.CompletedTask; + + [Fact] + [PooledDb] + [SharedDb] + public async Task Throws() + { + var exception = await Assert.ThrowsAsync(() => base.InitializeAsync().AsTask()); + Assert.Equal("[PooledDb], [SharedDb] and [NewDb] are mutually exclusive. Use only one on a test method.", exception.Message); + } +} diff --git a/src/EfLocalDb.Xunit.V3/EfLocalDb.Xunit.V3.csproj b/src/EfLocalDb.Xunit.V3/EfLocalDb.Xunit.V3.csproj index 2bb2ff31..caa80ae8 100644 --- a/src/EfLocalDb.Xunit.V3/EfLocalDb.Xunit.V3.csproj +++ b/src/EfLocalDb.Xunit.V3/EfLocalDb.Xunit.V3.csproj @@ -11,6 +11,7 @@ + diff --git a/src/EfLocalDb.Xunit.V3/LocalDbTestBase.cs b/src/EfLocalDb.Xunit.V3/LocalDbTestBase.cs index d2c5e3fe..4e3517ad 100644 --- a/src/EfLocalDb.Xunit.V3/LocalDbTestBase.cs +++ b/src/EfLocalDb.Xunit.V3/LocalDbTestBase.cs @@ -44,13 +44,9 @@ public virtual async ValueTask InitializeAsync() } var methodInfo = GetCurrentMethodInfo(); - isSharedDb = methodInfo.GetCustomAttribute() != null; - isPooledDb = methodInfo.GetCustomAttribute() != null; - - if (isPooledDb && isSharedDb) - { - throw new("[PooledDb] and [SharedDb] are mutually exclusive. Use only one on a test method."); - } + var mode = DbAttributeReader.Read(methodInfo, GetType()); + isSharedDb = mode == DbMode.Shared; + isPooledDb = mode == DbMode.Pooled; QueryFilter.Enable(); await Reset(); diff --git a/src/EfLocalDb/DbAttributeReader.cs b/src/EfLocalDb/DbAttributeReader.cs new file mode 100644 index 00000000..a74a946a --- /dev/null +++ b/src/EfLocalDb/DbAttributeReader.cs @@ -0,0 +1,64 @@ +// Decides whether a LocalDbTestBase test runs against a new, shared, or pooled database. +// [NewDb], [SharedDb] and [PooledDb] can be applied to the test method or the test class, and +// [SharedDb] and [PooledDb] also to the assembly. The nearest wins, and a new database is the +// default. Every level is validated, so two attributes on one level throw even when a nearer level +// decides. The attribute types are type parameters because each test framework package compiles +// its own copy of them. +static class DbAttributeReader +{ + public static DbMode Read(MethodInfo method, Type type) + where TShared : Attribute + where TPooled : Attribute + where TNew : Attribute => + Read(method, type, type.Assembly); + + public static DbMode Read(MethodInfo method, Type type, Assembly assembly) + where TShared : Attribute + where TPooled : Attribute + where TNew : Attribute + { + var methodMode = ReadLevel( + method.GetCustomAttribute() != null, + method.GetCustomAttribute() != null, + method.GetCustomAttribute() != null, + "a test method"); + var classMode = ReadLevel( + type.GetCustomAttribute() != null, + type.GetCustomAttribute() != null, + type.GetCustomAttribute() != null, + "a test class"); + var assemblyMode = ReadLevel( + assembly.GetCustomAttribute() != null, + assembly.GetCustomAttribute() != null, + assembly.GetCustomAttribute() != null, + "an assembly"); + return methodMode ?? classMode ?? assemblyMode ?? DbMode.New; + } + + static DbMode? ReadLevel(bool isShared, bool isPooled, bool isNew, string target) + { + if ((isShared && isPooled) || + (isShared && isNew) || + (isPooled && isNew)) + { + throw new($"[PooledDb], [SharedDb] and [NewDb] are mutually exclusive. Use only one on {target}."); + } + + if (isShared) + { + return DbMode.Shared; + } + + if (isPooled) + { + return DbMode.Pooled; + } + + if (isNew) + { + return DbMode.New; + } + + return null; + } +} diff --git a/src/EfLocalDb/DbMode.cs b/src/EfLocalDb/DbMode.cs new file mode 100644 index 00000000..0b9198e6 --- /dev/null +++ b/src/EfLocalDb/DbMode.cs @@ -0,0 +1,6 @@ +enum DbMode +{ + New, + Shared, + Pooled +}