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/ClassDatafor parameterized tests.Factfor 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.Equalis 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:
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
Couples tests to implementation. Prefer testing public behavior.
❌ Wrong: thick mock chains
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 voidtest — exceptions silently swallowed. - ⚠️ Static state across tests — flakiness.
- ⚠️ Mocking heavy chains — sign of bad abstraction.
- ⚠️ Testing private methods via reflection — coupling.
- ⚠️ No
IAsyncLifetimefor async fixture init — race conditions. - ⚠️ Time-dependent tests without
TimeProvider— flaky in CI.