SkbKontur.NUnit.Middlewares 0.1.11

Prefix Reserved
dotnet add package SkbKontur.NUnit.Middlewares --version 0.1.11                
NuGet\Install-Package SkbKontur.NUnit.Middlewares -Version 0.1.11                
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="SkbKontur.NUnit.Middlewares" Version="0.1.11" />                
For projects that support PackageReference, copy this XML node into the project file to reference the package.
paket add SkbKontur.NUnit.Middlewares --version 0.1.11                
#r "nuget: SkbKontur.NUnit.Middlewares, 0.1.11"                
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
// Install SkbKontur.NUnit.Middlewares as a Cake Addin
#addin nuget:?package=SkbKontur.NUnit.Middlewares&version=0.1.11

// Install SkbKontur.NUnit.Middlewares as a Cake Tool
#tool nuget:?package=SkbKontur.NUnit.Middlewares&version=0.1.11                

NUnit.Middlewares

NuGet Status Build status

Use middleware pattern to write tests in concise and comprehensive manner. And ditch test bases.

Test setup middlewares

Inspired by ASP.NET Core middlewares, the main idea of test middlewares can be summarized by this image:

nunit-middlewares

Here we focus on behaviours that we want to add to our test rather than focusing on implementing test lifecycle methods provided by NUnit.

suite, fixture and test in the image above are just ISetupBuilder that can accept either raw setup functions or anything that implements simple ISetup interface:

setup-builder

Simple test base

To inject this new behaviour into our tests, we will use two simple base classes: SimpleSuiteBase and SimpleTestBase, our tests from first image can be set up as follows:

[SetUpFixture]
public class PlaywrightSuite : SimpleSuiteBase
{
  protected override void Configure(ISetupBuilder suite)
  {
    suite
      .UseHostingEnvironment()
      .UseSimpleContainer()
      .UseSetup<PlaywrightSetup>();
  }
}

public class BusinessObjectsSearchTests : SimpleTestBase
{
  [Injected] // Injected from container by `InitializeInjectedSetup`
  private readonly IUserProvider userProvider;

  protected override void Configure(ISetupBuilder fixture, ISetupBuilder test)
  {
    fixture
      .UseSetup<InitializeInjectedSetup>();

    test
      .UseSetup<BrowserPerTestSetup>();
  }

  [Test]
  public async Task BasicTest()
  {
    // every test gets its own browser, thus making tests easily parallelizable
    var browser = SimpleTestContext.Current.Get<Browser>();

    await browser.LoginAsync(userProvider.DefaultUser);
    await browser.Page.GotoAsync("https://google.com");
    await browser.Page.GetByTitle("Search").FillAsync("nunit");
  }
}

Composition over inheritance

With the power of C#'s extension methods, we can use composition of setups instead of relying on inheritance. For example, here's how setup for our container can be written:

public static class SetupExtensions
{
  public static ISetupBuilder UseSimpleContainer(
    this ISetupBuilder builder,
    Action<ContainerBuilder>? configure = null)
  {
    return builder
      // our container needs hosting environment, hence we should always set it up,
      // but if it was already set up earlier, we will use existing environment
      .UseSetup(new HostingEnvironmentSetup(setupOnlyIfNotExists: true))
      .UseSetup(new SimpleContainerSetup(configure));
  }
}

public class SimpleContainerSetup : ISetup
{
  private readonly Action<ContainerBuilder>? configure;

  public SimpleContainerSetup(Action<ContainerBuilder>? configure)
  {
    this.configure = configure;
  }

  public Task SetUpAsync(ITest test)
  {
    var environment = test.GetFromThisOrParentContext<IHostingEnvironment>();
    var container = ContainerFactory.NewContainer(environment, configure);
    test.Properties.Set(container); // save container to current test context

    return Task.CompletedTask;
  }

  public Task TearDownAsync(ITest test)
  {
    var container = test.Properties.Get<IContainer>();
    container.Dispose();

    return Task.CompletedTask;
  }
}

Using these building blocks, we can move all the complexity of setups to separate, smaller code pieces (ISetups), and make setups more reusable in the process.

Simple test context

In our BasicTest above we used SimpleTestContext.Current.Get<Browser>() to get browser that we set up in BrowserPerTestSetup. Also, in SimpleContainerSetup we used GetFromThisOrParentContext method that can access items that previous setups have set up. How does it work? Good news is that we can use built-in NUnit features to build such test context.

TestExecutionContext.CurrentContext.CurrentTest - current test, implements ITest

How do we get container/browser from suite context in our test? Every test has property IPropertyBag Properties.

Tests in NUnit are represented by a tree-like structure, and ITest has access to parent through ITest Parent property. Parent for test method is test fixture, parent for fixture is suite and so on.

That means we can search for context item of interest in parent, if not found - in parent's parent

To ensure everything is working as intended, parent's context items should be used as readonly

In our example from first image, test context will look something like this:

test-context

Both SimpleTestContext and GetFromThisOrParentContext are just ITest wrappers that search for context value in ITest's Properties recursively

Why are test bases a problem?

To make a point, let's try to rewrite test above without our testing machinery.

Let's start with BusinessObjectsSearchTests.cs:

public class BusinessObjectsSearchTests : PlaywrightTestBase
{
  [Injected]
  private readonly IUserProvider userProvider;

  [Test]
  public async Task BasicTest()
  {
    // every test gets its own browser, thus making tests easily parallelizable
    await using var browser = await BrowserPerTest();

    await browser.LoginAsync(userProvider.DefaultUser);
    await browser.Page.GotoAsync("https://google.com");
    await browser.Page.GetByTitle("Search").FillAsync("nunit");
  }
}

So far so good, notice that we moved BrowserPerTestSetup into the test itself. A neat trick that would be more difficult if we had more per test instances to set up.

PlaywrightTestBase looks simple enough. But we had to make our Browser IAsyncDisposable:

public class PlaywrightTestBase : SimpleContainerTestBase
{
  protected IPlaywright playwright;
  protected IBrowser browser;

  [OneTimeSetUp]
  public async Task SetUpPlaywright()
  {
    playwright = await Playwright.CreateAsync();
    browser = await playwright.Chromium.LaunchAsync()
  }

  [OneTimeTearDown]
  public async Task TearDownPlaywright()
  {
    await browser.DisposeAsync().ConfigureAwait(false);
    playwright.Dispose();
  }

  protected async Task<Browser> BrowserPerTest()
  {
    var page = await browser.NewPageAsync();
    return new Browser(page); // now Browser is responsible for disposing of page
  }
}

How deep does this rabbit hole go? Let's dive into SimpleContainerTestBase:

public class SimpleContainerTestBase
{
  protected IContainer container;

  [OneTimeSetUp]
  public void SetUpContainer()
  {
    var environment = HostingEnvironment.Create();
    container = ContainerFactory.NewContainer(environment, ConfigureContainer);
    ContainerFactory.InitializeInjectedFields(container, this);
  }

  [OneTimeTearDown]
  public void TearDownContainer()
  {
    container.Dispose();
  }

  protected virtual void ConfigureContainer(ContainerBuilder builder)
  {
  }
}

Now it doesn't look that bad. What did we miss? Quite a few things:

  • it was harder to setup items per test and keep tests parallelizable
  • to shorten chain of inheritance, we tightly integrated setup of HostingEnvironment and Container and forgot to dispose of hosting environment
  • we set up container and hosting environment for each test, before we only set it up once. Refactoring it can be a PITA, especially if container or browser field is referenced in our tests. On the other hand, when using nunit-middlewares, we can refactor such case by moving two lines of code.
  • what if many of our test fixtures need an organization to work with? would we make class OrganizationTestBase : PlaywrightTestBase? and if we need an organization, but don't need browser?
  • our example is rather simple, in more complex cases, our test bases can quickly become a nightmare to debug and extend

Excellent example of a complex case is playwright integration with nunit in official Playwright.NUnit package:

  • it has PageTest that inherits ContextTest that inherits BrowserTest that inherits PlaywrightTest that inherits WorkerAwareTest... whoa
Product Compatible and additional computed target framework versions.
.NET net5.0 was computed.  net5.0-windows was computed.  net6.0 was computed.  net6.0-android was computed.  net6.0-ios was computed.  net6.0-maccatalyst was computed.  net6.0-macos was computed.  net6.0-tvos was computed.  net6.0-windows was computed.  net7.0 was computed.  net7.0-android was computed.  net7.0-ios was computed.  net7.0-maccatalyst was computed.  net7.0-macos was computed.  net7.0-tvos was computed.  net7.0-windows was computed.  net8.0 was computed.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed. 
.NET Core netcoreapp2.0 was computed.  netcoreapp2.1 was computed.  netcoreapp2.2 was computed.  netcoreapp3.0 was computed.  netcoreapp3.1 was computed. 
.NET Standard netstandard2.0 is compatible.  netstandard2.1 was computed. 
.NET Framework net461 was computed.  net462 was computed.  net463 was computed.  net47 was computed.  net471 was computed.  net472 was computed.  net48 was computed.  net481 was computed. 
MonoAndroid monoandroid was computed. 
MonoMac monomac was computed. 
MonoTouch monotouch was computed. 
Tizen tizen40 was computed.  tizen60 was computed. 
Xamarin.iOS xamarinios was computed. 
Xamarin.Mac xamarinmac was computed. 
Xamarin.TVOS xamarintvos was computed. 
Xamarin.WatchOS xamarinwatchos was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.
  • .NETStandard 2.0

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last updated
0.1.11 1,947 4/17/2024
0.1.10 561 2/22/2024
0.1.9 139 2/21/2024
0.1.8 113 2/21/2024
0.1.8-pre1 103 2/21/2024
0.1.7 141 2/20/2024
0.1.3 109 2/8/2024
0.1.2 114 2/8/2024