# Write Integration Tests

> Use this skill whenever the user asks to write, generate, or add integration tests for C# or .NET code. Trigger on: "write integration tests", "add integration tests", "test this endpoint", "test this API", "integration test for this controller", "test against the database", "write end-to-end tests", "test this repository with a real database", "add integration test coverage". Also trigger when the user shows a controller, repository, or service and asks for tests that require real infrastructure (database, message broker, HTTP, etc.) rather than mocks.

- Skill: `jzills/write-integration-tests` (Agent Skill, multi-file: 2 files)
- Install (CLI): `npx skillmds@latest add jzills/write-integration-tests`
- Raw SKILL.md: https://api.skillmd.com/api/skills/jzills/write-integration-tests/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Integrations & APIs
- Author: jzills (https://skillmd.com/u/jzills)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/jzills/write-integration-tests

---


# .NET Integration Test Writer

You are writing production-quality integration tests for C# code using **NUnit**, **Testcontainers** (or docker-compose), and **FluentAssertions**.
Integration tests verify that your code works correctly against real infrastructure — real databases, real message brokers, real HTTP layers.
They are not unit tests with mocks; the point is to exercise the full stack.

---

## Pre-flight Check

Before writing any tests, do the following:

1. **Read the class or file under test.** Understand its dependencies, what infrastructure it touches (database, broker, HTTP), and what outcomes are observable (HTTP responses, DB rows, published messages).

2. **Look for an existing integration test project.** Search for `.csproj` files that reference `Microsoft.AspNetCore.Mvc.Testing` or any `Testcontainers.*` package. Check sibling directories of the source project (e.g. `MyProject.IntegrationTests`, `MyProject.Tests.Integration`).

3. **If no test project exists**, stop and tell the user:
   > "I don't see an integration test project yet. Run the `scaffold-integration-project` skill first — it will create the project, wire up Testcontainers, and generate the `IntegrationTestBase` fixture. Then come back and I'll write the tests."

4. **If a test project exists**, read its `.csproj` to determine the container approach:
   - If it references any `Testcontainers.*` package → **Testcontainers approach** (see Step 4A below).
   - If it does not → **docker-compose approach** (see Step 4B below).

5. **Look for an `IntegrationTestBase` class** (generated by the `setup-test-infrastructure` skill). If it exists, inherit from it rather than re-declaring lifecycle code.

---

## Step 4 — Infrastructure Sub-skill

If the test project exists but has no container fixture or `IntegrationTestBase`, invoke the infrastructure sub-skill before writing tests:

**REQUIRED SUB-SKILL:** Invoke `setup-test-infrastructure` with args:
`"source csproj: <path-to-source.csproj>, test project dir: <path-to-test-project/>"`

The generated fixture files will be available for the sub-steps below.

---

## Test Structure: AAA

Every test follows **Arrange / Act / Assert**. Separate the three sections with blank lines.
Skip `// Arrange` comments unless the test is unusually long — well-named variables make the sections self-evident.

```csharp
[Test]
public async Task GetProduct_WhenProductExists_Returns200WithBody()
{
    var productId = await SeedProductAsync(name: "Widget", price: 9.99m);

    var response = await Client.GetAsync($"/api/products/{productId}");

    response.StatusCode.Should().Be(HttpStatusCode.OK);
    var body = await response.Content.ReadFromJsonAsync<ProductDto>();
    body.Should().NotBeNull();
    body!.Name.Should().Be("Widget");
    body.Price.Should().Be(9.99m);
}
```

---

## Naming Convention

Use the format: `MethodName_Condition_ExpectedOutcome`

- `CreateOrder_WhenPayloadIsValid_Returns201WithLocation`
- `GetUser_WhenUserDoesNotExist_Returns404`
- `PlaceOrder_WhenStockIsEmpty_PublishesOutOfStockEvent`
- `DeleteProduct_WhenUserIsUnauthorized_Returns401`
- `GetAll_Always_ReturnsOnlyRowsBelongingToTenant` (use `Always` when there is no meaningful condition)

Failures should be self-documenting — the test name alone should tell you exactly what broke.

---

## Fixture Setup

Use `[OneTimeSetUp]` for expensive shared resources (containers, `WebApplicationFactory`, migrations).
Use `[SetUp]` for per-test state: seeding rows, resetting queues, clearing caches.
Use `[TearDown]` to remove test-seeded data so tests remain independent.

```csharp
[TestFixture]
public class ProductsEndpointTests : IntegrationTestBase
{
    private Guid _seededProductId;

    [SetUp]
    public async Task SetUp()
    {
        _seededProductId = await SeedProductAsync(name: "Widget", price: 9.99m);
    }

    [TearDown]
    public async Task TearDown()
    {
        await CleanUpProductsAsync();
    }
}
```

When inheriting from `IntegrationTestBase`, do not redeclare `[OneTimeSetUp]` / `[OneTimeTearDown]` for container lifecycle — the base class owns that. Per-fixture `[SetUp]` and `[TearDown]` in derived classes are fine.

---

## Step 4A — Testcontainers Approach

Inherit from `IntegrationTestBase` (generated by `setup-test-infrastructure`).
`IntegrationTestBase` owns the container lifecycle and exposes `Client` (an `HttpClient`) and `Infrastructure` (the container fixture).

For EF Core tests that need direct DB access, resolve a scoped `DbContext` from the factory's service provider:

```csharp
using var scope = Factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
```

For each scenario, refer to the matching pattern in `references/integration-test-patterns.md`:
- ASP.NET Core controller/endpoint tests → **Pattern 1: WebApplicationFactory pattern**
- EF Core repository tests → **Pattern 2: EF Core repository pattern**
- Any test needing data setup and cleanup → **Pattern 3: Data seeding and cleanup**
- Message consumer/handler tests → **Pattern 4: Message consumer pattern**

---

## Step 4B — Docker Compose Approach

When the test project does not use Testcontainers, note at the top of every generated test file:

```csharp
// Prerequisites: run `docker compose up -d` from the test project root before executing these tests.
// Connection strings are read from environment variables or appsettings.Testing.json.
```

Read connection strings from `IConfiguration` / environment variables — never hardcode them.

```csharp
var connectionString = Environment.GetEnvironmentVariable("ConnectionStrings__Postgres")
    ?? configuration.GetConnectionString("Postgres")
    ?? throw new InvalidOperationException("Postgres connection string not configured.");
```

---

## Test Patterns by Scenario

For each scenario, refer to the concrete C# examples in `references/integration-test-patterns.md`.

| Scenario | Pattern to use |
|---|---|
| ASP.NET Core controller / endpoint | Pattern 1 — WebApplicationFactory |
| EF Core repository with real DB | Pattern 2 — EF Core repository |
| Data setup and cleanup strategies | Pattern 3 — Seeding and cleanup |
| Message consumer / handler | Pattern 4 — Message consumer |

---

## What to Test

For every class or endpoint under test, identify and cover:

- **Happy paths** — valid inputs produce the expected response, DB state, or side effect
- **Not-found cases** — missing resources return 404 (HTTP) or `null` / empty (repositories)
- **Validation failures** — invalid payloads return 400 with a useful problem detail
- **Authorization failures** — unauthenticated requests return 401; unauthorized (wrong role/tenant) return 403
- **Boundary conditions** — empty collections, zero values, maximum lengths

For HTTP tests, always assert **both** the status code and the response body shape.

---

## Assertions with FluentAssertions

FluentAssertions produces failure messages that tell you exactly what went wrong.

```csharp
// HTTP status
response.StatusCode.Should().Be(HttpStatusCode.Created);

// Response body
var body = await response.Content.ReadFromJsonAsync<OrderDto>();
body.Should().NotBeNull();
body!.Id.Should().NotBeEmpty();
body.Total.Should().Be(99.99m);

// Collections
var items = await response.Content.ReadFromJsonAsync<List<ProductDto>>();
items.Should().HaveCount(3);
items.Should().Contain(product => product.IsActive);

// Database state
var row = await db.Orders.FindAsync(orderId);
row.Should().NotBeNull();
row!.Status.Should().Be(OrderStatus.Confirmed);

// Exceptions (async)
Func<Task> act = () => repository.GetByIdAsync(-1);
await act.Should().ThrowAsync<ArgumentOutOfRangeException>();
```

---

## Code Style

**Lambda parameters:** Use the type name (singular) rather than `x`. This makes intent clear without needing to look up what `x` refers to.

```csharp
// Preferred
items.Should().Contain(product => product.IsActive);
items.Should().AllSatisfy(order => order.TenantId.Should().Be(expectedTenantId));

// Avoid
items.Should().Contain(x => x.IsActive);
```

---

## Async Tests

Always `await` async methods. NUnit supports `async Task` test methods natively.
Never use `.Result` or `.Wait()` — they deadlock in async contexts and hide exceptions.

```csharp
[Test]
public async Task CreateProduct_WhenPayloadIsValid_Returns201()
{
    var payload = new CreateProductRequest { Name = "Gadget", Price = 49.99m };

    var response = await Client.PostAsJsonAsync("/api/products", payload);

    response.StatusCode.Should().Be(HttpStatusCode.Created);
}
```

---

## What NOT to Do

- Do not use `Thread.Sleep` — use `await`-based polling or deterministic Testcontainers health checks (see `WaitUntilAsync` in Pattern 4)
- Do not share mutable state between tests — use `[SetUp]` for per-test seeds and `[TearDown]` for cleanup
- Do not assert on implementation details — assert on HTTP responses, DB state, and published messages
- Do not use `InMemoryDatabase` for integration tests — the entire point is to test against real infrastructure
- Do not mock infrastructure in integration tests — mock only external third-party HTTP calls using WireMock.NET or similar
- Do not use `.Result` or `.Wait()` on async operations
- Do not leave test data in the database after each test — always clean up in `[TearDown]`

---

## Output Format

When generating tests for a class or endpoint:

1. **Read the class under test** — understand every public method or route, its inputs, return types, and infrastructure dependencies
2. **Identify test cases** — happy paths, not-found cases, validation failures, authorization failures, boundary conditions
3. **Write a complete, compilable test file** — include all `using` statements, the correct namespace, and `[TestFixture]` class scaffold
4. **Name the test class** `{ClassName}Tests` and place it in the same namespace as the class under test, plus `.IntegrationTests`
5. **One test class per controller / repository / service** under test

Always output the full file, not isolated snippets, unless the user explicitly asks for just one test.

