diff --git a/docs/articles/nunit/writing-tests/attributes/onetimesetup.md b/docs/articles/nunit/writing-tests/attributes/onetimesetup.md index 71bc59a84..68280edec 100644 --- a/docs/articles/nunit/writing-tests/attributes/onetimesetup.md +++ b/docs/articles/nunit/writing-tests/attributes/onetimesetup.md @@ -34,7 +34,8 @@ Methods may be **async** (`Task` / `Task`): NUnit will wait for completion li If `OneTimeSetUp` lives on an **abstract or concrete base test class**, NUnit invokes it **for every derived fixture type** that inherits that hierarchy (along with setup on the descendants). When you truly need execution **exactly once for many unrelated fixtures**, prefer **`SetUpFixture`** or **`static`** initialization instead of repeating base-class -`OneTimeSetUp` semantics. +`OneTimeSetUp` semantics. See [Sharing setup across projects](xref:attribute-setupfixture#sharing-setup-across-projects) +for the pattern to use when the base class lives in a shared package. ## Failures and exceptions diff --git a/docs/articles/nunit/writing-tests/attributes/setupfixture.md b/docs/articles/nunit/writing-tests/attributes/setupfixture.md index 13fc76950..2e6d8023d 100644 --- a/docs/articles/nunit/writing-tests/attributes/setupfixture.md +++ b/docs/articles/nunit/writing-tests/attributes/setupfixture.md @@ -67,6 +67,65 @@ The defined order is as follows... [Items 6 and 7 rarely come into play but the features are available for situations like code generation, where it may be more convenient to have multiple setup fixtures and/or methods.] +## Inheritance + +`[SetUpFixture]` can be inherited. A class that derives from a class marked `[SetUpFixture]` is itself a setup +fixture, even if it does not repeat the attribute. This lets a shared test-support package provide the one-time setup and +teardown in a base class, with each test project deriving from it: + +[!code-csharp[SetUpFixtureInheritanceBase](~/snippets/Snippets.NUnit/Attributes/SetUpFixtureAttributeExamples.cs#SetUpFixtureInheritanceBase)] + +[!code-csharp[SetUpFixtureInheritanceDerived](~/snippets/Snippets.NUnit/Attributes/SetUpFixtureAttributeExamples.cs#SetUpFixtureInheritanceDerived)] + +NUnit applies these rules: + +* **The namespace of the concrete class sets the scope.** NUnit builds the setup fixture from the concrete class, so it + covers the namespace that class is in and the namespaces below it. The namespace of the base class plays no part. In + the example, the setup runs once for the tests in `SetUpFixtureInheritance.Consumer`. +* **An abstract base class is not a setup fixture by itself.** NUnit ignores abstract classes when it looks for + fixtures, so the base class does nothing until a test project derives from it. A base class in another assembly is + never scanned anyway, because NUnit only looks at the types in the test assembly. If the base class is concrete and + lives in the test assembly, it is a setup fixture for its own namespace in addition to the derived class. +* **Attributes on the derived class win.** NUnit looks for fixture attributes one level of the inheritance chain at a + time, starting at the concrete class, and uses the first level that has any. An inherited `[SetUpFixture]` is only + used when the derived class has no fixture attribute of its own: + * If the derived class is marked `[TestFixture]`, NUnit builds an ordinary test fixture and the inherited + `[SetUpFixture]` is ignored. Its tests run, and the base class `[OneTimeSetUp]` runs once for that fixture, like + any inherited `[OneTimeSetUp]`. + * If the derived class has `[Test]` methods but no `[TestFixture]`, the inherited `[SetUpFixture]` wins. NUnit builds + a setup fixture and the tests are **not discovered**. There is no warning; the tests are simply missing from the + run, and from `dotnet test --list-tests`. + +[!code-csharp[SetUpFixtureInheritancePrecedence](~/snippets/Snippets.NUnit/Attributes/SetUpFixtureAttributeExamples.cs#SetUpFixtureInheritancePrecedence)] + +> [!TIP] +> Put `[SetUpFixture]` on the concrete class as well. It is not required, but it makes the role of the class visible +> without looking at the base class, and it is easier to spot a `[Test]` method that ended up in the wrong place. + +## Sharing setup across projects + +When several test projects share one-time setup from a common package, a base class with `[SetUpFixture]` works, but +each project still has to declare a derived class. A `[OneTimeSetUp]` on a shared base *test* class is not an +alternative: NUnit runs it once for **every fixture** that derives from it, not once per run. See +[OneTimeSetUp](xref:attribute-onetimesetup#test-fixture-versus-setup-fixture). + +The simplest pattern keeps the package free of NUnit fixture attributes. The package provides a plain class with the +setup and teardown logic: + +[!code-csharp[SetUpFixtureSharingPackage](~/snippets/Snippets.NUnit/Attributes/SetUpFixtureAttributeExamples.cs#SetUpFixtureSharingPackage)] + +Each test project declares a small `[SetUpFixture]` that calls it. Placed outside any namespace, it runs once for the +whole project: + +[!code-csharp[SetUpFixtureSharingConsumer](~/snippets/Snippets.NUnit/Attributes/SetUpFixtureAttributeExamples.cs#SetUpFixtureSharingConsumer)] + +If you cannot ask consumers to add a class, start the resource on first use from a static `Lazy` in the base test +class. The resource lives as long as the type stays loaded: `dotnet test` starts a fresh test host per run, so each +test project gets its own instance, but a host that keeps the assembly loaded between runs reuses it. NUnit offers no +hook to stop it at the end of the run, so use this only for resources the process can abandon: + +[!code-csharp[SetUpFixtureSharingLazy](~/snippets/Snippets.NUnit/Attributes/SetUpFixtureAttributeExamples.cs#SetUpFixtureSharingLazy)] + ## Notes 1. `SetUpFixture` classes must be public and have a default constructor (or be static). @@ -74,6 +133,7 @@ The defined order is as follows... 3. A setup fixture outside any namespace applies to the entire assembly. 4. Multiple setup fixtures at the same namespace level execute in indeterminate order. 5. Prior to NUnit 3.0, setup fixtures used `SetUp`/`TearDown`; NUnit 3+ uses `OneTimeSetUp`/`OneTimeTearDown`. +6. `SetUpFixture` can be inherited. The namespace of the derived class sets the scope; see [Inheritance](#inheritance). ## See Also diff --git a/docs/articles/nunit/writing-tests/setup-and-teardown.md b/docs/articles/nunit/writing-tests/setup-and-teardown.md index 785b39acc..c0ec6f148 100644 --- a/docs/articles/nunit/writing-tests/setup-and-teardown.md +++ b/docs/articles/nunit/writing-tests/setup-and-teardown.md @@ -58,6 +58,9 @@ child namespaces, and its `[OneTimeTearDown]` runs once after all of them. whole test assembly. - The class must be public and have a default constructor, or be static. - A setup fixture can only have `[OneTimeSetUp]` and `[OneTimeTearDown]` methods, not `[SetUp]` and `[TearDown]`. +- `[SetUpFixture]` can be inherited, and the namespace of the derived class decides what it covers. See + [Inheritance](xref:attribute-setupfixture#inheritance) on the reference page, which also shows how to share the + setup from a package used by several test projects. ## Which one should I use? diff --git a/docs/snippets/Snippets.NUnit/Attributes/SetUpFixtureAttributeExamples.cs b/docs/snippets/Snippets.NUnit/Attributes/SetUpFixtureAttributeExamples.cs index d8b0c50ce..0de194af9 100644 --- a/docs/snippets/Snippets.NUnit/Attributes/SetUpFixtureAttributeExamples.cs +++ b/docs/snippets/Snippets.NUnit/Attributes/SetUpFixtureAttributeExamples.cs @@ -1,4 +1,8 @@ using NUnit.Framework; +using NUnit.Framework.Api; +using NUnit.Framework.Interfaces; + +#pragma warning disable CA1822 namespace Snippets.NUnit.Attributes.SampleNamespace { @@ -18,3 +22,243 @@ public void RunAfterAnyTests() } #endregion } + +namespace Snippets.NUnit.Attributes.SetUpFixtureInheritance.SharedPackage +{ + #region SetUpFixtureInheritanceBase + // Lives in a shared test-support package. NUnit skips abstract classes, + // so this class is not a setup fixture by itself. + [SetUpFixture] + public abstract class SharedEnvironmentSetUpFixture + { + public static bool IsStarted { get; private set; } + + [OneTimeSetUp] + public void StartEnvironment() + { + IsStarted = true; + } + + [OneTimeTearDown] + public void StopEnvironment() + { + IsStarted = false; + } + } + #endregion +} + +namespace Snippets.NUnit.Attributes.SetUpFixtureInheritance.Consumer +{ + using Snippets.NUnit.Attributes.SetUpFixtureInheritance.SharedPackage; + + #region SetUpFixtureInheritanceDerived + // Inherits [SetUpFixture] from the base class, so NUnit treats it as a setup + // fixture for the namespace of *this* class: ...SetUpFixtureInheritance.Consumer, + // not the namespace of the base class. + public class ConsumerEnvironment : SharedEnvironmentSetUpFixture + { + } + + public class ConsumerTests + { + [Test] + public void Environment_IsStartedByInheritedSetUpFixture() + { + Assert.That(SharedEnvironmentSetUpFixture.IsStarted, Is.True); + } + } + #endregion +} + +namespace Snippets.NUnit.Attributes.SetUpFixtureInheritance.Precedence +{ + using Snippets.NUnit.Attributes.SetUpFixtureInheritance.SharedPackage; + + #region SetUpFixtureInheritancePrecedence + // [TestFixture] on the derived class wins over the inherited [SetUpFixture]. + // This is an ordinary test fixture: its tests are discovered and run, and the + // base class's [OneTimeSetUp] runs once for this fixture, like any inherited + // [OneTimeSetUp]. + [TestFixture] + public class DerivedTestFixture : SharedEnvironmentSetUpFixture + { + [Test] + public void ThisTestIsDiscovered() + { + Assert.That(IsStarted, Is.True); + } + } + + // No fixture attribute on the derived class, so the inherited [SetUpFixture] + // wins. NUnit builds this class as a setup fixture for this namespace and + // silently ignores its [Test] methods. + public class DerivedWithTestsOnly : SharedEnvironmentSetUpFixture + { + [Test] + public void ThisTestIsNotDiscovered() + { + Assert.Fail("NUnit never runs this test."); + } + } + #endregion +} + +namespace Snippets.NUnit.Attributes.SetUpFixtureInheritance +{ + // Verifies the rules documented on the SetUpFixture page by building the test + // tree for this assembly the same way the NUnit framework does at discovery time. + public class SetUpFixtureInheritanceVerification + { + private static ITest BuildTestTree() + { + var builder = new DefaultTestAssemblyBuilder(); + return builder.Build(typeof(SetUpFixtureInheritanceVerification).Assembly, new Dictionary()); + } + + private static ITest? Find(ITest root, Type type) + { + if (root.TypeInfo?.Type == type) + return root; + + foreach (var child in root.Tests) + { + var found = Find(child, type); + if (found is not null) + return found; + } + + return null; + } + + [Test] + public void AbstractBaseClass_IsNotASetUpFixtureByItself() + { + var tree = BuildTestTree(); + + Assert.That(Find(tree, typeof(SharedPackage.SharedEnvironmentSetUpFixture)), Is.Null); + } + + [Test] + public void ConcreteDerivedClass_IsASetUpFixture_ForItsOwnNamespace() + { + var tree = BuildTestTree(); + + var fixture = Find(tree, typeof(Consumer.ConsumerEnvironment)); + Assert.That(fixture, Is.Not.Null); + Assert.That(fixture.TestType, Is.EqualTo("SetUpFixture")); + Assert.That(fixture.Parent?.FullName, Is.EqualTo("Snippets.NUnit.Attributes.SetUpFixtureInheritance")); + Assert.That(Find(fixture, typeof(Consumer.ConsumerTests)), Is.Not.Null); + } + + [Test] + public void DerivedClassWithTestFixtureAttribute_IsATestFixture() + { + var tree = BuildTestTree(); + + var fixture = Find(tree, typeof(Precedence.DerivedTestFixture)); + Assert.That(fixture, Is.Not.Null); + Assert.That(fixture.TestType, Is.EqualTo("TestFixture")); + Assert.That(fixture.TestCaseCount, Is.EqualTo(1)); + } + + [Test] + public void DerivedClassWithOnlyTestMethods_IsASetUpFixture_AndItsTestsAreNotDiscovered() + { + var tree = BuildTestTree(); + + var fixture = Find(tree, typeof(Precedence.DerivedWithTestsOnly)); + Assert.That(fixture, Is.Not.Null); + Assert.That(fixture.TestType, Is.EqualTo("SetUpFixture")); + Assert.That(fixture.Tests.Select(t => t.Name), Does.Not.Contain(nameof(Precedence.DerivedWithTestsOnly.ThisTestIsNotDiscovered))); + } + } +} + +namespace Snippets.NUnit.Attributes.SetUpFixtureSharing.SharedPackage +{ + #region SetUpFixtureSharingPackage + // In the shared package: a plain class with no NUnit attributes. + public static class SharedTestEnvironment + { + public static bool IsStarted { get; private set; } + + public static void Start() + { + // Start containers, seed the database, ... + IsStarted = true; + } + + public static void Stop() + { + IsStarted = false; + } + } + #endregion +} + +namespace Snippets.NUnit.Attributes.SetUpFixtureSharing.TestProject +{ + using Snippets.NUnit.Attributes.SetUpFixtureSharing.SharedPackage; + + #region SetUpFixtureSharingConsumer + // In each test project that uses the package. Put it outside any namespace + // to cover the whole project. + [SetUpFixture] + public class SharedTestEnvironmentSetUp + { + [OneTimeSetUp] + public void Start() + { + SharedTestEnvironment.Start(); + } + + [OneTimeTearDown] + public void Stop() + { + SharedTestEnvironment.Stop(); + } + } + + public class OrderTests + { + [Test] + public void Environment_IsStartedOnceForTheProject() + { + Assert.That(SharedTestEnvironment.IsStarted, Is.True); + } + } + #endregion +} + +namespace Snippets.NUnit.Attributes.SetUpFixtureSharing.LazyAlternative +{ + public sealed class TestServer + { + public static TestServer Start() => new(); + + public string BaseAddress => "http://localhost:5000"; + } + + #region SetUpFixtureSharingLazy + // In the shared package: start the resource the first time any test needs it. + // Lazy starts it once per process that loads this type, and nothing stops + // it at the end of the run. + public abstract class SharedServerTestBase + { + private static readonly Lazy Server = new(TestServer.Start); + + protected static TestServer SharedServer => Server.Value; + } + + // In a test project: no setup fixture needed. + public class CustomerTests : SharedServerTestBase + { + [Test] + public void Server_IsAvailable() + { + Assert.That(SharedServer.BaseAddress, Does.StartWith("http://")); + } + } + #endregion +}