Most .NET test suites hit the same three walls when they start mocking. The first is ceremony: your test is 40% wrapper types. The second is silence: the mock answers a call nobody meant it to receive, returns null or 0, and the test passes for a reason that has nothing to do with the behaviour it claims to check. The third is the wall you hit in a codebase older than the test suite — a sealed class, a static helper, a method nobody thought to mark virtual — where the usual advice is "extract an interface first", which is exactly the change you wanted a test for.
This approach builds on NSubstitute, provides an API familiar to Moq users, and uses runtime patching when proxy-based mocking is not sufficient.
1. The Substitute Is the Object
Moq's expressive Setup/Verify API comes attached to a wrapper. You arrange on mock, you pass mock.Object, and every helper that touches a mock needs to decide which of the two it takes:
// Moq
var clock = new Mock<IClock>();
clock.Setup(x => x.Now).Returns(new DateTime(2026, 8, 30));
var sut = new Scheduler(clock.Object);
clock.Verify(x => x.Now, Times.Once());
NSubstitute answers that by having no wrapper at all — but then it also has no Setup, no MockBehavior.Strict, and no MockRepository.
The extension API keeps NSubstitute's plain substitute and provides familiar extension members:
var clock = IClock.Strict();
clock.Setup(x => x.Now).Returns(new DateTime(2026, 8, 30));
var sut = new Scheduler(clock);
clock.Verify(x => x.Now, Times.Once());
There is one object. It goes into the constructor, it goes into the assertion, and it stays a valid IClock the whole way through. Helper methods take IClock. Fixtures return IClock. Nothing in your test signatures has to know a mock is involved.
Because it is an ordinary NSubstitute substitute, NSubstitute's own syntax can still be used:
var clock = Substitute.For<IClock>();
clock.Now.Returns(new DateTime(2026, 8, 30));
clock.AsStrict();
clock.Verify(x => x.Now, Times.Once());
_ = clock.Received(1).Now;
This allows the APIs to be used alongside existing NSubstitute tests without requiring the entire test file to use a different style.
2. Strictness, So a Test Cannot Pass by Accident
A loose mock can answer unexpected calls, so a test that exercises the wrong code path may still receive plausible-looking values.
var repo = IUserRepository.Strict();
repo.Setup(x => x.Load(1)).Returns(new User(1));
sut.Process(1);
If Process also calls Delete(1), the test can fail at that call:
This substitute has strict behaviour and received a call that was never set up:
Delete(1)
Calls that were set up:
Load(1)
Add a Setup(...) or AllowCall(...) for it, or create the substitute with MockBehavior.Loose.
Strictness is implemented through a custom call handler registered through NSubstitute's public ICallRouter.RegisterCustomCallHandlerFactory. The handler is reached only when nothing above it has answered the call. A configured call is therefore not processed by the strict handler.
This has several consequences:
sub.Received().Foo()andReceived.InOrdertravel other routes and are not affected by strict mode.Event subscriptions and
object's own members do not require a setup.A call that should be permitted without assigning a result can use
sub.AllowCall(x => x.Foo()).
Strictness is per substitute, allowing strict behavior to be used only for collaborators where the interaction protocol is being asserted.
3. Arranging Is Not Calling
Declaring a setup does not count as a received call:
var calc = ICalculator.Loose();
calc.Setup(x => x.Add(2, 3)).Returns(5);
calc.Verify(x => x.Add(2, 3), Times.Never());
Setups are recorded through NSubstitute's RecordCallSpecification route, which records no received call and runs no custom handlers. This also allows a setup to be declared against a strict substitute without the act of arranging triggering the strict check.
It also keeps Times.Exactly(n) focused on the calls made by the system under test.
4. Testing Code That Is Difficult to Proxy
Consider the following types:
public sealed class LicenceCache
{
public string Get(string key) => /* real lookup */;
}
public static class FileHelper
{
public static string ReadFile(string path) => File.ReadAllText(path);
}
public class UserService
{
public string GetUserName(int id) => /* real query */;
}
Proxy-based mocking libraries generally rely on generated subclasses. This creates limitations for sealed classes, static members, and non-virtual methods.
One alternative is to construct the real object and intercept its members at runtime through Harmony:
var service = Substitute.ForConcrete<UserService>("staging");
service.Setup(x => x.GetUserName(1)).Returns("John Doe");
Assert.Equal("John Doe", service.GetUserName(1));
Assert.Equal("Real User Two", service.GetUserName(2));
service.Verify(x => x.GetUserName(1), Times.Once());
Sealed classes can be handled similarly, as can static methods:
Substitute.SetupStatic(() => FileHelper.ReadFile(It.IsAny<string>()))
.Returns("mock contents");
Assert.Equal("mock contents", FileHelper.ReadFile("invoices.csv"));
Substitute.VerifyStatic(() => FileHelper.ReadFile("invoices.csv"), Times.Once());
A scoped static substitute can provide a visible lifetime:
using var files = Substitute.ForStatic<FileHelper>();
files.Setup(() => FileHelper.ReadFile("invoices.csv"))
.Returns("mock contents");
files.VerifyNoOtherCallsOnDispose = true;
Leaving the scope unpatches FileHelper.
The API remains consistent whether the underlying implementation uses a proxy or runtime patching. Setup, It, Times, Verify, MockBehavior, MockSequence, and MockRepository retain the same shape.
An unconfigured call on a concrete substitute runs the real implementation, so ForConcrete<T> behaves like a partial substitute rather than Substitute.For<T>().
When the opposite behavior is required, StrictConcrete<T> intercepts every member and throws on calls that have not been set up.
5. Verification and Runtime Patching Limits
Runtime patching has limitations that affect verification.
By default, only the members named in a setup are intercepted. A call to an unpatched member is therefore invisible. This can affect assertions such as "this was never called" and "nothing else was called."
The implementation can reject these assertions when it cannot reliably observe the required calls:
VerifyNoOtherCalls needs every member of this concrete substitute to be intercepted, and only the
members named in a setup are.
Create it with MockBehavior.Strict or a default-value provider, set ConcreteOptions.PatchAllMembers = true
before creating it, or set up the member first.
The same rule applies to Times.Never() on a member that was not patched and to a MockSequence that mixes engines while ConcreteOptions.TrackCallOrder is disabled.
6. Verification Across the Whole Interaction
Individual Verify calls check specific assertions. Other features can verify broader interaction behavior.
VerifyNoOtherCalls checks that no calls reached the substitute beyond those that were set up or already verified.
MockRepository can apply a shared policy to a fixture and report multiple failures:
using var mocks = new MockRepository(MockBehavior.Strict)
{
DefaultValueProvider = DefaultValueProviders.Empty,
VerifyAllOnDispose = true,
VerifyNoOtherCallsOnDispose = true,
};
var clock = mocks.Create<IClock>();
var ledger = mocks.CreatePartial<Ledger>();
var service = mocks.CreateConcrete<UserService>("staging");
var files = mocks.CreateStatic<FileHelper>();
Because end-of-scope checks run on Dispose, teardown can perform verification and unpatch static members opened by the repository.
MockSequence can check ordering across substitutes:
MockSequence.Create()
.Expect(connection, x => x.Open())
.ExpectStatic(() => FileHelper.ReadFile("query.sql"))
.Expect(command, x => x.Execute(It.IsAny<string>()))
.Expect(connection, x => x.Close())
.Verify();
Unrelated calls between the expected calls are tolerated by default. Contiguous() can require the calls to be consecutive.
A verified sequence also counts as verification for subsequent VerifyNoOtherCalls checks.
7. Default Values as a Design Tool
An unconfigured call returning null may fail far from where the missing arrangement originated.
Default-value providers can be used before NSubstitute's own auto-values:
sub.WithDefaultValues(DefaultValueProviders.Empty);
sub.WithDefaultValues(DefaultValueProviders.Substitutes);
sub.WithDefaultValues(DefaultValueProviders.Custom()
.Register("(unset)")
.Register<int>(call => call.GetArguments().Length)
.RegisterMatching(
t => t.IsEnum,
(t, _) => Enum.GetValues(t).GetValue(0)));
A custom value such as "(unset)" can make a missing setup easier to identify during debugging.
A strict substitute still throws on an unexpected call rather than allowing a default-value provider to answer it.
8. Protected Members Without Reflection in Tests
Testing a template-method base class can otherwise require making members internal or using reflection.
The API can target protected members by name:
var ledger = Ledger.LoosePartsOf();
ledger.Protected<int>("Calculate", It.IsAny<int>()).Returns(42);
ledger.ProtectedVoid("Audit", It.IsAny<string>())
.Callback(c => seen.Add(c.ArgAt<string>(0)));
ledger.Protected<string>("get_Label").Returns("configured");
ledger.VerifyProtected(
"Audit",
Times.Once(),
"every posting is audited");
The names remain strings, but failures can identify specific problems. For example:
A name that does not resolve can raise
ProtectedMethodNotFoundException.A non-virtual match can raise
ProtectedMethodNotVirtualException.An incompatible result type can raise
UnsupportedSetupExpressionException.
The matchers, Times, and verification syntax remain consistent with other setups.
9. Moving from Moq
The vocabulary maps closely, while the substitute itself is used directly:
| Moq | Equivalent API |
|---|---|
| new Mock<IFoo>() / .Object | IFoo.Loose() — no wrapper or .Object |
| new Mock<IFoo>(MockBehavior.Strict) | IFoo.Strict() |
| mock.Setup(x => x.M(It.IsAny<int>())) | sub.Setup(x => x.M(It.IsAny<int>())) |
| .Returns / .Throws / .Callback | Same |
| SetupSequence | SetupSequence |
| SetupGet / SetupSet / SetupProperty | Same |
| .ReturnsAsync / .ThrowsAsync | Same |
| mock.Verify(…, Times.Once()) | sub.Verify(…, Times.Once()) |
| VerifyAll / VerifyNoOtherCalls | Same |
| MockRepository | MockRepository, with concrete and static creation |
| mock.Protected().Setup<int>("M") | sub.Protected<int>("M", …) |
| MockSequence | MockSequence, including multiple substitute types |
| — | Concrete classes, sealed classes, non-virtual members, and statics |
Two C# language rules require different syntax.
Arg.Any<T>() cannot appear in a Setup or Verify expression because it returns ref T, and C# forbids a by-reference return inside an expression tree.
Use It.IsAny<T>() for the expression API:
sub.Setup(x => x.Add(It.IsAny<int>(), 3)).Returns(7);
sub.Arrange(x => x.Add(Arg.Any<int>(), 3).Returns(7));
ref and out arguments cannot appear in an expression tree either. They can be arranged using the native API:
sub.Arrange(x => x.TryParse("42", out Arg.Any<int>())
.Returns(call =>
{
call[1] = 42;
return true;
}));
Arrange suspends strict enforcement for its duration, allowing this syntax to work on a strict substitute.
10. Trade-Offs and Limitations
The following considerations apply when using runtime patching:
Patching is process-wide. A Harmony patch applies to a method rather than an individual instance.
Substitute.ResetConcrete()can be called during fixture teardown, and tests that patch methods may need to be serialized.MockRepositoryandStaticMockscopes can remove patches onDispose.The restrictions apply only to concrete and static substitutes. Ordinary interface and virtual-member substitutes remain regular NSubstitute proxies.
VerifyNoOtherCallsandTimes.Never()on a concrete substitute require full interception throughConcreteOptions.PatchAllMembers = true, strict behavior, or a default-value provider.The extensions should be imported in test projects only. The API is provided through extension members, so methods such as
SetupandVerifybecome available on class types in scope.Consuming projects need a C# 14 compiler for the type-first helpers such as
IClock.Strict()andSubstitute.ForConcrete<T>(), which are static extension members.The assembly targets
netstandard2.0and is strong-named, allowing it to be referenced by .NET Framework 4.x, .NET Core, and .NET 5+ applications.
Summary
Mocking approaches often involve trade-offs between API simplicity, verification behavior, and the types of code that can be substituted. Combining NSubstitute-style substitutes with familiar setup and verification APIs, along with runtime patching for concrete and static members, provides another approach for testing code that cannot easily be handled through proxy-based mocking. The important consideration is understanding the limitations of each mechanism—particularly runtime patching and verification coverage—so that tests only make assertions the implementation can reliably observe.
Comments
Join the conversation! Your thoughts help the community grow.