Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save NameOfTheDragon/e03c528933b6956fefda0df3585859d2 to your computer and use it in GitHub Desktop.

Select an option

Save NameOfTheDragon/e03c528933b6956fefda0df3585859d2 to your computer and use it in GitHub Desktop.
The Context-Builder pattern un MSpec unit specs

The Context-Builder Pattern in MSpec

Overview

The Context-Builder pattern is a structural testing pattern used with the MSpec BDD framework. Its central purpose is to keep spec classes (the things you read when understanding system behaviour) free of construction noise — the boilerplate of creating fakes, wiring them together, and assembling the subject under test — while still making that construction easy to follow in a dedicated place.

The pattern separates three concerns:

Participant Role
Context class Immutable snapshot of all state needed by a spec: the subject, its dependencies, any captured output.
Builder class Fluent factory that assembles a context. Encapsulates all construction detail and test-double setup.
Spec class(es) Pure behaviour description: EstablishBecauseIt. No construction noise.

The Problem It Solves

Consider a naïve MSpec spec written without this pattern. There is no Context class; every piece of state is declared as an individual static field directly in the spec class:

// ❌ Before — individual fields, inline construction, and try/catch all in one class
class when_printing_with_dymo_label_printer_and_the_underlying_printer_succeeds
{
    static IDymoPrint<DymoSpecModel> printerFake;
    static DymoLabelPrinter<DymoSpecModel> subject;
    static DymoSpecModel model;
    static string labelFileName;
    static Exception exception;

    Establish context = () =>
    {
        model         = new DymoSpecModel();
        labelFileName = "embedded-specimen.label";
        printerFake   = A.Fake<IDymoPrint<DymoSpecModel>>();

        A.CallTo(() => printerFake.PrintAsync(A<DymoSpecModel>.Ignored, A<string>.Ignored))
            .Returns(Task.CompletedTask);

        subject = new DymoLabelPrinter<DymoSpecModel>(printerFake);
    };

    Because of = () =>
    {
        try   { subject.Print(model, labelFileName); }
        catch (Exception ex) { exception = ex; }
    };

    It should_call_the_underlying_printer_once =
        () => A.CallTo(() => printerFake.PrintAsync(model, labelFileName))
              .MustHaveHappenedOnceExactly();

    It should_not_capture_an_exception = () => exception.ShouldBeNull();
}

Problems visible here:

  • Five static fields must be declared before any logic is written.
  • Establish mixes model creation, fake creation, fake configuration, and subject construction — four separate concerns.
  • Because must contain its own try/catch because there is nowhere else to put exception capture; this try/catch is duplicated in every spec class that needs to observe exception behaviour.
  • It delegates reference the raw printerFake field directly. If the fake is ever renamed or changed, every assertion must be updated.
  • When a second spec covers the fault scenario, all five fields and all the construction logic are duplicated in full with only the A.CallTo line changed.

Multiply this across a suite of specs and the maintenance burden becomes significant.


The Three Participants in Detail

1. The Context Class

The context class is a plain, immutable data container (a record-like object). It holds everything a spec might need to inspect after the action is performed.

public sealed class DymoLabelPrinterContext<TModel>
{
    public required IDymoPrint<TModel> PrinterFake { get; init; }  // exposed for assertions
    public required DymoLabelPrinter<TModel> Subject { get; init; } // the system under test
    public required TModel Model { get; init; }
    public string LabelFileName { get; init; } = "specimen.label";
    public Exception? Exception { get; set; }                       // captured by Because
}

Key design points:

  • required + init enforces that the builder, not the consumer, sets all mandatory properties.
  • Exception is the one mutable property — it is set by the Because clause (via an extension method) after the action is performed.
  • The fake (PrinterFake) is exposed deliberately so that It delegates can make FakeItEasy assertions against it without needing their own reference to the fake.
  • Properties are instance members, not static fields. In MSpec, only the reference to the context object needs to be static (static ... Context). The properties themselves live on a freshly constructed instance that is replaced by Establish on every spec run. This means there is no stale state to reason about: reassigning Context implicitly resets every property at once. With raw static fields in a spec class, every individual field must be explicitly reinitialised in Establish — forgetting even one is a latent, hard-to-diagnose bug where a spec silently inherits state from a previous run.

2. The Builder Class

The builder class accumulates configuration through fluent With* methods and materialises a fully-wired context when Build() is called.

public sealed class DymoLabelPrinterContextBuilder<TModel>
{
    private IDymoPrint<TModel>? printerFake;
    private TModel? model;
    private string labelFileName = "specimen.label";

    public DymoLabelPrinterContextBuilder<TModel> WithModel(TModel dataModel)
    {
        model = dataModel;
        return this;
    }

    public DymoLabelPrinterContextBuilder<TModel> WithLabelFileName(string fileName)
    {
        labelFileName = fileName;
        return this;
    }

    // Scenario method: the "happy path" — printer completes successfully
    public DymoLabelPrinterContextBuilder<TModel> WithPrinterSucceeding()
    {
        var fake = A.Fake<IDymoPrint<TModel>>();
        A.CallTo(() => fake.PrintAsync(A<TModel>.Ignored, A<string>.Ignored))
            .Returns(Task.CompletedTask);
        printerFake = fake;
        return this;
    }

    // Scenario method: the "sad path" — printer throws a given exception
    public DymoLabelPrinterContextBuilder<TModel> WithPrinterFailing(Exception exception)
    {
        var fake = A.Fake<IDymoPrint<TModel>>();
        A.CallTo(() => fake.PrintAsync(A<TModel>.Ignored, A<string>.Ignored))
            .Returns(Task.FromException(exception));
        printerFake = fake;
        return this;
    }

    public DymoLabelPrinterContext<TModel> Build()
    {
        var resolvedPrinter = printerFake ?? A.Fake<IDymoPrint<TModel>>();
        var resolvedModel   = model
            ?? throw new InvalidOperationException("A model must be configured for this context.");

        return new DymoLabelPrinterContext<TModel>
        {
            PrinterFake = resolvedPrinter,
            Subject     = new DymoLabelPrinter<TModel>(resolvedPrinter),
            Model       = resolvedModel,
            LabelFileName = labelFileName
        };
    }
}

// Optional static entry-point for readability
public static class DymoBuilder
{
    public static DymoLabelPrinterContextBuilder<TModel> ForLabelPrinter<TModel>() => new();
}

Notice that WithPrinterSucceeding and WithPrinterFailing are scenario methods — they combine fake creation and fake configuration into a single, intention-revealing call. The spec class never touches FakeItEasy's setup API directly.

3. The Spec Classes

With the builder doing all the heavy lifting, the spec classes become a pure description of behaviour:

// ✅ After — a single fluent statement per Establish
class when_printing_with_dymo_label_printer_and_the_underlying_printer_succeeds : with_a_dymo_label_printer
{
    Establish context = () => Context = Builder.WithPrinterSucceeding().Build();

    Because of = () => Context.Print();

    It should_call_the_underlying_printer_once =
        () => A.CallTo(() => Context.PrinterFake.PrintAsync(Context.Model, Context.LabelFileName))
              .MustHaveHappenedOnceExactly();

    It should_not_capture_an_exception = () => Context.Exception.ShouldBeNull();
}

class when_printing_with_dymo_label_printer_and_the_underlying_printer_faults : with_a_dymo_label_printer
{
    Establish context = () => Context = Builder
        .WithPrinterFailing(new InvalidOperationException("Printer communication failed."))
        .Build();

    Because of = () => Context.Print();

    It should_capture_the_underlying_invalid_operation_exception =
        () => Context.Exception.ShouldBeOfExactType<InvalidOperationException>();
}

The Establish clause is now a single statement that reads like a sentence: "Build a context with a printer that succeeds." No FakeItEasy setup code, no new expressions for the subject, no wiring logic.


Shared Context: The With_ Base Class

When multiple spec classes share the same base configuration, it is extracted into a with_{context_name} base class. MSpec runs Establish delegates from the outermost base class down to the most derived class, so the base initialises the builder and each derived class adds its scenario-specific step before calling Build().

// Base class: sets up the shared part of every DymoLabelPrinter context
class with_a_dymo_label_printer
{
    protected static DymoLabelPrinterContextBuilder<DymoSpecModel> Builder;
    protected static DymoLabelPrinterContext<DymoSpecModel> Context;

    Establish context = () => Builder = DymoBuilder.ForLabelPrinter<DymoSpecModel>()
        .WithModel(new DymoSpecModel())
        .WithLabelFileName("embedded-specimen.label");
}

Each derived spec class then adds only what makes it different:

class when_printing_... : with_a_dymo_label_printer
{
    // Establish runs AFTER the base Establish, so Builder is already initialised
    Establish context = () => Context = Builder.WithPrinterSucceeding().Build();
    ...
}

Execution order (MSpec):

  1. with_a_dymo_label_printer.Establish — initialises Builder with model + file name
  2. when_printing_....Establish — calls Builder.WithPrinterSucceeding().Build()
  3. Because of — performs the action
  4. Each It delegate — makes assertions

The Because Extension Method

Rather than duplicating the action call in every spec class, a static extension method captures both the invocation and any exception:

public static class DymoContextExtensions
{
    public static void Print<TModel>(this DymoLabelPrinterContext<TModel> context)
    {
        context.Exception = Catch.Exception(
            () => context.Subject.Print(context.Model, context.LabelFileName));
    }
}

This means the Because clause in every Dymo spec is always:

Because of = () => Context.Print();

One line. No duplication. Exceptions are captured into Context.Exception and are available to It delegates without any try/catch in the spec class.


Why the Pattern Is Useful

Readability at the spec level

The spec class is the document. When a developer (or a product owner reading a test report) scans a spec class, they should see only:

  • What conditions exist (Establish)
  • What action is performed (Because)
  • What the expected outcomes are (It)

Construction detail — fakes, DI setup, wiring — is irrelevant to that story. Hiding it in the builder restores the signal-to-noise ratio.

Scenario methods as a vocabulary

Builder methods like WithPrinterSucceeding() and WithPrinterFailing(...) form a domain-specific vocabulary for spec authors. Writing a new spec becomes a matter of choosing the right With* method rather than recalling FakeItEasy syntax. This lowers the barrier for adding new specs and reduces copy-paste errors.

Single point of change

If the DymoLabelPrinter<TModel> constructor signature changes, or if the FakeItEasy setup idiom needs updating, there is one place to fix: the builder. Without this pattern, that change would require touching every spec class that constructs the subject.

Shared context without repetition

The with_a_dymo_label_printer base class eliminates repetition across specs that share preconditions. Adding a new spec for the same subject requires only inheriting from the base and writing the scenario-specific Establish, Because, and It clauses.


Pros and Cons

Pros

# Benefit
1 Highly readable spec classes. Each spec reads as a concise narrative with no construction noise.
2 Single responsibility. The builder owns construction; the spec owns behaviour description.
3 DRY construction. Adding or changing a constructor parameter is a one-line fix in the builder.
4 Scenario vocabulary. WithPrinterSucceeding() is more expressive and less error-prone than inline FakeItEasy setup.
5 Easy extension. New scenarios require only a new With* method on the builder and a new spec class inheriting from the shared base.
6 Centralised exception capture. The Because extension method handles the try/catch once; specs never need it.
7 Supports inheritance naturally. MSpec's Establish inheritance model pairs perfectly with the with_a_* base class convention.
8 Automatic state reset between spec runs. Replacing the static Context reference in Establish resets all context state at once. Raw static fields require every individual field to be manually reinitialised; missing one causes silent state leakage from a previous spec run.

Cons

# Trade-off
1 Indirection. The construction detail is no longer visible in the spec file. Developers must navigate to the builder to understand exactly how the subject is wired. This is intentional, but it is a trade-off.
2 More files. Each SUT gets a context class, a builder class, and at least one spec file. For very simple units this may feel over-engineered.
3 Builder methods can grow stale. If a With* scenario method no longer reflects the real behaviour of the dependency it mocks, specs may pass for the wrong reasons. The scenario methods are themselves a form of documentation and must be kept accurate.
4 Generic builders add complexity. DymoLabelPrinterContextBuilder<TModel> works across data model types, but the generic constraint means Build() must guard against a null model at runtime rather than compile time.
5 MSpec-specific. The pattern is tightly coupled to MSpec's static-field and delegate conventions. It does not translate directly to xUnit or NUnit without significant adaptation.
6 Static state. All fields are static, as required by MSpec. This means care must be taken that base-class Establish fully re-initialises shared fields on each spec run to prevent state leakage between specs.

Naming Conventions

Element Convention Example
Spec classes when_{subject}_{condition} when_printing_with_dymo_label_printer_and_the_underlying_printer_faults
Shared base classes with_{context_name} with_a_dymo_label_printer
Context class {Subject}Context<TModel> DymoLabelPrinterContext<TModel>
Builder class {Subject}ContextBuilder<TModel> DymoLabelPrinterContextBuilder<TModel>
Static entry-point {Subject}Builder DymoBuilder
Builder scenario methods With{ScenarioDescription} WithPrinterSucceeding, WithPrinterFailing

File Organisation

TestHelpers/
    DymoContextBuilders.cs       ← Context class, builder class, entry-point, extensions

DymoConnectSpecs/
    DymoLabelPrinterSpecs.cs     ← with_a_dymo_label_printer base + all when_printing_ specs

The context/builder infrastructure lives in TestHelpers because it may be shared across multiple spec files. The spec classes and their shared base live together in a single file per subject area, making it easy to read all specs for a given feature without jumping between files.


Summary

The Context-Builder pattern moves test construction into a dedicated, fluent builder and exposes only a finished, immutable context to the spec class. The result is spec classes that read like behaviour specifications rather than construction instructions, a shared vocabulary of scenario methods that reduces copy-paste errors, and a single place to maintain wiring logic when the subject under test changes.

The key rule of thumb: if an Establish clause takes more than one statement, the extra work belongs in the builder.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment