TestFramework.Mock
0.1.0
dotnet add package TestFramework.Mock --version 0.1.0
NuGet\Install-Package TestFramework.Mock -Version 0.1.0
<PackageReference Include="TestFramework.Mock" Version="0.1.0" />
<PackageVersion Include="TestFramework.Mock" Version="0.1.0" />
<PackageReference Include="TestFramework.Mock" />
paket add TestFramework.Mock --version 0.1.0
#r "nuget: TestFramework.Mock, 0.1.0"
#:package TestFramework.Mock@0.1.0
#addin nuget:?package=TestFramework.Mock&version=0.1.0
#tool nuget:?package=TestFramework.Mock&version=0.1.0
TestFramework.Mock
TestFramework.Mock is an extension package for TestFramework.Core.
It hosts the system under test in-process — your application's own service composition — with chosen dependencies replaced by declared test doubles, and drives it from a timeline. What the doubles record becomes run artifacts through Core's ordinary finder verb, and the frozen run says which doubles it ran against.
The public entry points are MockDefinition<TService> (a Mock-Pack), MockEnvironment, MockExt.Host
and MockArtifactFinder.
Install
dotnet add package TestFramework.Mock
Quick Start
A Mock-Pack declares one double, once:
using System.Threading.Tasks;
using TestFramework.Mock;
public sealed class MailSenderPack : MockDefinition<IMailSender>
{
protected override void Configure(MockBuilder<IMailSender> mock)
{
mock.Call(m => m.SendAsync(MockArg.Any<string>(), MockArg.Any<string>()))
.ReturnsAsync(true)
.ProducesArtifact((string to, string subject) => new("sentMail", $"{to}: {subject}"));
}
}
The environment takes the registrations production makes and puts the pack's double where the real dependency was registered. The timeline calls into the hosted service and finds what the double recorded:
using Microsoft.Extensions.DependencyInjection;
using TestFramework.Core.Timelines;
using TestFramework.Mock;
using TestFramework.Mock.Artifacts;
MockEnvironment environment = MockEnvironment.For(services =>
{
services.AddSingleton<IMailSender, SmtpMailSender>();
services.AddSingleton<SignupService>();
}).Include<MailSenderPack>();
Timeline timeline = Timeline.Create()
.Trigger(MockExt.Host((SignupService signup) => signup.RegisterAsync("ada@example.com"))).Name("register")
.FindArtifact("sentMail", new MockArtifactFinder("sentMail"))
.Build();
TimelineRun run = await timeline.SetupRun().SetEnv(environment).RunAsync();
run.EnsureRanToCompletion();
bool registered = run.MockResult<bool>("register");
int sent = run.Mock<IMailSender>().CountCalls(m => m.SendAsync("ada@example.com", MockArg.Any<string>()));
object? mail = run.ArtifactStore.GetMockArtifact("sentMail").Last.Payload; // "ada@example.com: Welcome"
SmtpMailSender is never constructed: the environment removes every registration of IMailSender
before it adds the double.
The Model
Mock is an environment like any other. Three pieces, each with one job:
| Piece | What it is | Lifetime |
|---|---|---|
Mock-Pack — MockDefinition<TService> |
one immutable declaration of a double: which calls it answers and how | declared once, instantiated per run |
MockEnvironment |
hosts your service composition with each included pack's double standing in | one host per run, frozen and disposed with it |
Timeline verbs — MockExt.Host, FindArtifact |
call the hosted system under test; bring what a double recorded into the run | steps of the timeline |
A double never writes into the run. It records what a call left behind in its environment — the way a
real dependency would leave a file or a row — and a timeline step brings that into the run with Core's
FindArtifact, exactly as it would find a file another program wrote. After the run, the doubles are
frozen: their call logs are a snapshot, and a late call is refused by name.
Writing A Mock-Pack
A setup names one call on the service. Arguments are matched by value, or by MockArg.Any<T>():
mock.Call(f => f.ReadText("config.json")).Returns("{}");
mock.Call(f => f.ReadText(MockArg.Any<string>())).Throws(new FileNotFoundException());
The second setup here would overlap the first for "config.json" — and a call that more than one setup
matches is refused naming them all. There is no precedence rule; narrow the matchers instead.
MockArg.Any<T>()matches any value ofT, and nothing of another type: on anobjectparameter,MockArg.Any<string>()does not answer anint. It stands only as a whole argument — inside a larger expression such asMockArg.Any<int>() + 1it is refused, because it would be read once as a fixed value.- An exact value is evaluated once, when the pack is instantiated. Arrays and lists compare by their
elements in order; everything else compares with
Equals.
| Verb | Meaning |
|---|---|
Returns(value) |
every matching call returns this value |
Returns((a, b) => ...) |
the result computed from the call's typed arguments |
Throws(exception) |
every matching call throws it |
ReturnsAsync(value) |
for a method returning Task<T> or ValueTask<T>: a task already completed with the value |
Completes() |
for a method returning Task or ValueTask: a task already completed |
ThrowsAsync(exception) |
for any task-returning method: the call returns a task that has already failed, as a real async method does |
Callback((a, b) => ...) |
a body for a void call; a void setup with no body simply does nothing |
Compute((a, b, artifacts) => ...) |
the primitive: the result plus artifacts.Publish(identity, payload) by hand |
ProducesArtifact((a, b) => new(identity, payload)) |
records a payload when the call completes; stackable |
The async verbs exist only on a setup for a task-returning method, so ReturnsAsync on a method returning
int does not compile. Prefer ThrowsAsync over Throws for an async method: Throws raises the
exception at the call itself, which no real async method does.
Typed lambdas exist for one to eight arguments. Their parameter types are checked against the mocked method when the pack is instantiated, so a mistyped lambda fails there, naming the method's signature — not at the first call.
The rules a pack is held to:
- Strict only. A call no setup matches is recorded, then refused naming the call and listing the setups that exist.
- A value is never invented. A setup for a method that returns something must state
Returns,ComputeorThrows, and states it once. - A declared artifact is what a completed call produced.
ProducesArtifactruns after the result, so a call that throws never publishes it. For an async call, completed means its task finished successfully: a task that later fails publishes nothing, andThrowsAsyncwithProducesArtifactis refused likeThrowsis. What aComputebody published by hand before throwing stands.Throwstogether withProducesArtifacton one setup is refused as unreachable. - Interfaces only. The service must be an interface; class mocking is not supported.
- Declared once. Every setup is sealed when the double is built. A setup object or builder kept past
Configurerefuses further changes, so nothing can alter a double that is already answering calls. - A fresh pack per run. The environment creates a new pack object for every run, so a field on a pack never carries one run's state into the next.
Hosting The System Under Test
MockEnvironment environment = MockEnvironment.For(services => services.AddMyApplication())
.Include<MailSenderPack>()
.Include<ClockPack>();
For takes the same registrations production makes, handed over rather than rebuilt, so the test hosts
what actually ships. For each included pack the environment removes every registration of the service —
the plain one, every keyed one, and so everything an IEnumerable<TService> would resolve — and puts the
run's double in each of those places, so nothing reaches the real dependency whichever way it asks.
- Including the same pack twice is allowed and has no effect. Two packs for the same service are refused naming both.
- The environment seals when the first run uses it: including another pack after that is refused, so every run hosts the same declaration.
- Every run gets its own host, doubles and call logs, so parallel runs never share state.
- Every service the composition registers is declared as a
mock.hostresource named by its full type name (an open generic registration by its definition's name), so aHoststep for a service nothing registers — or a run without the environment at all — is refused before its first step, naming what is registered. - The finished run records which pack stood where:
run.EffectiveSettingsholds kindmock.host, key = the service's full name — with[key]appended for each keyed registration it replaced — and value = the pack's type name. - At the end of the run the provider is disposed first and the doubles freeze after, so what the system under test does while being disposed — a flush, stopping a timer — still reaches its doubles.
- A run hosts one composition. Set the environment with
SetupRun(...).SetEnv(environment).
Calling The System Under Test
MockExt.Host resolves the service from the run's host and calls it. The hosted service arrives behind
the call's own arguments, which can be bound from run variables:
Timeline timeline = Timeline.Create()
.Trigger(MockExt.Host(
Var.Ref<string>("email"),
(string email, SignupService signup) => signup.RegisterAsync(email))).Name("register")
.Build();
TimelineRun run = await timeline.SetupRun()
.AddVariable("email", "grace@example.com")
.SetEnv(environment)
.RunAsync();
A call that returns
TaskorTask<T>is awaited: the step finishes when the call does, and its result is what the task yielded.asynclambdas work the same way.Any other awaitable — a
ValueTask, a task of a task — is refused when the timeline is built, because it would not be awaited. Call.AsTask()on aValueTask.A synchronous
voidmethod is called the same way:MockExt.Host((SignupService signup) => signup.Forget("ada@example.com")).Add a
CancellationTokenbehind the service to receive the step's own — cancelled when the step times out or the run is stopped — so a call that honours it stops with its step:.Trigger(MockExt.Host((SignupService signup, CancellationToken cancellation) => signup.RegisterAsync("ada@example.com", cancellation))).Name("register")Every call runs in a scope of its own, the way production opens one per request: scoped services are created fresh for the call and disposed when it ends, singletons are shared across the run. A result that still needs a scoped service after the call — a lazily loaded entity, say — is read inside the call.
run.MockResult<T>(label)reads what a labelledHoststep returned, withTthe call's own return type — for an awaited call, what its task yields.
Artifacts From Calls
ProducesArtifact and Publish record a payload under an identity in the environment. A later publish of
the same identity replaces it, as a second write to a file does. Nothing enters the run until a step
looks:
.FindArtifact("sentMail", new MockArtifactFinder("sentMail"))
Read it with run.ArtifactStore.GetMockArtifact("sentMail"). If nothing has published the identity yet,
the finder finds nothing and logs a warning, like any empty finder.
Taking A Later Look
A version is a look the timeline took, with Core's own verb:
Timeline timeline = Timeline.Create()
.Trigger(MockExt.Host((SignupService signup) => signup.RegisterAsync("ada@example.com"))).Name("first")
.FindArtifact("sentMail", new MockArtifactFinder("sentMail"))
.Trigger(MockExt.Host((SignupService signup) => signup.RegisterAsync("grace@example.com"))).Name("second")
.CaptureArtifactVersion("sentMail")
.Build();
The artifact now has two versions: the first mail and the second. Two publishes between two looks produce one version — the later payload. Every individual call stays in the double's call log.
Finding reads; it consumes nothing. A retried step, or a second look with nothing new, sees what the environment holds at that moment. Two doubles publishing the same identity are refused naming both services — give each its own identity.
Verifying Calls
MockInstance<IMailSender> mail = run.Mock<IMailSender>();
int count = mail.CountCalls(m => m.SendAsync(MockArg.Any<string>(), "Welcome"));
IReadOnlyList<RecordedCall> calls = mail.RecordedCalls; // method, arguments, matched, sequence
The call log holds every call in order, including the unmatched and ambiguous ones that were refused — the call nothing expected is usually the interesting one. It is frozen with the run.
Troubleshooting
No setup matches 'Method(...)' — the system under test made a call the pack does not declare. The
message lists the setups that do exist; add one, or widen a matcher.
'Method(...)' matches more than one setup — two setups overlap for this call. Narrow them so exactly
one answers.
Step '...' requires mock.host '...', and nothing in this run declares it — refused before the first
step. Either the run was started without SetEnv(MockEnvironment...), or the composition handed to
MockEnvironment.For(...) does not register the class the Host step calls. The message lists every
service that is registered; register the system under test itself, not only its dependencies.
The hosted call yields ValueTask<...> — return a Task so Host can await it.
The run is finished; the mock no longer accepts calls — something in the system under test kept
calling after the run ended, usually background work the test started. Make stopping it a step of the
timeline.
The full list, with when each one fires, is in Documentation/ERROR-HANDLING-MOCK.md.
Current Limits
- Interfaces only, and methods only — properties and events cannot be set up yet.
- Matchers are exact values and
MockArg.Any<T>(); there is no predicate matcher and no call sequences. - Recorded arguments and payloads are held by reference: an object the system under test changes after the call changes the record too. Publish a copy when that matters.
Target Frameworks
- .NET 8 (
net8.0) - .NET 10 (
net10.0)
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net8.0 is compatible. 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. net9.0 was computed. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- Castle.Core (>= 5.2.1)
- Microsoft.Extensions.DependencyInjection (>= 10.0.0)
- TestFramework.Core (>= 0.6.0)
-
net8.0
- Castle.Core (>= 5.2.1)
- Microsoft.Extensions.DependencyInjection (>= 10.0.0)
- TestFramework.Core (>= 0.6.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.0 | 29 | 9/30/2026 |
First release, on TestFramework.Core 0.6.0. Declare test doubles as Mock-Packs, host the application's own service composition in-process with MockEnvironment, call into it with MockExt.Host, and bring what the doubles recorded into the run with Core's FindArtifact and a MockArtifactFinder.