Skip to content

Test Fundamentals (xUnit, NUnit, Moq, NSubstitute)

Key Points

  • xUnit is the dominant .NET test framework in 2026. NUnit is alive and fine. MSTest is mostly Microsoft-internal.
  • xUnit philosophy: each test method gets a fresh class instance (no shared state by default). Setup via constructor; teardown via IDisposable/IAsyncLifetime.
  • Theory + InlineData/MemberData/ClassData for parameterized tests. Fact for single-shot.
  • Mocking: NSubstitute (cleanest syntax) > Moq (older; v4.20+ telemetry controversy stained reputation) > FakeItEasy. Don't mock what you own when an in-memory implementation is cheap.
  • AAA pattern — Arrange, Act, Assert. One concept per test. Avoid loops or multiple Acts in a single test.
  • Assertion libraries: Shouldly (most readable) or FluentAssertions v7 (paid since v8). Plain Assert.Equal is fine for simple cases.

Concepts (deep dive)

xUnit basics

public class CalculatorTests
{
    private readonly Calculator _sut = new();   // fresh per test

    [Fact]
    public void Add_returns_sum()
    {
        // Arrange (in field above)
        // Act
        var result = _sut.Add(2, 3);
        // Assert
        Assert.Equal(5, result);
    }

    [Theory]
    [InlineData(1, 1, 2)]
    [InlineData(0, 0, 0)]
    [InlineData(-1, 1, 0)]
    public void Add_works_for(int a, int b, int expected)
        => Assert.Equal(expected, _sut.Add(a, b));
}

Fresh instance per test: xUnit creates a new CalculatorTests instance for every method. This eliminates inter-test state pollution that plagued older frameworks. Setup goes in constructor; teardown in Dispose().

Sharing setup with fixtures

When setup is expensive (DB container, web factory), share across tests in a class:

public class DbFixture : IAsyncLifetime
{
    public DbConnection Connection { get; private set; } = default!;

    public async Task InitializeAsync()
    {
        Connection = new SqliteConnection("DataSource=:memory:");
        await Connection.OpenAsync();
        // create schema
    }

    public Task DisposeAsync() => Connection.DisposeAsync().AsTask();
}

public class OrderTests : IClassFixture<DbFixture>   // fresh fixture per class
{
    private readonly DbFixture _fx;
    public OrderTests(DbFixture fx) => _fx = fx;
}

// Across multiple test classes:
[CollectionDefinition("DB collection")]
public class DbCollection : ICollectionFixture<DbFixture> { }

[Collection("DB collection")]
public class OrderTests { /* ... */ }

IClassFixture: shared per class. ICollectionFixture: shared across multiple classes. Fixtures support async via IAsyncLifetime (xUnit v2/v3).

NUnit comparison

[TestFixture]
public class CalculatorTests
{
    private Calculator _sut;

    [SetUp] public void Setup() => _sut = new();
    [TearDown] public void Tear() { /* ... */ }

    [Test]
    public void Add() => Assert.That(_sut.Add(2, 3), Is.EqualTo(5));

    [TestCase(1, 1, 2)]
    [TestCase(0, 0, 0)]
    public void Add_cases(int a, int b, int e) => Assert.That(_sut.Add(a, b), Is.EqualTo(e));
}

NUnit shares one instance across tests by default (with [SetUp]). xUnit's per-test instance is generally cleaner — but NUnit fluent assertions are nice.

MSTest

Slightly verbose; still ships with Visual Studio templates. Prefer xUnit for new code unless you're in a tightly Microsoft-integrated shop with MSTest patterns established.

Mocking with NSubstitute

public interface IClock { DateTimeOffset UtcNow { get; } }

[Fact]
public void Order_uses_clock()
{
    var clock = Substitute.For<IClock>();
    clock.UtcNow.Returns(new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero));

    var order = new OrderService(clock);
    var result = order.Place();

    clock.Received(1).UtcNow;                   // verify
    Assert.Equal(2026, result.CreatedAt.Year);
}

Setup with .Returns(...). Verify with .Received(n).Method(). Throw with .Throws<T>(). Cleaner than Moq's It.IsAny<T>() ceremony.

Mocking with Moq (still common)

var clock = new Mock<IClock>();
clock.Setup(c => c.UtcNow).Returns(new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero));

var order = new OrderService(clock.Object);
order.Place();

clock.Verify(c => c.UtcNow, Times.Once);

Moq v4.20 had a telemetry/SponsorLink incident in 2023 that hurt trust. Many teams migrated to NSubstitute. Both work fine technically.

Don't mock what you own (when alternatives exist)

// ❌ Mocking EF DbContext — fragile
var ctxMock = new Mock<AppDb>();
ctxMock.Setup(...).Returns(...);

// ✅ Use real EF in-memory or Sqlite
var options = new DbContextOptionsBuilder<AppDb>().UseSqlite("DataSource=:memory:").Options;
using var ctx = new AppDb(options);

Mocked DbContext breaks under any non-trivial query. Real DB (in-memory or container) catches actual SQL bugs.

Assertion libraries

// xUnit built-in
Assert.Equal("Alice", user.Name);
Assert.True(user.IsActive);
Assert.Throws<InvalidOperationException>(() => svc.Do());

// Shouldly (free, MIT)
user.Name.ShouldBe("Alice");
user.IsActive.ShouldBeTrue();
Should.Throw<InvalidOperationException>(() => svc.Do());

// FluentAssertions v7 (free) / v8 (paid)
user.Name.Should().Be("Alice");
user.IsActive.Should().BeTrue();
svc.Invoking(s => s.Do()).Should().Throw<InvalidOperationException>();

For 2026, Shouldly is a strong free choice. FluentAssertions v8 became commercial — many teams pinned to v7 or migrated.

AAA pattern

[Fact]
public void Should_calculate_total()
{
    // Arrange
    var lines = new[] { new Line(2, 5m), new Line(1, 10m) };

    // Act
    var total = OrderTotal.Of(lines);

    // Assert
    Assert.Equal(20m, total);
}

Clear sections; one Act per test (multiple Asserts on the same outcome OK).

Naming

Common conventions: - Method_state_expectation: Add_two_positives_returns_sum - Should_expectation_when_state: Should_return_sum_when_two_positives - BDD: When_two_positives, Add_should_return_sum

Pick one and apply consistently.

Test data

// AutoFixture for randomized inputs:
var fixture = new Fixture();
var user = fixture.Create<User>();   // populated with anonymous data

// Bogus for realistic-looking fakes:
var faker = new Faker<User>()
    .RuleFor(u => u.Name, f => f.Name.FullName())
    .RuleFor(u => u.Email, f => f.Internet.Email());

Use sparingly — random tests can be flaky. Better for fuzz-style tests; deterministic data for unit tests.

Async tests

[Fact]
public async Task GetUser_returns_user()
{
    var user = await _sut.GetAsync(1);
    Assert.NotNull(user);
}

Always async Task, never async void. xUnit detects async automatically.

Cancellation testing

[Fact]
public async Task Operation_respects_cancellation()
{
    using var cts = new CancellationTokenSource();
    cts.Cancel();
    await Assert.ThrowsAsync<OperationCanceledException>(() => _sut.GoAsync(cts.Token));
}

Test parallelization

xUnit runs tests in different classes in parallel by default. Tests within a class run serially. Disable for stateful tests:

[CollectionDefinition("NoParallel", DisableParallelization = true)]
public class NoParallelCollection { }

Or globally in xunit.runner.json:

{ "parallelizeTestCollections": false }

xUnit v3 (released 2024)

Brings: native ahead-of-time (AOT) support, better runner architecture, improved diagnostics. Migration mostly drop-in. For new projects in 2026, xUnit v3.

Code coverage

dotnet test --collect:"XPlat Code Coverage"
# generates Cobertura XML
reportgenerator -reports:**/coverage.cobertura.xml -targetdir:coveragereport

Coverage is a floor, not a ceiling. 100% coverage with bad tests is worse than 60% with good ones.


Code: correct vs wrong

❌ Wrong: shared mutable state across tests

public static List<User> _users = new();
[Fact] public void T1() { _users.Add(new()); }
[Fact] public void T2() { Assert.Empty(_users); }   // flaky

✅ Correct: fresh state per test

public class Tests
{
    private readonly List<User> _users = new();   // re-created per test
    [Fact] public void T1() { _users.Add(new()); }
    [Fact] public void T2() { Assert.Empty(_users); }
}

❌ Wrong: testing internals via reflection

var method = typeof(Svc).GetMethod("PrivateThing", BindingFlags.NonPublic);
method.Invoke(...)

Couples tests to implementation. Prefer testing public behavior.

❌ Wrong: thick mock chains

mock.Setup(x => x.A().B().C().D()).Returns(...);

Heavy mocks signal poor abstractions. Refactor.

✅ Correct: in-memory fakes for owned types

public class FakeRepo : IRepo
{
    private readonly Dictionary<int, User> _users = new();
    public Task<User?> Get(int id) => Task.FromResult(_users.GetValueOrDefault(id));
}

Design patterns for this topic

Pattern 1 — "Test pyramid"

  • Intent: lots of unit, fewer integration, very few E2E.

Pattern 2 — "AAA per test"

  • Intent: clear arrange/act/assert sections.

Pattern 3 — "Fixtures for expensive setup"

  • Intent: share DB containers, web factories.

Pattern 4 — "Real DB over mocked DB"

  • Intent: catch actual SQL bugs.

Pattern 5 — "Behavior-driven naming"

  • Intent: test names describe scenarios, not method names.

Pros & cons / trade-offs

Tool Pros Cons
xUnit Modern; per-test isolation Less docs than NUnit
NUnit Mature; rich assertions Setup-style state sharing
MSTest Bundled with VS Less ergonomic
Moq Mature; familiar Telemetry incident; ceremony
NSubstitute Clean syntax Smaller community
Shouldly Readable Yet another dep
FluentAssertions v8 Polished Commercial license

When to use / when to avoid

  • Use xUnit + NSubstitute for new projects.
  • Use real DB (Testcontainers) for repository/integration tests.
  • Avoid mocking what you can replace with a simple in-memory fake.
  • Avoid shared mutable test state.
  • Avoid pinning to FluentAssertions v8 without legal review.

Interview Q&A

Q1. What does "fresh instance per test" in xUnit mean? xUnit creates a new test class instance for each test method. No shared state by default; setup via constructor.

Q2. IClassFixture vs ICollectionFixture? IClassFixture: one instance per test class. ICollectionFixture: one instance shared across multiple classes via collection.

Q3. Fact vs Theory? Fact: parameterless test. Theory: parameterized — each InlineData/MemberData/ClassData row is a separate test.

Q4. Why not mock DbContext? EF translates LINQ to SQL — mocked DbContext misses real query bugs. Use in-memory provider or Sqlite/Postgres via Testcontainers.

Q5. Why did some teams move from Moq to NSubstitute? Moq v4.20 added telemetry (SponsorLink) without clear notice. Trust damaged; many migrated.

Q6. What's the AAA pattern? Arrange (setup), Act (the operation under test), Assert (verify). Keeps tests readable.

Q7. How do you parameterize tests? xUnit [Theory] + [InlineData]/[MemberData]/[ClassData]. NUnit [TestCase]. Each row is a discrete test case.

Q8. Async tests? async Task (never async void). xUnit handles awaiting automatically.

Q9. Test parallelization in xUnit? By default, classes run in parallel; tests within a class serial. Disable via collection or xunit.runner.json.

Q10. Why is 100% coverage a misleading goal? Coverage measures execution, not correctness. Bad tests with 100% can still miss bugs. Focus on quality + boundary conditions.

Q11. What's a "fixture"? xUnit's term for shared setup state (DB connections, factory instances). Lives across tests via IClassFixture/ICollectionFixture.

Q12. xUnit v3? Released 2024; AOT-friendly; improved runner. Recommended for new projects in 2026.


Gotchas / common mistakes

  • ⚠️ async void test — exceptions silently swallowed.
  • ⚠️ Static state across tests — flakiness.
  • ⚠️ Mocking heavy chains — sign of bad abstraction.
  • ⚠️ Testing private methods via reflection — coupling.
  • ⚠️ No IAsyncLifetime for async fixture init — race conditions.
  • ⚠️ Time-dependent tests without TimeProvider — flaky in CI.

Further reading