C# Test Instructions

June 9, 2026 ยท View on GitHub

Conventions for C# test code. All conventions from csharp.instructions.md apply, including member ordering and field naming with underscore prefix.

Test Framework

Use XUnit with NSubstitute for mocking. Focus on class behaviors rather than implementation details. Follow BDD-style naming and Arrange/Act/Assert structure.

Mocking Libraries

LibraryUsage
NSubstitutePreferred for new projects
FakeItEasyAcceptable alternative
MoqExisting projects only (pin to 4.18.x or 4.20.2+)

Test Naming

Test file naming matches the class under test: PipelineServiceTests.

Test method format: GivenContext_WhenAction_ExpectedResult

WhenValidRequest_ProcessDataAsync_ReturnsParsedResponse
GivenEmptyInput_ProcessDataAsync_ThrowsArgumentException

Prefer one assertion per test. Related assertions validating the same behavior are acceptable. Do not verify logger mocks.

Use [Theory] with [InlineData] for simple parameterized cases or [MemberData] for complex test data.

Test Organization

  • Fields at class top, alphabetically by name after underscore (_httpClient before _sut), readonly when possible
  • Service under test named _sut
  • Utility methods after constructor, before test methods
  • Test methods grouped by behavior, alphabetically within groups
  • Common mock setup in constructor; specific setup in test methods

NSubstitute Patterns

Common mocking patterns:

// Create substitutes
var service = Substitute.For<IDataService>();
var options = Substitute.For<IOptions<Config>>();

// Configure returns
service.GetAsync(Arg.Any<int>()).Returns(Task.FromResult(data));
options.Value.Returns(new Config { Endpoint = "https://api.test" });

// Argument matching
service.Process(Arg.Is<Request>(r => r.Id > 0)).Returns(result);

// Verify calls
await service.Received(1).SaveAsync(Arg.Any<Data>());
service.DidNotReceive().Delete(Arg.Any<int>());

Lifecycle Interfaces

Implement IAsyncLifetime for per-test setup and teardown:

  • InitializeAsync runs before each test
  • DisposeAsync runs after each test

Base Classes

Create base classes when multiple test classes share setup logic. Name base class *TestsBase and derived class ClassUnderTest_GivenContext or ClassUnderTest_WhenAction. Define fake classes once in the base class.

Complete Example

Using NSubstitute:

public class EndpointDataProcessorTests
{
    private readonly HttpClient _httpClient;
    private readonly MockHttpMessageHandler _httpHandler = new();
    private readonly IOptions<PipelineOptions> _options;
    private readonly EndpointDataProcessor<FakeSource, FakeSink> _sut;

    public EndpointDataProcessorTests()
    {
        _options = Substitute.For<IOptions<PipelineOptions>>();
        _options.Value.Returns(new PipelineOptions { EndpointUri = "https://test.com/predict" });

        _httpClient = new HttpClient(_httpHandler);
        _sut = new EndpointDataProcessor<FakeSource, FakeSink>(_options, _httpClient);
    }

    [Fact]
    public async Task WhenValidRequest_ProcessDataAsync_ReturnsParsedResponse()
    {
        // Arrange
        var expected = new FakeSink { Result = "Processed", Score = 0.95 };
        _httpHandler.Response = new HttpResponseMessage(HttpStatusCode.OK)
        {
            Content = new StringContent(JsonSerializer.Serialize(expected))
        };

        // Act
        var actual = await _sut.ProcessDataAsync(new FakeSource { Id = 1 }, CancellationToken.None);

        // Assert
        Assert.NotNull(actual);
        Assert.Equivalent(expected, actual);
    }

    [Fact]
    public async Task WhenServerError_ProcessDataAsync_ThrowsHttpRequestException()
    {
        // Arrange
        _httpHandler.Response = new HttpResponseMessage(HttpStatusCode.InternalServerError);

        // Act & Assert
        await Assert.ThrowsAsync<HttpRequestException>(
            () => _sut.ProcessDataAsync(new FakeSource { Id = 1 }, CancellationToken.None));
    }

    public record FakeSource { public int Id { get; init; } }
    public record FakeSink { public string? Result { get; init; } public double Score { get; init; } }

    private class MockHttpMessageHandler : HttpMessageHandler
    {
        public HttpResponseMessage Response { get; set; } = new(HttpStatusCode.OK);
        protected override Task<HttpResponseMessage> SendAsync(
            HttpRequestMessage request, CancellationToken cancellationToken) => Task.FromResult(Response);
    }
}