Skip to content
End To End Tester
Languagespractical

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

xml
<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

csharp
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:

csharp
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

csharp
// 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:

csharp
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

csharp
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

bash
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:Html

See 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.
  • TimeProvider and FakeTimeProvider as 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 test has 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?