Implementing a custom NUnit constraint

October 16th 2026 .NET Unit Testing

In the previous blog post I simplified complex test methods by refactoring the arrange and act code into reusable classes and methods. In this blog post I'll continue by refactoring the assert code.

This was the final state of the test I was simplifying:

[Test]
public async Task SlowResponseAfterPointOfNoReturnTest()
{
    var scenario = new SubmissionScenario(
        _application,
        TimeSpan.FromSeconds(0.6),
        TimeSpan.FromSeconds(0.6)
    );

    var submissionFromCreate = await scenario.CreateSubmissionAsync();
    Assert.That(scenario.LastResponseTimeMilliseconds, Is.InRange(1000, 1100));
    Assert.That(submissionFromCreate, Is.Not.Null);
    Assert.That(submissionFromCreate.Id, Is.Not.EqualTo(Guid.Empty));
    Assert.That(submissionFromCreate.Phase1CompletedAt, Is.Not.Null);
    Assert.That(submissionFromCreate.Phase2CompletedAt, Is.Null);

    await Task.Delay(TimeSpan.FromSeconds(1));

    var submissionFromGet = await scenario.GetSubmissionAsync();
    Assert.That(submissionFromGet, Is.Not.Null);
    Assert.That(submissionFromGet.Id, Is.EqualTo(submissionFromCreate.Id));
    Assert.That(submissionFromGet.Phase1CompletedAt, Is.Not.Null);
    Assert.That(submissionFromGet.Phase2CompletedAt, Is.Not.Null);
}

Notice the two very similar blocks of Assert.That calls. This is the part I'll be refactoring this time.

One possible approach would be a helper method with the repeated assert calls:

private static void AssertThatSubmissionIsValid(
    [NotNull] Submission? actual,
    bool isPhase2Completed
)
{
    Assert.That(actual, Is.Not.Null);
    using (Assert.EnterMultipleScope())
    {
        Assert.That(actual.Id, Is.Not.EqualTo(Guid.Empty));
        Assert.That(actual.Phase1CompletedAt, Is.Not.Null);
        Assert.That(
            actual.Phase2CompletedAt,
            isPhase2Completed ? Is.Not.Null : Is.Null
        );
    }
}

I added a parameter to support the two different asserts for actual.Phase2CompletedAt. By using this method, the test method could be simplified to:

[Test]
public async Task SlowResponseAfterPointOfNoReturnTest()
{
    var scenario = new SubmissionScenario(
        _application,
        TimeSpan.FromSeconds(0.6),
        TimeSpan.FromSeconds(0.6)
    );

    var submissionFromCreate = await scenario.CreateSubmissionAsync();
    Assert.That(scenario.LastResponseTimeMilliseconds, Is.InRange(1000, 1100));
    AssertThatSubmissionIsValid(submissionFromCreate, false);

    await Task.Delay(TimeSpan.FromSeconds(1));

    var submissionFromGet = await scenario.GetSubmissionAsync();
    AssertThatSubmissionIsValid(submissionFromGet, true);
    Assert.That(submissionFromGet.Id, Is.EqualTo(submissionFromCreate.Id));
}

As an added bonus, the asserts aren't repeated multiple times across the tests anymore. So if I ever needed to modify them, I could only do it in one place.

Pay attention also to the [NotNull] attribute on the actual parameter of the helper method. It informs the compiler that its value is not null after the method successfully completes and gets rid of a warning because of a possible null value in the next line of code.

But since I'm using NUnit, I can do even better. Its constraint model allows implementation of custom constraints. The core part of the assertion code ends up in a class derived from the Constraint base class, in the ApplyTo method override to be exact:

public override ConstraintResult ApplyTo<TActual>(TActual actual)
{
    if (actual is not Submission submission)
    {
        throw new ArgumentException("Actual value must be of type Submission.", nameof(actual));
    }

    var isIdValid = submission.Id != Guid.Empty;
    var isPhase1Completed = submission.Phase1CompletedAt != null;
    var isPhase2Completed = submission.Phase2CompletedAt != null;
    var isPhase2CompletedMatchesExpectation =
        _expectedIsPhase2Completed == null
        || isPhase2Completed == _expectedIsPhase2Completed;
    var isValid = isIdValid && isPhase1Completed && isPhase2CompletedMatchesExpectation;
    return new ConstraintResult(this, actual, isValid);
}

With this approach, I can't simply combine multiple Assert.That calls. I have to manually validate all the relevant values and decide whether the assertion passes or fails.

To set the _expectedIsPhase2Completed parameter I added two constraint modifiers:

public SubmissionIsValidConstraint WithPhase2Completed
{
    get
    {
        _expectedIsPhase2Completed = true;
        return this;
    }
}

public SubmissionIsValidConstraint WithoutPhase2Completed
{
    get
    {
        _expectedIsPhase2Completed = false;
        return this;
    }
}

Since they aren't parameterized themselves, I implemented them as properties instead of methods.

To be able to use this new constraint with the standard NUnit constraint model syntax, I need to implement two additional helper properties. They are a great fit for the extension members introduced in C# 14:

public static class SubmissionIsValidConstraintExtensions
{
    extension(Is)
    {
        public static SubmissionIsValidConstraint ValidSubmission => new();
    }

    extension(ConstraintExpression expression)
    {
        public SubmissionIsValidConstraint ValidSubmission =>
            expression.Append(new SubmissionIsValidConstraint());
    }
}

The first one is used with Is when its the first constraint for a value. The second one is used when using constraint modifiers such as Not or And.

With all of this code in place, the test method can be implemented with even nicer syntax (at least in my opinion):

public async Task SlowResponseAfterPointOfNoReturnTest()
{
    var scenario = new SubmissionScenario(
        _application,
        TimeSpan.FromSeconds(0.6),
        TimeSpan.FromSeconds(0.6)
    );

    var submissionFromCreate = await scenario.CreateSubmissionAsync();
    Assert.That(scenario.LastResponseTimeMilliseconds, Is.InRange(1000, 1100));
    Assert.That(submissionFromCreate, Is.ValidSubmission.WithoutPhase2Completed);

    await Task.Delay(TimeSpan.FromSeconds(1));

    var submissionFromGet = await scenario.GetSubmissionAsync();
    Assert.That(submissionFromGet, Is.ValidSubmission.WithPhase2Completed);
    Assert.That(submissionFromGet.Id, Is.EqualTo(submissionFromCreate.Id));
}

That's as nice and readable a syntax as I managed to achieve it. However, there is still place for improvement in another area. If the assertion fails, the current implementation emits the following default message:

Assert.That(submissionFromCreate, Is.ValidSubmission.WithPhase2Completed)
  Expected: submission is completed
  But was:  <Submission { Id = ffcaa5c8-766c-4faf-b0da-628ddce1920e, Phase1CompletedAt = 10/3/2026 4:41:00 PM, Phase2CompletedAt =  }>

It's not all that informative, right? Fortunately, I can do better by implementing a custom ConstraintResult class:

public class SubmissionIsValidConstraintResult(
    IConstraint constraint,
    object actualValue,
    bool isSuccess,
    bool isIdValid,
    bool isPhase1Completed,
    bool isPhase2Completed,
    bool? expectedIsPhase2Completed
) : ConstraintResult(constraint, actualValue, isSuccess)
{
    public override void WriteMessageTo(MessageWriter writer)
    {
        writer.WriteLine("Submission validation failed:");
        if (!isIdValid)
        {
            writer.WriteLine("  Id is invalid (Guid.Empty).");
        }
        if (!isPhase1Completed)
        {
            writer.WriteLine("  Phase 1 is not completed.");
        }
        if (expectedIsPhase2Completed.HasValue)
        {
            if (isPhase2Completed != expectedIsPhase2Completed.Value)
            {
                writer.WriteLine(
                    "  Phase 2 completion status does not match expectation."
                    + $" Expected: {expectedIsPhase2Completed.Value},"
                    + $" Actual: {isPhase2Completed}"
                );
            }
        }
    }
}

This allows me to create a fully custom message describing exactly why the assertion failed. In my case I wanted the message to clearly explain the exact reason for failure even for someone who is not fully familiar with the internals of the Submission class.

Of course, the ApplyTo method must now return this class instead of the default ConstraintResult:

return new SubmissionIsValidConstraintResult(
    this,
    actual,
    isValid,
    isIdValid,
    isPhase1Completed,
    isPhase2Completed,
    _expectedIsPhase2Completed
);

This makes the message generated for a failed assertion much clearer:

Assert.That(submissionFromCreate, Is.ValidSubmission.WithPhase2Completed)
Submission validation failed:
  Phase 2 completion status does not match expectation. Expected: True, Actual: False

Since I've only posted incomplete snippets of code in this blog post, I suggest you take a look in my GitHub repository with the full working project. You can even check individual commits as I transition from the starting to the final state in multiple steps as described here.

Implementing a custom NUnit constraint for a small project like this one is admittedly overkill. But in a larger project with even more tests doing the same type of asserts, the effort will soon be worth it. I believe that having tests that are easy to read and understand is very beneficial in the long run.

Get notified when a new blog post is published (usually every Friday):

Copyright
Creative Commons License