All skills

Test C# and .NET code with xUnit, FluentAssertions, mocks, integration tests, and clear test structure.

  • 1 file
  • 14.8 KB
  • Updated 4 weeks ago
  • GitHub

Use this Skill: https://skilld.dev/gh/agenticluke/dotnet-test-builder-plus/skill

This session only. Nothing lands on disk.

SKILL.md

≈28 tokens always: the name and description. ≈3.8k when used: this file.

C# Testing Patterns

Original skill by ECC. Keep this credit when you copy or change this skill.

Use these patterns to write clear and safe tests for C# and .NET apps.

When to Use This Skill

Use this skill when you:

  • Write new C# tests.
  • Review test quality or coverage.
  • Set up tests for a .NET project.
  • Fix slow or flaky tests.
  • Add unit, API, database, or cancel tests.

Main Tools

Tool Use
xUnit Run tests
FluentAssertions Write clear checks
NSubstitute or Moq Replace app parts in unit tests
WebApplicationFactory Test ASP.NET Core APIs
Testcontainers Test with a real database or service
Bogus Make test data

Use one mock tool per test project. Do not mix NSubstitute and Moq without a clear need.

Core Rules

  • Test what the user can see.
  • Keep each test about one case.
  • Use Arrange, Act, and Assert.
  • Give each test its own data.
  • Do not depend on test order.
  • Do not use a real clock, random value, web call, or shared file in a unit test.
  • Pass a CancellationToken to async code.
  • Use async Task, not async void.
  • Check both good and bad paths.
  • Add a test when you fix a bug.

Name tests like this:

Method_ExpectedResult_WhenCondition

Unit Test Structure

public sealed class OrderServiceTests
{
    private readonly IOrderRepository _repository =
        Substitute.For<IOrderRepository>();

    private readonly ILogger<OrderService> _logger =
        Substitute.For<ILogger<OrderService>>();

    private readonly OrderService _sut;

    public OrderServiceTests()
    {
        _sut = new OrderService(_repository, _logger);
    }

    [Fact]
    public async Task PlaceOrderAsync_ReturnsSuccess_WhenRequestIsValid()
    {
        // Arrange
        var request = new CreateOrderRequest
        {
            CustomerId = "cust-123",
            Items = [new OrderItem("SKU-001", 2, 29.99m)]
        };

        // Act
        var result = await _sut.PlaceOrderAsync(
            request,
            CancellationToken.None);

        // Assert
        result.IsSuccess.Should().BeTrue();
        result.Value.Should().NotBeNull();
        result.Value!.CustomerId.Should().Be("cust-123");
    }

    [Fact]
    public async Task PlaceOrderAsync_ReturnsFailure_WhenItemsAreEmpty()
    {
        // Arrange
        var request = new CreateOrderRequest
        {
            CustomerId = "cust-123",
            Items = []
        };

        // Act
        var result = await _sut.PlaceOrderAsync(
            request,
            CancellationToken.None);

        // Assert
        result.IsSuccess.Should().BeFalse();
        result.Error.Should().Contain("at least one item");
    }
}

Only remove Arrange comments when the test is still easy to scan.

Tests with Many Inputs

Use [Theory] when the same rule needs many values.

[Theory]
[InlineData("", false)]
[InlineData("a", false)]
[InlineData("ab@c.d", false)]
[InlineData("user@example.com", true)]
[InlineData("user+tag@example.co.uk", true)]
public void IsValidEmail_ReturnsExpectedResult(
    string email,
    bool expected)
{
    EmailValidator.IsValid(email).Should().Be(expected);
}

Use MemberData for larger objects.

[Theory]
[MemberData(nameof(InvalidOrderCases))]
public async Task PlaceOrderAsync_ReturnsFailure_WhenOrderIsInvalid(
    CreateOrderRequest request,
    string expectedError)
{
    var result = await _sut.PlaceOrderAsync(
        request,
        CancellationToken.None);

    result.IsSuccess.Should().BeFalse();
    result.Error.Should().Contain(expectedError);
}

public static TheoryData<CreateOrderRequest, string> InvalidOrderCases => new()
{
    {
        new CreateOrderRequest
        {
            CustomerId = "",
            Items = [ValidItem()]
        },
        "CustomerId"
    },
    {
        new CreateOrderRequest
        {
            CustomerId = "c1",
            Items = []
        },
        "at least one item"
    },
    {
        new CreateOrderRequest
        {
            CustomerId = "c1",
            Items = [new OrderItem("", 1, 10m)]
        },
        "SKU"
    }
};

Add cases for empty text, spaces, zero, negative values, limits, and null when the type allows it.

Mocks with NSubstitute

Mock code outside the part being tested. Do not mock simple data objects.

[Fact]
public async Task GetOrderAsync_ReturnsNull_WhenOrderDoesNotExist()
{
    // Arrange
    var orderId = Guid.NewGuid();

    _repository
        .FindByIdAsync(orderId, Arg.Any<CancellationToken>())
        .Returns((Order?)null);

    // Act
    var result = await _sut.GetOrderAsync(
        orderId,
        CancellationToken.None);

    // Assert
    result.Should().BeNull();
}

[Fact]
public async Task PlaceOrderAsync_SavesOrder_WhenRequestIsValid()
{
    // Arrange
    var request = ValidOrderRequest();

    // Act
    await _sut.PlaceOrderAsync(request, CancellationToken.None);

    // Assert
    await _repository.Received(1).AddAsync(
        Arg.Is<Order>(order =>
            order.CustomerId == request.CustomerId),
        Arg.Any<CancellationToken>());
}

Only check calls when the call itself matters. Prefer checking the result or saved state.

Error and Cancel Tests

Check the exact error type when it is part of the app contract.

[Fact]
public async Task GetOrderAsync_Throws_WhenRepositoryFails()
{
    var orderId = Guid.NewGuid();

    _repository
        .FindByIdAsync(orderId, Arg.Any<CancellationToken>())
        .Returns<Task<Order?>>(_ =>
            throw new InvalidOperationException("Database failed"));

    var act = () => _sut.GetOrderAsync(
        orderId,
        CancellationToken.None);

    await act.Should()
        .ThrowAsync<InvalidOperationException>()
        .WithMessage("Database failed");
}

[Fact]
public async Task PlaceOrderAsync_Stops_WhenCanceled()
{
    using var source = new CancellationTokenSource();
    source.Cancel();

    var act = () => _sut.PlaceOrderAsync(
        ValidOrderRequest(),
        source.Token);

    await act.Should().ThrowAsync<OperationCanceledException>();
}

Do not check full error text if the text is not part of the contract. Text may change often.

ASP.NET Core API Tests

Use WebApplicationFactory<Program> to test routes, status codes, headers, and JSON.

Use a unique database name for each test class. Shared names can leak data across tests.

public sealed class OrderApiTests :
    IClassFixture<WebApplicationFactory<Program>>
{
    private readonly HttpClient _client;

    public OrderApiTests(WebApplicationFactory<Program> factory)
    {
        var databaseName = $"OrderApiTests-{Guid.NewGuid()}";

        _client = factory.WithWebHostBuilder(builder =>
        {
            builder.ConfigureServices(services =>
            {
                services.RemoveAll<DbContextOptions<AppDbContext>>();

                services.AddDbContext<AppDbContext>(options =>
                    options.UseInMemoryDatabase(databaseName));
            });
        }).CreateClient();
    }

    [Fact]
    public async Task GetOrder_Returns404_WhenOrderDoesNotExist()
    {
        var response = await _client.GetAsync(
            $"/api/orders/{Guid.NewGuid()}");

        response.StatusCode.Should().Be(HttpStatusCode.NotFound);
    }

    [Fact]
    public async Task CreateOrder_Returns201_WhenRequestIsValid()
    {
        var request = new CreateOrderRequest
        {
            CustomerId = "cust-1",
            Items = [new OrderItem("SKU-001", 1, 19.99m)]
        };

        var response = await _client.PostAsJsonAsync(
            "/api/orders",
            request);

        response.StatusCode.Should().Be(HttpStatusCode.Created);
        response.Headers.Location.Should().NotBeNull();
    }

    [Fact]
    public async Task CreateOrder_Returns400_WhenItemsAreEmpty()
    {
        var request = new CreateOrderRequest
        {
            CustomerId = "cust-1",
            Items = []
        };

        var response = await _client.PostAsJsonAsync(
            "/api/orders",
            request);

        response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
    }
}

The EF Core in-memory store does not act like a real SQL database. It may hide SQL, key, and transaction bugs. Use it for simple API tests. Use Testcontainers when database rules matter.

Database Tests with Testcontainers

These tests need Docker. Pin the image version so test runs stay stable.

public sealed class PostgresOrderRepositoryTests : IAsyncLifetime
{
    private readonly PostgreSqlContainer _postgres =
        new PostgreSqlBuilder()
            .WithImage("postgres:16-alpine")
            .Build();

    private AppDbContext _db = null!;

    public async Task InitializeAsync()
    {
        await _postgres.StartAsync();

        var options =
            new DbContextOptionsBuilder<AppDbContext>()
                .UseNpgsql(_postgres.GetConnectionString())
                .Options;

        _db = new AppDbContext(options);
        await _db.Database.MigrateAsync();
    }

    public async Task DisposeAsync()
    {
        await _db.DisposeAsync();
        await _postgres.DisposeAsync();
    }

    [Fact]
    public async Task AddAsync_SavesOrder()
    {
        var repository = new SqlOrderRepository(_db);
        var order = Order.Create(
            "cust-1",
            [new OrderItem("SKU-001", 2, 10m)]);

        await repository.AddAsync(
            order,
            CancellationToken.None);

        _db.ChangeTracker.Clear();

        var found = await repository.FindByIdAsync(
            order.Id,
            CancellationToken.None);

        found.Should().NotBeNull();
        found!.Items.Should().HaveCount(1);
    }
}

Clear the EF Core change tracker before reading data back. This helps prove that the data came from the database.

If Docker is not ready, fail with a clear message. Do not hide the test by catching all errors.

Test Data Builders

Builders keep setup short. Their default data must be valid.

public sealed class OrderBuilder
{
    private string _customerId = "cust-default";

    private readonly List<OrderItem> _items =
        [new OrderItem("SKU-001", 1, 10m)];

    public OrderBuilder WithCustomer(string customerId)
    {
        _customerId = customerId;
        return this;
    }

    public OrderBuilder WithItem(
        string sku,
        int quantity,
        decimal price)
    {
        _items.Add(new OrderItem(sku, quantity, price));
        return this;
    }

    public OrderBuilder WithoutItems()
    {
        _items.Clear();
        return this;
    }

    public Order Build()
    {
        return Order.Create(_customerId, [.. _items]);
    }
}

Concrete use:

[Fact]
public void Build_CreatesVipOrder_WithPremiumItem()
{
    var order = new OrderBuilder()
        .WithCustomer("cust-vip")
        .WithItem("SKU-PREMIUM", 3, 99.99m)
        .Build();

    order.CustomerId.Should().Be("cust-vip");
    order.Items.Should().Contain(item =>
        item.Sku == "SKU-PREMIUM" &&
        item.Quantity == 3 &&
        item.Price == 99.99m);
}

Return a copy of lists when needed. This stops one test from changing data used by another test.

Time, Random Data, and Culture

Do not use DateTime.Now in code that must be tested. Pass in TimeProvider or a clock type.

Set a fixed random seed when random data is needed:

Randomizer.Seed = new Random(12345);
var faker = new Faker();

Add culture tests when code reads or writes dates, money, or numbers. Do not assume every system uses the same date or decimal format.

Slow or Flaky Tests

When a test fails only at times:

  • Check for shared state.
  • Check for fixed ports and file names.
  • Check for real clock use.
  • Check for random data with no fixed seed.
  • Check for tests that depend on run order.
  • Check for tasks that were not awaited.
  • Check for database data left by an old test.
  • Check for strict time limits.
  • Check for real network calls.

Do not use Thread.Sleep.

For code that changes later, poll until a short time limit ends:

private static async Task WaitUntilAsync(
    Func<bool> condition,
    TimeSpan timeout)
{
    var stopAt = DateTime.UtcNow + timeout;

    while (!condition())
    {
        if (DateTime.UtcNow >= stopAt)
        {
            throw new TimeoutException(
                "The test condition was not met in time.");
        }

        await Task.Delay(20);
    }
}

Use this only when the app work is truly done in the background. Prefer a task or event that can be awaited.

Test Layout

tests/
  MyApp.UnitTests/
    Services/
      OrderServiceTests.cs
      PaymentServiceTests.cs
    Validators/
      EmailValidatorTests.cs
  MyApp.IntegrationTests/
    Api/
      OrderApiTests.cs
    Repositories/
      OrderRepositoryTests.cs
  MyApp.TestHelpers/
    Builders/
      OrderBuilder.cs
    Fixtures/
      DatabaseFixture.cs

Keep unit and integration tests in separate projects when they need different tools or run times.

Common Problems

Problem Fix
Tests check private code Check public results and behavior
Tests share data they can change Make fresh data for each test
Tests need a set run order Remove the link between tests
Async tests use Thread.Sleep Await work or poll with a short limit
Tests use async void Return Task
Tests check ToString() Check typed fields
One test checks many cases Split it into focused tests
Names tell how code works Name the case and result
Code drops the cancel token Pass it and test cancel behavior
Unit tests call real services Use a fake or mock
Database tests pass only in memory Test key SQL rules with a real database
Tests use the real clock Pass in a clock
Random tests fail at times Use a fixed seed
Parallel tests use one file or port Give each test a unique name or port
Cleanup hides the first error Keep cleanup safe and simple

Run Tests

# Run all tests
dotnet test

# Run tests and collect coverage
dotnet test --collect:"XPlat Code Coverage"

# Run one project
dotnet test tests/MyApp.UnitTests/

# Run tests with a matching name
dotnet test --filter "FullyQualifiedName~OrderService"

# Keep running tests while files change
dotnet watch test --project tests/MyApp.UnitTests/

A high coverage number does not prove good tests. Cover key rules, errors, limits, and user paths first.

Final Check

Before you finish, make sure:

  • The test name states the case and result.
  • The test can run by itself.
  • The result does not depend on time or run order.
  • All async work is awaited.
  • Good, bad, empty, limit, and cancel cases are covered when they matter.
  • Unit tests do not call real outside services.
  • Integration tests clean up their own data.
  • The full test suite passes.

Source: SKILL.md on GitHub

No third-party reports yet.

Signed by skilld at ff17395. This ties the file your Agent reads to that commit on GitHub. It does not review the instructions.

Last checked against GitHub 4 weeks ago.

Activeupdated 4 weeks ago
origin
ECC

README badge

README badge for agenticluke/dotnet-test-builder-plus