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: Establish → Because → It. No construction noise. |
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.
Establishmixes model creation, fake creation, fake configuration, and subject construction — four separate concerns.Becausemust 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.Itdelegates reference the rawprinterFakefield 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.CallToline changed.
Multiply this across a suite of specs and the maintenance burden becomes significant.
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+initenforces that the builder, not the consumer, sets all mandatory properties.Exceptionis the one mutable property — it is set by theBecauseclause (via an extension method) after the action is performed.- The fake (
PrinterFake) is exposed deliberately so thatItdelegates 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 byEstablishon every spec run. This means there is no stale state to reason about: reassigningContextimplicitly resets every property at once. With raw static fields in a spec class, every individual field must be explicitly reinitialised inEstablish— forgetting even one is a latent, hard-to-diagnose bug where a spec silently inherits state from a previous run.
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.
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.
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):
with_a_dymo_label_printer.Establish— initialisesBuilderwith model + file namewhen_printing_....Establish— callsBuilder.WithPrinterSucceeding().Build()Because of— performs the action- Each
Itdelegate — makes assertions
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.
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.
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.
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.
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.
| # | 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. |
| # | 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. |
| 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 |
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.
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.