Testing .NET code
xUnit setup, fixtures, the assertions that describe intent, and choosing what to mock so the tests stay useful.
Project setup and fixtures
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<IsPackable>false</IsPackable>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" />
<PackageReference Include="NSubstitute" />
</ItemGroup>
</Project>using Xunit;
// A collection fixture: expensive setup shared by every test in the class
public sealed class DatabaseFixture : IAsyncLifetime
{
public string ConnectionString { get; private set; } = "";
public async Task InitializeAsync()
{
ConnectionString = await TestDb.StartAsync();
}
public Task DisposeAsync() => TestDb.StopAsync();
}
[CollectionDefinition("db")]
public sealed class DatabaseCollection : ICollectionFixture<DatabaseFixture> { }
[Collection("db")]
public sealed class OrderRepositoryTests
{
private readonly DatabaseFixture _db;
public OrderRepositoryTests(DatabaseFixture db) => _db = db;
[Theory]
[InlineData(0, false)]
[InlineData(1, true)]
[InlineData(50_000, true)]
public void Total_is_valid_only_within_bounds(decimal amount, bool expected)
{
var order = new OrderBuilder().WithTotal(amount).Build();
Assert.Equal(expected, order.IsValid());
}
[Fact]
public async Task Missing_order_returns_null_rather_than_throwing()
{
var repo = new SqlOrderRepository(_db.ConnectionString);
Assert.Null(await repo.FindAsync(999_999));
}
}- Test classes run in parallel by default; tests inside a class do not. Shared mutable state needs a collection fixture or a lock.
[Theory]with data is almost always better than several near-identical[Fact]methods.- Name tests by behaviour, not by method:
Missing_order_returns_nulltells a reviewer what broke;FindAsync_Test2does not. - One assertion concept per test. Several
Assertcalls verifying one outcome are fine; verifying two unrelated outcomes means two tests.
Mock the boundary, not your own code
using NSubstitute;
public sealed class PaymentServiceTests
{
[Fact]
public async Task Declined_card_surfaces_a_domain_error()
{
// Mock the outbound boundary
var gateway = Substitute.For<IPaymentGateway>();
gateway.ChargeAsync(Arg.Any<ChargeRequest>(), Arg.Any<CancellationToken>())
.Returns(new ChargeResult(Declined: true, Reason: "insufficient_funds"));
var clock = Substitute.For<IClock>();
clock.UtcNow.Returns(new DateTimeOffset(2026, 9, 18, 9, 0, 0, TimeSpan.Zero));
// The class under test is real
var service = new PaymentService(gateway, clock);
var result = await service.ChargeAsync(new Charge(2500, "GBP"), default);
Assert.False(result.Succeeded);
Assert.Equal("insufficient_funds", result.Reason);
// Assert on the interaction that matters, not on every call
await gateway.Received(1).ChargeAsync(
Arg.Is<ChargeRequest>(r => r.Amount == 2500 && r.Currency == "GBP"),
Arg.Any<CancellationToken>());
}
}# Run everything
dotnet test
# Run one class while iterating
dotnet test --filter FullyQualifiedName~PaymentServiceTests
# Run by trait, which is how you split fast from slow in CI
dotnet test --filter Category=Unit
# With coverage, for a report rather than a gate
dotnet test --collect:"XPlat Code Coverage" --results-directory ./TestResults⚠️
A coverage percentage is not a quality measure. Chasing a number produces assertion-free tests that execute code without checking anything, and those tests fail to detect the bugs they were supposed to catch while slowing every build.
FAQ
How do I test code that depends on the current time?
Inject an IClock or a TimeProvider. DateTime.UtcNow inside a method is untestable without global state, and TimeProvider is now the platform-provided abstraction.
Why did my parallel test suite start failing?
Because two collections share state, usually a database or a static cache. Give each collection its own fixture data, or put the tests in one collection to serialise them.
Related
Dependency injection and the generic host Writing and publishing NuGet libraries
Last refreshed 2026-09-18.