Writing Tests in C#
The .NET testing stack — xUnit, Moq or NSubstitute, WebApplicationFactory, Testcontainers — and the dependency injection story that makes it the most testable of the four.
1 min read · updated 19 September 2026
.NET has the most coherent testing story of the four languages covered here, largely because dependency injection is built into the platform rather than bolted on. Substituting a dependency is a supported operation at every level from a constructor to a whole web application.
#The stack
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.*" />
<PackageReference Include="xunit" Version="2.*" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.*" />
<PackageReference Include="NSubstitute" Version="5.*" />
<PackageReference Include="Shouldly" Version="4.*" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.*" />
<PackageReference Include="Testcontainers.PostgreSql" Version="4.*" />
<PackageReference Include="coverlet.collector" Version="6.*" />
</ItemGroup>#Unit tests
public class PricingTests
{
[Theory]
[InlineData(12_000, 2_400)]
[InlineData(10_000, 2_000)]
[InlineData(9_999, 0)]
public void Discount_applies_at_or_above_the_threshold(int subtotal, int expected)
{
var result = Pricing.ApplyDiscount(new Order(subtotal), new DiscountPolicy(10_000, 20));
result.DiscountCents.ShouldBe(expected);
}
[Fact]
public void Reserving_more_than_stock_throws_with_the_sku_named()
{
var service = new OrderService(new StubInventory(available: 1), TimeProvider.System);
Should.Throw<InsufficientStockException>(() => service.Reserve("book-1", 2))
.Message.ShouldContain("book-1");
}
}#TimeProvider
.NET 8 added TimeProvider, which removed the last common excuse for
untestable time-dependent code:
public class TokenService(TimeProvider clock)
{
public bool IsExpired(Token token) => token.ExpiresAt <= clock.GetUtcNow();
}
[Fact]
public void A_token_expires_exactly_at_its_expiry()
{
var clock = new FakeTimeProvider(new DateTimeOffset(2026, 1, 1, 13, 0, 0, TimeSpan.Zero));
var service = new TokenService(clock);
var token = new Token(expiresAt: clock.GetUtcNow());
service.IsExpired(token).ShouldBeTrue();
clock.Advance(TimeSpan.FromSeconds(-1)); // FakeTimeProvider, from the test package
service.IsExpired(token).ShouldBeFalse();
}FakeTimeProvider also controls Task.Delay and timers, so retry and
backoff logic becomes testable with no real waiting. See
dependency injection.
#Doubles
// NSubstitute — lighter syntax than Moq for the same capability
var gateway = Substitute.For<IPaymentGateway>();
gateway.ChargeAsync(Arg.Any<ChargeRequest>()).Returns(ChargeResult.Succeeded("pi_1"));
await new Checkout(gateway).PayAsync(order);
await gateway.Received(1).ChargeAsync(Arg.Is<ChargeRequest>(r => r.Cents == 4_000));See Moq for the alternative, and test doubles for when a hand-written fake beats both.
#Component tests with WebApplicationFactory
The most valuable tests in a typical .NET service, and the reason the ecosystem's testing story is strong:
public class ApiFactory : WebApplicationFactory<Program>, IAsyncLifetime
{
private readonly PostgreSqlContainer _db = new PostgreSqlBuilder()
.WithImage("postgres:16-alpine").Build();
public FakePaymentGateway Payments { get; } = new();
public Task InitializeAsync() => _db.StartAsync();
protected override void ConfigureWebHost(IWebHostBuilder builder) =>
builder.ConfigureTestServices(services =>
{
services.RemoveAll<IPaymentGateway>();
services.AddSingleton<IPaymentGateway>(Payments); // only the boundary
services.Configure<DbOptions>(o => o.ConnectionString = _db.GetConnectionString());
});
public new Task DisposeAsync() => _db.DisposeAsync().AsTask();
}
public class OrdersApiTests(ApiFactory factory) : IClassFixture<ApiFactory>
{
[Fact]
public async Task A_declined_card_returns_402_and_leaves_no_order()
{
var client = factory.CreateClient();
factory.Payments.DeclineNext();
var response = await client.PostAsJsonAsync("/orders", new { sku = "book-1", quantity = 1 });
response.StatusCode.ShouldBe(HttpStatusCode.PaymentRequired);
(await client.GetFromJsonAsync<OrderView[]>("/orders")).ShouldBeEmpty();
}
}Real routing, real middleware, real model binding, real authorization filters, real EF Core against a real Postgres. Roughly 300ms. See component testing.
#Integration tests with a transaction
public class OrderRepositoryTests(PostgresFixture fixture) : IClassFixture<PostgresFixture>, IAsyncLifetime
{
private NpgsqlConnection _connection = null!;
private NpgsqlTransaction _transaction = null!;
public async Task InitializeAsync()
{
_connection = new NpgsqlConnection(fixture.ConnectionString);
await _connection.OpenAsync();
_transaction = await _connection.BeginTransactionAsync();
}
// Never committed: perfect isolation, no cleanup, parallel-safe.
public async Task DisposeAsync() => await _transaction.RollbackAsync();
}#Running it
dotnet test # everything
dotnet test --filter "Category!=Integration" # the fast suite
dotnet test --collect:"XPlat Code Coverage" # coverlet
dotnet test --logger "trx;LogFileName=results.trx" # for Azure DevOps
# Report from the cobertura output
dotnet tool install -g dotnet-reportgenerator-globaltool
reportgenerator -reports:"**/coverage.cobertura.xml" -targetdir:coverage -reporttypes:HtmlSee code coverage and Azure DevOps pipelines.
#Where C# is genuinely ahead
- Dependency injection in the framework, so substitution is a supported operation at every layer.
WebApplicationFactory, which has no direct equivalent of comparable quality in the other three ecosystems.TimeProviderandFakeTimeProvideras first-party abstractions.- Compile-time safety in tests, the same argument as TypeScript but with a stronger type system.
#Where it is behind
- Test startup.
dotnet testhas meaningful overhead; a JVM or Node suite of the same size often starts faster. - Browser testing is a second-class citizen — Playwright for .NET is good, but the ecosystem, examples and community answers are all in TypeScript. Many .NET shops write their end-to-end suite in TypeScript for exactly this reason.
Common questions
- What is the standard C# testing stack in 2026?
- xUnit as the runner, NSubstitute or Moq for doubles, Shouldly or FluentAssertions for readability, WebApplicationFactory for in-process API tests, and Testcontainers for real databases. That combination covers nearly everything and has been stable for years.
- How do I test ASP.NET Core endpoints without deploying?
- WebApplicationFactory<Program> starts the whole application in-process — real routing, real middleware, real DI — and gives you an HttpClient. Override only the services that cross a process boundary and you have a fast, realistic component test.
- Should I use FluentAssertions?
- Its failure messages are substantially better than the built-in ones, which is worth a dependency. Note that version 8 moved to a commercial licence for non-open-source use; version 7 remains free, and Shouldly is a permissively licensed alternative.
Runnable samples for this page
last test results ↗- C#
dotnet/Tests.XUnit/languages/csharp
Working tests, not fragments — they run in CI on every push to 8exgh/endtoendtester-samples.
Was this page useful?
Related topics
- xUnit.netThe .NET runner with a fresh instance per test — Facts, Theories, fixtures, parallelism and the design opinions baked into it.
- NUnitThe longest-serving .NET test framework — its constraint-based assertion model, attribute set, and the shared-instance behaviour you have to work with.
- MoqThe .NET mocking library in practical detail — setups, argument matchers, verification, loose versus strict behaviour, and what to use instead where it strains.
- Component TestingTesting one deployable in isolation through its real interface, with its own dependencies containerised and everything beyond its boundary stubbed.
- TestcontainersRunning real databases, brokers and services as disposable containers from inside your test suite — the pattern that made integration testing cheap.
- Dependency Injection for TestabilityWhy injected dependencies are the one technique that makes isolated testing possible, the three forms of injection, and how to do it without a container.
- Testing in Azure DevOps PipelinesA full YAML pipeline with stages, jobs, parallel test slicing and the best built-in test reporting of any CI platform.
- Writing Tests in JavaJUnit 5, Mockito, AssertJ and Testcontainers — the stack that has been stable for a decade, plus Spring's test slices and what they cost.