TUnit.Mocks: No Castle, No Reflection, No Drama
Open any .NET test project started in the last two years and the mocking line in the .csproj usually reads NSubstitute. Three years ago, it read Moq. The flip happened fast, and most of the industry has not gone back. What both share, however, is the same architectural foundation: Castle.DynamicProxy, runtime IL (Intermediate Language) emission, expression-tree-driven dispatch. The libraries differ in API ergonomics. They are identical underneath.
That foundation is starting to crack.
NativeAOT does not allow runtime IL generation. Trimming strips members that Castle later needs. Cold-start benchmarks penalize the first-call reflection cost. And the diagnostics (when a setup matches nothing) surface at the first invocation, deep in test output, not at compile time where they belong.
TUnit.Mocks is the response. It’s a standalone, source-generated mocking library shipping with the TUnit family. It depends on no test framework, runs against xUnit, NUnit, MSTest, or TUnit itself, and answers a question that’s been sitting unasked for years: what would mocking look like if you started over today, with Roslyn analyzers, source generators, and AOT as the baseline assumption?
The official documentation is at tunit.dev/docs/writing-tests/mocking/. Everything that follows is verified against the docs and the source in thomhurst/TUnit/TUnit.Mocks.
The Red Corner
Moq is not the comparison axis anymore. In August 2023 it shipped a silent SponsorLink dependency that hashed and transmitted developer emails on every build. Trust never recovered. NSubstitute became the default. That’s the baseline. Moving on.
The Problem NSubstitute Inherits
NSubstitute does not have a SponsorLink problem. It has a different problem, and it shares it with every other library that depends on Castle.DynamicProxy.
AOT (Ahead-of-Time Compilation) compatibility. Castle.DynamicProxy calls System.Reflection.Emit to materialize proxy types at runtime. NativeAOT and any sufficiently aggressive trimming pass both refuse. The workaround is to keep the JIT alive, which defeats half the reason to ship AOT in the first place.
Expression-tree fragility. NSubstitute parses sub.GetUser(Arg.Any<int>()).Returns(user) using Castle-generated proxies that intercept the method call and walk the call site. When the method involves generic constraints, ref structs, in-parameters, or default interface methods, the interception either fails outright or silently produces a setup that matches nothing. The error, if it appears at all, surfaces during the first invocation.
Cold-start cost. Each first substitute of a type pays a one-time IL emission cost, and every .Returns() allocates and resolves call specifications at runtime. On a CI cold cache, with thousands of tests, this adds real wall time. Not catastrophic, but visible.
Static abstract members. Interfaces with static abstract members (an increasingly common .NET pattern since C# 11) trigger CS8920 when passed as a generic type parameter. Substitute.For<IFoo>() refuses to compile if IFoo has any. The library has no escape hatch.
None of these are dealbreakers individually. Stacked together, they are the reason NSubstitute keeps showing up on issue trackers as a candidate for the next step.
One Caveat Before We Start: C# 14 / .NET 10
TUnit.Mocks requires C# 14 or later (LangVersion set to 14 or preview). The source generator emits TM004 at compile time if it doesn’t see it. The reason is the most ergonomic entry point uses C# 14 static extension members. If you’re stuck on C# 12 or 13, you can still use the library, but you’ll use the Mock.Of<T>() factory throughout instead of the T.Mock() syntax shown below.
What TUnit.Mocks Actually Ships
The API has the shape of NSubstitute, deliberately. Migration is meant to be possible. The internals are completely different.
Creating Mocks: IService.Mock() (and Mock.Of<T>())
The recommended entry point is a static extension method on the type itself:
var mock = IUserService.Mock();
This is real C# 14 syntax — a static extension member generated by the source generator. For interfaces, it returns a typed wrapper that implements the interface directly. You can assign it to the interface variable without unwrapping, pass it as a constructor argument, store it in collections — anywhere IUserService is expected:
var mock = IUserService.Mock();
IUserService svc = mock; // implicit cast, no .Object
List<IUserService> all = [mock]; // works in collections
var processor = new OrderProcessor(mock); // passes as parameter
For older language versions, multi-interface mocks, wrap mocks, delegate mocks, or interfaces with static abstract members, the factory variants on the Mock static class remain:
var alt = Mock.Of<IUserService>(); // equivalent to IUserService.Mock()
var multi = Mock.Of<ILogger, IDisposable>(); // multi-interface (up to 4)
var del = Mock.OfDelegate<Func<int, string>>();
var partial = Mock.Wrap(realService); // wrap a real instance
Source: Mock.cs, MockOfT.cs.
Matchers: No Arg. Prefix
TUnit.Mocks ships a global using static TUnit.Mocks.Arguments.Arg; — every matcher is in scope by name, no prefix needed. The everyday matchers (full set at argument-matchers):
Any() // matches everything, type inferred from position
Any<T>() // typed any-value
Is<T>(value) // exact equality
Is<T>(predicate) // predicate match
IsNull<T>()
IsNotNull<T>()
Matches(regex) // string regex
IsInRange<T>(min, max)
Not<T>(inner)
AnyArgs() // expands to one Any<T>() per parameter
The clever part: raw values and inline lambdas work directly. No wrapper needed:
mock.GetUser(42).Returns(alice); // raw int → exact match on 42
mock.GetUser(id => id > 0).Returns(user); // inline lambda → predicate match
mock.Search(name => name.Length > 3, Any()).Returns(results); // mix freely
So in idiomatic test code, you almost never type Is(...) explicitly. Compared to NSubstitute’s Arg.Any<int>(), Arg.Is<int>(x => x > 0), it’s a real reduction in noise.
Setup: .Returns(), .Throws(), .Callback(), .Then()
The chain method after the call decides whether you’re stubbing or verifying. From setup:
// Return values: async methods need no special API
mock.GetUser(Any()).Returns(new User("Alice"));
mock.GetUserAsync(Any()).Returns(new User("Alice")); // auto-wrapped in Task<T>
// Exceptions
mock.Delete(Any()).Throws<InvalidOperationException>();
mock.Delete(Any()).Throws(new ArgumentException("bad id"));
// Callbacks
mock.Process(Any()).Callback((object?[] args) => log.Add(args[0]!));
// Sequential behaviors: each .Then() advances the call counter
mock.GetValue(Any())
.Throws<InvalidOperationException>() // 1st call
.Then().Returns("retry-ok") // 2nd call
.Then().Returns("cached"); // 3rd+ calls
// Shorthand
mock.GetValue(Any()).ReturnsSequentially("first", "second", "third");
Void methods support Callback and Throws (not Returns) and are eagerly registered — calling mock.Log(Any()) without a chain is enough to allow the call in strict mode.
Properties via C# 14 Extension Properties
Property mocking gets first-class syntax:
// Getter (default)
mock.Name.Returns("Alice");
mock.Name.Throws<InvalidOperationException>();
// Setter: any value
mock.Count.Setter.Callback(() => log.Add("set"));
// Setter — specific value
mock.Count.Set(Is(42)).Callback(() => log.Add("set to 42"));
// Auto-tracking — setters store, getters return
mock.SetupAllProperties();
mock.Object.Name = "Alice";
var name = mock.Object.Name; // "Alice"
No NSubstitute equivalent ships at this fluency level.
Verification: .WasCalled(), .WasNeverCalled(), VerifyInOrder
Same call site as setup, different chain method. From verification:
mock.GetUser(42).WasCalled(); // at least once
mock.GetUser(42).WasCalled(Times.Once);
mock.GetUser(Any()).WasCalled(Times.Exactly(3));
mock.Delete(Any()).WasNeverCalled();
mock.GetUser(42).WasCalled(Times.Once, "must run at startup"); // custom message
Times carries the standard vocabulary (Once, Never, AtLeastOnce, Exactly(n), AtLeast(n), AtMost(n), Between(min, max)).
Cross-mock ordered verification is something I’ve wanted in NSubstitute for years and never had cleanly:
Mock.VerifyInOrder(() =>
{
mockLogger.Log("Starting").WasCalled();
mockRepo.SaveAsync(Any()).WasCalled();
mockLogger.Log("Done").WasCalled();
});
Out of order, it throws with the actual observed sequence. Works across independent mock instances, not just within one.
There’s also TUnit-assertion integration if you prefer the Assert.That pipeline:
using TUnit.Mocks.Assertions;
await Assert.That(mock.GetUser(42)).WasCalled(Times.Once);
Strict Mode, Repository, State, Auto-Mocking
MockBehavior.Strict. Unconfigured calls throw MockStrictBehaviorException. NSubstitute has no native strict mode — you simulate it via .Throws() defaults.
var mock = IUserService.Mock(MockBehavior.Strict);
MockRepository. When a test creates five mocks, NSubstitute leaves you to verify each one. The repository groups them — aggregates failures across the batch:
var repo = new MockRepository(MockBehavior.Strict);
var users = repo.Of<IUserService>();
var orders = repo.Of<IOrderService>();
repo.VerifyAll(); // throws AggregateException if any setup unmet
repo.VerifyNoOtherCalls();
Source: MockRepository.cs.
Auto-mocking (recursive). In loose mode, methods returning interface types auto-return functional mocks. Reach them via Mock.Get(obj):
var mock = IServiceA.Mock();
var serviceB = mock.Object.GetServiceB(); // not null — an auto-mock
Mock.Get(serviceB).GetValue().Returns(42);
serviceB.GetValue(); // 42
State machines. Setups scoped to named states; transitions on a chain method. From advanced:
var mock = IConnection.Mock();
mock.SetState("disconnected");
mock.InState("disconnected", m =>
{
m.GetStatus().Returns("OFFLINE");
m.Connect().TransitionsTo("connected");
});
mock.InState("connected", m =>
{
m.GetStatus().Returns("ONLINE");
m.Disconnect().TransitionsTo("disconnected");
});
mock.Object.GetStatus(); // "OFFLINE"
mock.Object.Connect();
mock.Object.GetStatus(); // "ONLINE"
For protocols, connection lifecycles, or anything whose response depends on prior calls, this is dramatically cleaner than the Returns(_ => ...) callback workarounds typical in NSubstitute.
Events. Auto-generated Raise{EventName}() extension methods plus chain-attached .Raises{EventName}() for auto-raise on call:
mock.RaiseOnMessage("hello"); // raise an event manually
mock.SendMessage(Any())
.Returns(true)
.RaisesOnMessage("echo"); // fire OnMessage every time SendMessage is called
There’s also a mock.Events surface for subscription tracking (SubscriberCount, OnSubscribe callbacks).
Out / ref parameters. Strongly typed by parameter name — out string value produces .SetsOutValue(...), ref int count produces .SetsRefCount(...). No untyped (index, value) tuples in normal code.
Sister Packages
TUnit.Mocks.Http and TUnit.Mocks.Logging provide first-class helpers for two cases that everyone reinvents:
// TUnit.Mocks.Http
var httpHandler = Mock.HttpHandler();
var httpClient = Mock.HttpClient(baseAddress: "https://api/");
// TUnit.Mocks.Logging
var logger = Mock.Logger<MyService>(); // implements ILogger<MyService>
NSubstitute users typically have hand-rolled MockHttpHandler and TestLogger<T> in every project. Replacing those is a quiet win.
What [GenerateMock] Is Actually For
The [assembly: GenerateMock(typeof(IFoo))] attribute is not the normal entry point. Most types are picked up automatically when you call T.Mock() or Mock.Of<T>(). The attribute exists for one specific case: types with static abstract members. Those trigger CS8920 (“the interface has unresolved static abstract members”) when used as a generic type parameter, and Mock.Of<IMyParseable>() refuses to compile — same wall NSubstitute hits.
TUnit.Mocks’ workaround is to emit a bridge interface (suffixed _Mockable) with default implementations for the static abstract surface. You mock the bridge instead:
using TUnit.Mocks;
[assembly: GenerateMock(typeof(IMyParseable))]
public interface IMyParseable : IParsable<IMyParseable>
{
string Format();
}
// In your test:
var mock = Mock.Of<TUnit_Mocks_Tests_IMyParseable_Mockable>();
mock.Format().Returns("formatted");
This is one of the very few places in the API where the source-generation model leaks into the test code. For 99% of types, you never see the attribute at all. NSubstitute has no equivalent. If your interface uses static abstract members, you’re either rewriting the interface or wrapping it.
Source: GenerateMockAttribute.cs.
NSubstitute → TUnit.Mocks Side-by-Side
// NSubstitute
var sub = Substitute.For<IUserService>();
sub.GetUser(Arg.Any<int>()).Returns(new User(42));
var result = sub.GetUser(42);
sub.Received(1).GetUser(42);
sub.DidNotReceive().Save(Arg.Any<User>());
// TUnit.Mocks (idiomatic — C# 14)
var mock = IUserService.Mock();
mock.GetUser(Any()).Returns(new User(42));
IUserService svc = mock;
var result = svc.GetUser(42);
mock.GetUser(42).WasCalled(Times.Once);
mock.Save(Any()).WasNeverCalled();
Roughly half the syntactic noise vanishes — partly because the Arg. prefix is gone, partly because raw values match exactly without Arg.Is(...). Where the two diverge in capability (and where TUnit.Mocks pulls ahead) is the framework surface: strict mode, repositories, state machines, wrap mocks, VerifyInOrder, property mocking. Those translate as feature additions, not API changes.
Where It Still Hurts
I want to be honest about where this is not yet drop-in.
C# 14 requirement. If you’re stuck on C# 12 or 13, T.Mock() and extension-property setup are off the table. You can still use Mock.Of<T>() and most features, but the ergonomic gap closes.
Maturity gap. NSubstitute has a decade of edge cases catalogued in issues and Stack Overflow answers. TUnit.Mocks is much newer. When a setup behaves unexpectedly, the search results are sparse. This will close with time.
Generator diagnostics. The source generator surfaces some errors cleanly (missing factory, wrong type, language version). Others — especially around complex generic constraints — show up as compile errors on the generated code. Read the generated file in obj/Generated/; it’s the fastest way to understand what the generator decided to emit.
Wrap mocks need virtual members. Mock.Wrap<T>(instance) requires T to be non-sealed with virtual members the generator can override. Sealed classes (including most records with bodies) can’t be wrapped. NSubstitute hits the same wall.
Ecosystem. Libraries that ship NSubstitute.Extensions or test helpers built on its argument matchers won’t transparently work. Migration is per-test, not per-project.
A Realistic Migration Path
This is what worked on the project I tried it on.
- Bump
LangVersionto14first. Without it, you get TM004 and the most useful syntax is locked. - Add
TUnit.MocksalongsideNSubstitute. Both coexist across fixtures. Don’t mix them within a single test file — the cognitive cost is real even though the technical conflict is small. - Migrate new tests first. Anything written from scratch uses
T.Mock(). Old tests stay on NSubstitute until they break. - Migrate by fixture, not by file. One library per test class.
- Adopt
MockBehavior.Strictdeliberately. Both libraries default to loose. Strict-by-default is tempting, but it’s a cultural choice — make it explicitly. - Delete
NSubstituteonly when the verification semantics are confirmed.VerifyNoOtherCallsinteracts differently in edge cases compared to NSubstitute’s “what wasn’tReceived()is fine” model. Write a few high-coverage tests in parallel before committing.
The fastest wins I observed: AOT-failing tests compiled without changes, setup-matched-nothing errors surfaced at build time rather than buried in test output, and cold-start time dropped roughly 15% on a 3,000-test suite. Property mocking and VerifyInOrder immediately replaced hand-rolled helpers I’d been carrying for years.
When to Stay on NSubstitute
Don’t migrate just because something is new. If the test suite is in maintenance mode and nobody’s touching it, the cost outweighs the benefit. Same if you can’t move to C# 14 yet: the ergonomic gap closes hard without static extension members and extension properties. And if CI depends heavily on NSubstitute.Analyzers rules, verify the TUnit.Mocks analyzer coverage first. The rule sets differ, and losing existing guardrails by accident is a bad trade.
Verdict
TUnit.Mocks is not a curiosity. It’s a serious answer to a problem the .NET mocking ecosystem has been quietly accumulating since Castle.DynamicProxy became the foundation everyone built on. NSubstitute is excellent — and stuck on the same ceiling. If you’re standing up a new test project in 2026 on .NET 10 / C# 14, especially one targeting NativeAOT, this is now my default recommendation.
The API is close enough to NSubstitute that the muscle memory transfers. The internals are different enough that the AOT, trimming, and cold-start problems disappear. The diagnostics surface at compile time instead of test run time. And the strict-mode, repository, state-machine, VerifyInOrder, and property-mocking features are genuine improvements I didn’t realize I was missing.
The honest summary: NSubstitute is not dead, and Moq is not coming back. But for the first time in a decade, the foundation underneath all of them has a credible challenger — a Roslyn source generator, not a smarter proxy.
A mocking library that compiles your setup expressions instead of evaluating them at runtime is not a small change. It’s a different category of tool.

Comments