xUnit Testing & CI/CD in .NET

xUnit Testing & CI/CD in .NET

Explore xunit testing & ci/cd in .net code snippets and tutorials

xUnit Testing & CI/CD in .NET

Unit testing a C# HttpClient wrapper with a fake HttpMessageHandler (xUnit, WireMock.Net)

Don't mock HttpClient.

csharp
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// ======================================================================
// Q: How do you unit test a class that uses HttpClient?
//
// Don't mock HttpClient. Give it a fake HttpMessageHandler: all of your own code
// (serialization, headers, status codes) runs as normal.
// Alternatively: WireMock.Net (a real HTTP server inside the test) for
// integration tests of the client.
// ======================================================================

public class StubHandler(HttpStatusCode status, string json) : HttpMessageHandler
{
    public HttpRequestMessage? LastRequest { get; private set; }

    protected override Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request, CancellationToken ct)
    {
        LastRequest = request;
        return Task.FromResult(new HttpResponseMessage(status)
        {
            Content = new StringContent(json, Encoding.UTF8, "application/json")
        });
    }
}

[Fact]
public async Task Erp_422_becomes_ErpRejectedException()
{
    var handler = new StubHandler(HttpStatusCode.UnprocessableEntity, """{"MESSAGE":"discontinued"}""");
    var client = new ErpClient(new HttpClient(handler) { BaseAddress = new("http://erp/") });

    await Assert.ThrowsAsync<ErpRejectedException>(() => client.SendOrderAsync(order, default));
    Assert.True(handler.LastRequest!.Headers.Contains("Idempotency-Key"));
}
xUnit Testing & CI/CD in .NET

Testing time-dependent C# code with TimeProvider and FakeTimeProvider (.NET 8), Random and Guid

Turn them into dependencies instead of static calls.

csharp
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// ======================================================================
// Q: How do you test code that depends on time, random values or Guid?
//
// Turn them into dependencies instead of static calls. For time: TimeProvider
// (.NET 8+) and FakeTimeProvider in tests.
// Same idea for Random (a seed or an interface) and Guid (a generator interface,
// or only asserting that it is not Guid.Empty).
// ======================================================================

public class CreateOrderHandler(IOrderRepository orders, TimeProvider clock) { /* clock.GetUtcNow() */ }

[Fact]
public async Task Order_number_contains_creation_date()
{
    var clock = new FakeTimeProvider(new DateTimeOffset(2026, 11, 6, 10, 0, 0, TimeSpan.Zero));
    var handler = new CreateOrderHandler(orders, clock);

    var order = await handler.Handle(command, CancellationToken.None);

    Assert.StartsWith("ORD-20261106-", order.OrderNumber);
}

// Timeouts / retries without real waiting
clock.Advance(TimeSpan.FromMinutes(31));      // half an hour "passes" instantly
xUnit Testing & CI/CD in .NET

Testing async code and exceptions in xUnit: Assert.ThrowsAsync, Throws and ThrowsAny in C#

A forgotten await on Assert.ThrowsAsync means the test always passes.

csharp
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
// ======================================================================
// Q: How do you test async code and exceptions?
//
// - A forgotten await on Assert.ThrowsAsync means the test always passes. The
//   xUnit analyzer catches it (keep the analyzers enabled).
// - Never use .Result / .Wait() in tests: deadlocks and hidden
//   AggregateExceptions.
// ======================================================================

[Fact]
public async Task Place_without_stock_throws()          // async Task, never async void
{
    stock.TryReserveAsync("LIM-001", 5, Arg.Any<CancellationToken>()).Returns(false);

    var ex = await Assert.ThrowsAsync<DomainException>(
        () => handler.Handle(new PlaceOrder(orderId), CancellationToken.None));

    Assert.Contains("Insufficient stock", ex.Message);
}

// Synchronous: Assert.Throws<T> (exact type) or Assert.ThrowsAny<T> (subclasses too)
Assert.Throws<ArgumentException>(() => new Money(10m, "euro"));
xUnit Testing & CI/CD in .NET

Mocks, stubs and fakes in C# unit tests: mocking with NSubstitute and Moq in xUnit

Stub: returns canned answers (e.g.

csharp
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
// ======================================================================
// Q: Mocks, stubs and fakes? How do you mock with NSubstitute or Moq?
//
// - Stub: returns canned answers (e.g. "the ERP responds 4500100001").
// - Mock: you verify it was called with specific arguments (e.g. "an email was
//   sent").
// - Fake: a simple working implementation (in-memory repository,
//   FakeTimeProvider).
// - Don't mock what you don't own (DbContext, HttpClient). Mock your own
//   abstractions and cover the external ones with integration tests.
// - Over-mocking: if a test has 8 mocks and checks the order of calls, it tests
//   the implementation and breaks on every refactoring. Prefer fakes and
//   asserting on the outcome.
// ======================================================================

[Fact]
public async Task Rejected_by_erp_releases_stock_and_rejects_order()
{
    // Arrange
    var order = new OrderBuilder().WithLine("OLD-001", 3).Placed().Build();
    var orders = Substitute.For<IOrderRepository>();
    orders.GetForSystemAsync(order.Id, Arg.Any<CancellationToken>()).Returns(order);   // stub

    var erp = Substitute.For<IErpClient>();
    erp.SendOrderAsync(order, Arg.Any<CancellationToken>())
       .ThrowsAsync(new ErpRejectedException("Material OLD-001 is discontinued"));

    var stock = Substitute.For<IStockService>();
    var handler = new SyncOrderToErpHandler(orders, erp, stock, /* ... */);

    // Act
    await handler.Handle(new SyncOrderToErp(order.Id), CancellationToken.None);

    // Assert
    Assert.Equal(OrderStatus.Rejected, order.Status);
    await stock.Received(1).ReleaseAsync("OLD-001", 3, Arg.Any<CancellationToken>());   // mock
}

// The same with Moq
var erpMock = new Mock<IErpClient>();
erpMock.Setup(e => e.SendOrderAsync(It.IsAny<Order>(), It.IsAny<CancellationToken>()))
       .ReturnsAsync(new ErpOrderResult("4500100001"));
erpMock.Verify(e => e.SendOrderAsync(order, It.IsAny<CancellationToken>()), Times.Once);
xUnit Testing & CI/CD in .NET

xUnit test lifecycle in C#: constructor setup, IDisposable teardown, class and collection fixtures

xUnit creates a new instance of the test class for every test.

csharp
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// ======================================================================
// Q: How does the xUnit test lifecycle work? Setup, teardown and fixtures?
//
// xUnit creates a new instance of the test class for every test. There is no
// [SetUp] / [TearDown] as in NUnit / MSTest:
// - Per-test setup: the constructor in xUnit; [SetUp] / [TestInitialize] in
//   NUnit / MSTest.
// - Per-test cleanup: IDisposable / IAsyncLifetime in xUnit; [TearDown] /
//   [TestCleanup] in NUnit / MSTest.
// - Shared by all tests of a class: IClassFixture<T> in xUnit; [OneTimeSetUp] /
//   [ClassInitialize] in NUnit / MSTest.
// - Shared by many classes: ICollectionFixture<T> + [Collection] in xUnit;
//   [SetUpFixture] / [AssemblyInitialize] in NUnit / MSTest.
// - Parallelism: collections run in parallel, tests within one collection run
//   sequentially. By default every test class is its own collection. Tests that
//   share a database go in the same collection.
// - xUnit v3 (package xunit.v3): test projects are executables, with
//   TestContext.Current.CancellationToken, dynamic Assert.Skip, and
//   IAsyncLifetime using ValueTask.
// ======================================================================

// Expensive resource (PostgreSQL container), created once for all tests in the collection
public class PostgresFixture : IAsyncLifetime
{
    private readonly PostgreSqlContainer _db = new PostgreSqlBuilder().Build();
    public string ConnectionString => _db.GetConnectionString();

    public Task InitializeAsync() => _db.StartAsync();            // v3: ValueTask
    public Task DisposeAsync() => _db.DisposeAsync().AsTask();
}

[CollectionDefinition("db")]
public class DbCollection : ICollectionFixture<PostgresFixture>;

[Collection("db")]
public class OrderRepositoryTests(PostgresFixture db)    // injected by xUnit
{
    [Fact]
    public async Task Saves_and_loads_order() { /* uses db.ConnectionString */ }
}