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 +}