---
title: "skill by agenticluke · skilld"
canonical_url: "https://skilld.dev/gh/agenticluke/dotnet-test-builder-plus"
meta:
  description: "Test C# and .NET code with xUnit, FluentAssertions, mocks, integration tests, and clear test structure. From agenticluke/dotnet-test-builder-plus."
  "og:description": "Test C# and .NET code with xUnit, FluentAssertions, mocks, integration tests, and clear test structure. From agenticluke/dotnet-test-builder-plus."
  "og:title": "skill by agenticluke"
  "twitter:description": "Test C# and .NET code with xUnit, FluentAssertions, mocks, integration tests, and clear test structure. From agenticluke/dotnet-test-builder-plus."
  "twitter:title": "skill by agenticluke"
---

`

[All skills](https://skilld.dev/skills)

[![agenticluke avatar](https://skilld.dev/_img/avatar?url=https%3A%2F%2Fgithub.com%2Fagenticluke.png%3Fsize%3D96)](https://skilld.dev/gh/agenticluke)

# **/skill**

[@ff17395](https://github.com/agenticluke/dotnet-test-builder-plus/commit/ff1739513f6d8ef17ed1203da9dc89c22c161f06 "Your agent reads SKILL.md at commit ff17395")

by [agenticluke](https://skilld.dev/gh/agenticluke)· [agenticluke](https://skilld.dev/gh/agenticluke)/ [dotnet-test-builder-plus](https://skilld.dev/gh/agenticluke/dotnet-test-builder-plus)

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](https://github.com/agenticluke/dotnet-test-builder-plus/blob/ff1739513f6d8ef17ed1203da9dc89c22c161f06/skill/SKILL.md "View SKILL.md on GitHub")

## SKILL.md

14.8 KB

**≈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](https://github.com/agenticluke/dotnet-test-builder-plus/blob/ff1739513f6d8ef17ed1203da9dc89c22c161f06/skill/SKILL.md)

## Third-party checks

No third-party reports yet.

## Provenance

[Signed by skilld at ff17395.](https://github.com/agenticluke/dotnet-test-builder-plus/commit/ff1739513f6d8ef17ed1203da9dc89c22c161f06 "ff1739513f6d8ef17ed1203da9dc89c22c161f06") 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

## Capability

<dl>

<dt>origin</dt>
<dd>ECC</dd>

</dl>

## README badge

![README badge for agenticluke/dotnet-test-builder-plus](https://skilld.dev/b/agenticluke/dotnet-test-builder-plus?theme=light&label=0)

## Related skills

-
-
-
-
-
-