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.
Join the conversation! Your thoughts help the community grow.