Skip to content

Rmkrs/Library.UnitTesting

Repository files navigation

Library.UnitTesting

Build NuGet License: MIT

A lightweight, opinionated support library for writing clean unit tests in .NET.

The goal of this package is not to replace your test framework, assertion library, or mocking library. It provides a small set of helpers that keep common unit test setup readable, consistent, and low-noise.

Library.UnitTesting is intentionally boring in the places where tests should be boring.

What it uses

Library.UnitTesting is built around a pragmatic testing stack:

What it provides

  • A small UnitTestBase<TTarget> base class for shared unit test setup
  • AutoFixture-backed test data creation through Instantiator
  • ItExt helpers for expressive Moq argument matching
  • Explicit null guard policy assertions
  • Explicit async-void policy assertions

The important word is explicit.

Older versions of this library leaned more heavily into automatic policy checks from the test base class. Modern usage keeps policy checks local, intentional, and visible in the test that cares about them.

Installation

dotnet add package Library.UnitTesting

Or install it through the NuGet package manager in your IDE.

Basic usage

Given a service like this:

namespace SampleApp;

using System.Globalization;

using SampleApp.Contracts;

public class SampleService(IDateTimeProvider dateTimeProvider) : ISampleService
{
    private readonly IDateTimeProvider dateTimeProvider = dateTimeProvider ?? throw new ArgumentNullException(nameof(dateTimeProvider));

    public string Greet(string name)
    {
        ArgumentNullException.ThrowIfNull(name);

        var now = this.dateTimeProvider.Now;
        return string.Create(
            CultureInfo.InvariantCulture,
            $"Hello, {name}. It's {now:HH:mm}.");
    }
}

A test can focus on the behavior instead of repeated setup noise:

namespace SampleAppUnitTests;

using System.Globalization;

using Library.UnitTesting.Common;

using Moq;

using NUnit.Framework;

using SampleApp;
using SampleApp.Contracts;

using Shouldly;

[TestFixture]
public class SampleServiceTests : UnitTestBase<SampleService>
{
    private readonly Mock<IDateTimeProvider> dateTimeProviderMock = new();

    [Test]
    public void Greet_Valid_ReturnsCorrectResult()
    {
        // Arrange
        var name = this.Instantiator.Random<string>();
        var dateTime = this.Instantiator.Random<DateTime>();
        var expected = string.Create(
            CultureInfo.InvariantCulture,
            $"Hello, {name}. It's {dateTime:HH:mm}.");

        this.dateTimeProviderMock.Setup(x => x.Now).Returns(dateTime);

        var target = this.GetTarget();

        // Act
        var actual = target.Greet(name);

        // Assert
        actual.ShouldBe(expected);
    }

    protected override void Setup()
    {
        this.dateTimeProviderMock.Reset();
    }

    protected override SampleService GetTarget()
    {
        return new SampleService(this.dateTimeProviderMock.Object);
    }
}

UnitTestBase

UnitTestBase<TTarget> gives each test a fresh Instantiator and a virtual setup hook.

[TestFixture]
public class MyServiceTests : UnitTestBase<MyService>
{
    [Test]
    public void DoWork_ValidInput_ReturnsExpectedResult()
    {
        var input = this.Instantiator.Random<MyInput>();

        var target = this.GetTarget();

        var actual = target.DoWork(input);

        actual.ShouldNotBeNull();
    }

    protected override void Setup()
    {
        // Optional per-test setup.
    }

    protected override MyService GetTarget()
    {
        return new MyService();
    }
}

The base class deliberately does not auto-run policy tests. Policy checks should be explicit tests.

Test data

Use the fixture-backed instantiator when the actual values do not matter.

[Test]
public void Save_ValidMessage_StoresMessage()
{
    var message = this.Instantiator.Random<Message>();

    var target = this.GetTarget();

    target.Save(message);

    this.messageRepositoryMock
        .Verify(x => x.Save(message), Times.Once);
}

For many tests, this keeps the arrange phase small and avoids hand-building object trees that are irrelevant to the behavior under test.

You can also create multiple values:

var messages = this.Instantiator.Random<Message>(5);

And register custom creation functions:

this.Instantiator.RegisterCreationFunction(() => new CustomerId(42));

Random string helper

RandomString creates a random string with the requested length.

var value = this.Instantiator.RandomString(32);

Argument matching with ItExt

ItExt exists to make common Moq argument checks more readable.

this.messageRepositoryMock
    .Verify(x => x.Save(ItExt.IsEquivalent(expectedMessage)));

For date/time comparisons, use IsCloseTo:

this.clockMock
    .Verify(x => x.Schedule(ItExt.IsCloseTo(expectedTime, TimeSpan.FromSeconds(1))));

Use these helpers for clarity, not cleverness. If a plain It.Is<T> is clearer, use the plain Moq API.

Null guard policy tests

Null guard checks are useful, but they are also easy to overdo.

Prefer normal language features and simple implementation code first:

public SampleService(IDateTimeProvider dateTimeProvider)
{
    this.dateTimeProvider = dateTimeProvider ?? throw new ArgumentNullException(nameof(dateTimeProvider));
}

When a null guard policy test adds value, keep it explicit and close to the type being tested.

namespace SampleAppUnitTests;

using Library.UnitTesting.Common;
using Library.UnitTesting.Policy;

using Moq;

using NUnit.Framework;

using SampleApp;
using SampleApp.Contracts;

[TestFixture]
public class SampleServicePolicyTests : UnitTestBase<SampleService>
{
    private readonly Mock<IDateTimeProvider> dateTimeProviderMock = new();

    [Test]
    public void Constructor_NullDependencies_ShouldThrowArgumentNullException()
    {
        typeof(SampleService).ShouldGuardConstructorNulls();
    }

    [Test]
    public async Task PublicMethods_NullArguments_ShouldThrowArgumentNullException()
    {
        var target = this.GetTarget();

        await target.ShouldGuardMethodNullsAsync();
    }

    protected override void Setup()
    {
        this.dateTimeProviderMock.Reset();
    }

    protected override SampleService GetTarget()
    {
        return new SampleService(this.dateTimeProviderMock.Object);
    }
}

NullGuardAssertionOptions can be used when a type needs explicit exclusions or custom parameter creation.

var options = new NullGuardAssertionOptions();

options.ExcludeConstructorParameter("optionalDependency");
options.ExcludeMethod("LegacyMethod");
options.ExcludeMethodParameter("Search", "optionalFilter");
options.TypeCreationOverrides[typeof(CustomerId)] = () => new CustomerId(42);

typeof(MyService).ShouldGuardConstructorNulls(options);

Async void policy tests

async void should almost never appear in production code. It is difficult to await, difficult to observe, and very easy to break silently.

Library.UnitTesting provides explicit checks for this policy:

[Test]
public void Type_ShouldNotContainAsyncVoidMethods()
{
    typeof(SampleService).ShouldNotContainAsyncVoidMethods();
}

You can also check an entire assembly:

[Test]
public void Assembly_ShouldNotContainAsyncVoidMethods()
{
    typeof(SampleService).Assembly.ShouldNotContainAsyncVoidMethods();
}

Event-handler-shaped async void methods are ignored by this policy check.

Recommended test style

A good Library.UnitTesting test usually follows this shape:

[Test]
public void MethodName_StateUnderTest_ExpectedBehavior()
{
    // Arrange
    var input = this.Instantiator.Random<SomeInput>();

    this.someDependencyMock
        .Setup(x => x.GetValue(input.Id))
        .Returns("value");

    var target = this.GetTarget();

    // Act
    var actual = target.MethodName(input);

    // Assert
    actual.ShouldBe("value");

    this.someDependencyMock
        .Verify(x => x.GetValue(input.Id), Times.Once);
}

The preferred style is:

  • One behavior per test
  • Arrange only what matters
  • Prefer readable assertions
  • Prefer explicit mocks over hidden magic
  • Use generated data for irrelevant details
  • Avoid policy tests unless they are truly useful

Example project layout

A simple solution can be structured like this:

Source/
  Library.UnitTesting/
  SampleApp/

Tests/
  Unit/
    SampleAppUnitTests/

The sample app demonstrates usage. The unit test project demonstrates the intended testing style.

CI should normally discover only real test projects, for example projects ending in *UnitTests, so sample applications do not accidentally become part of the test run.

CI example

A minimal GitHub Actions workflow might look like this:

name: CI

on:
  push:
  pull_request:

jobs:
  build:
    runs-on: windows-latest

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-dotnet@v4
        with:
          dotnet-version: |
            8.0.x
            10.0.x

      - name: Restore
        run: dotnet restore

      - name: Build
        run: dotnet build --configuration Release --no-restore

      - name: Test
        run: dotnet test --configuration Release --no-build --filter "FullyQualifiedName~UnitTests"

Adjust the filter to match your solution conventions.

Package philosophy

Library.UnitTesting is intentionally small and opinionated.

It should help teams write tests that are:

  • Fast
  • Clear
  • Consistent
  • Easy to maintain
  • Low on ceremony
  • High on intent

It should not become a framework that hides what the test is doing.

When a helper makes the test easier to read, use it. When it makes the test harder to understand, remove the helper and write the test plainly.

Versioning notes

Version 2.0.0 changes the preferred assertion library from FluentAssertions to Shouldly and moves automatic policy checks out of UnitTestBase<TTarget> into explicit policy assertion methods.

When upgrading from 1.x, check for these common changes:

  • Replace FluentAssertions-style assertions in tests with Shouldly where desired
  • Replace automatic UnitTestBase<TTarget> policy checks with explicit policy tests
  • Use ShouldGuardConstructorNulls for constructor null guard checks
  • Use ShouldGuardMethodNullsAsync for method null guard checks
  • Use ShouldNotContainAsyncVoidMethods for async-void policy checks
  • Review usages of removed FluentAssertions-specific ItExt overloads

Contributing

Keep changes focused.

Good contributions usually make tests easier to read, reduce repeated setup, or clarify project conventions.

Avoid adding helpers that hide too much behavior. A test helper should feel like a labeled drawer, not a locked cabinet.

License

This project is licensed under the MIT License.

About

A lightweight, opinionated library for writing high-quality unit tests in .NET.

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages