NSubstitute offers a cleaner, more ergonomic syntax than Moq for the majority of mocking scenarios. Rather than Moq’s mock.Setup(r => r.Method()).Returns(value) pattern, NSubstitute uses extension methods on the substitute directly: repo.GetByIdAsync(1).Returns(post). For most BlogApp service tests, NSubstitute’s simpler syntax produces more readable tests. The choice between Moq and NSubstitute is largely stylistic — both are mature, actively maintained, and fully capable.
NSubstitute Syntax
// dotnet add package NSubstitute
// ── Creating substitutes ───────────────────────────────────────────────────
IPostsRepository repo = Substitute.For<IPostsRepository>();
ICurrentUserService user = Substitute.For<ICurrentUserService>();
// ── Configure return values ───────────────────────────────────────────────
// Exact argument:
repo.GetByIdAsync(42, Arg.Any<CancellationToken>()).Returns(
new Post { Id = 42, Title = "Test Post" });
// Any argument:
repo.GetBySlugAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns((Post?)null);
// Conditional argument:
repo.GetByIdAsync(
Arg.Is<int>(id => id > 0),
Arg.Any<CancellationToken>())
.Returns(new Post { Id = 1 });
// Property:
user.UserId.Returns("author-123");
user.IsAuthenticated.Returns(true);
// ── Callbacks ─────────────────────────────────────────────────────────────
Post? savedPost = null;
await repo.AddAsync(Arg.Do<Post>(p => savedPost = p), Arg.Any<CancellationToken>());
// Arg.Do captures the argument when the method is called
// ── Sequences — different values on successive calls ─────────────────────
repo.GetViewCountAsync(1, Arg.Any<CancellationToken>())
.Returns(100, 101, 102); // first call=100, second=101, third=102
// ── Verify calls ──────────────────────────────────────────────────────────
// Received — was it called?
await repo.Received(1).AddAsync(
Arg.Is<Post>(p => p.Status == "draft"),
Arg.Any<CancellationToken>());
// DidNotReceive — was it NOT called?
await repo.DidNotReceive().AddAsync(
Arg.Any<Post>(), Arg.Any<CancellationToken>());
// ── Complete PostsService test with NSubstitute ───────────────────────────
public class PostsService_NSubstitute_Tests
{
private readonly IPostsRepository _repo = Substitute.For<IPostsRepository>();
private readonly ICurrentUserService _user = Substitute.For<ICurrentUserService>();
private readonly PostsService _sut;
public PostsService_NSubstitute_Tests()
{
_user.UserId.Returns("author-123");
_sut = new PostsService(_repo, _user);
}
[Fact]
public async Task CreateAsync_WithDuplicateSlug_ThrowsConflict()
{
_repo.SlugExistsAsync("taken", Arg.Any<CancellationToken>()).Returns(true);
Func<Task> act = () => _sut.CreateAsync(
new CreatePostRequest("Title", "taken", "Body", "draft"), default);
await act.Should().ThrowAsync<ConflictException>();
await _repo.DidNotReceive().AddAsync(Arg.Any<Post>(), Arg.Any<CancellationToken>());
}
}
// ── NSubstitute vs Moq syntax comparison ─────────────────────────────────
// MOQ:
// mock.Setup(r => r.GetByIdAsync(1, default)).ReturnsAsync(post);
// mock.Verify(r => r.GetByIdAsync(1, default), Times.Once);
// NSUBSTITUTE (equivalent):
// repo.GetByIdAsync(1, Arg.Any<CancellationToken>()).Returns(Task.FromResult(post));
// await repo.Received(1).GetByIdAsync(1, Arg.Any<CancellationToken>());
await repo.Received(1).GetByIdAsync(42, ...). This reads naturally: “the repository should have received exactly 1 call to GetByIdAsync with argument 42.” The verification is part of the assertion phase and reads like plain English. Moq’s mock.Verify(r => r.GetByIdAsync(42, default), Times.Once) requires a separate API that looks different from the setup syntax, which some developers find less intuitive.Arg.Do<T>(action) is the equivalent of Moq’s Callback<T>() — it executes a callback when the method is called with a matching argument. Use it to capture passed objects for later assertion: Post? captured = null; repo.AddAsync(Arg.Do<Post>(p => captured = p), ...). After the act phase, assert on captured. This is more readable than Moq’s Callback because the capture logic is inline with the argument specification.Received() is a post-hoc assertion. This is generally cleaner (Arrange-Act-Assert is more explicit) but means forgetting to call Received in the Assert phase silently skips the verification, unlike Moq’s VerifyAll which catches unverified setups.Common Mistakes
Mistake 1 — Forgetting Received() assertion (verification silently skipped)
❌ Wrong — test passes without verifying a critical call was made; the interaction check was intended but forgotten.
✅ Correct — explicitly call await substitute.Received(1).MethodName(...) in the Assert phase for every interaction you care about.
Mistake 2 — Mixing NSubstitute and Moq in the same test project (inconsistent patterns)
❌ Wrong — some tests use Moq, others use NSubstitute; team members must know both APIs; inconsistent style.
✅ Correct — choose one library for the project and use it consistently; document the choice in the project README.