Design and write a .NET test strategy and the tests themselves — xUnit (or NUnit/MSTest) conventions, unit tests of domain logic, ASP.NET Core integration tests with WebApplicationFactory, real databases with Testcontainers, mocking with NSubstitute or Moq and when not to mock, test data builders, assertion libraries, contract and architecture tests, coverage with coverlet, flaky-test triage and CI.
When agents use itUse this whenever the user asks how to test a C# class, handler, controller, minimal API endpoint, DbContext or background service; creates a *.Tests.csproj; sets up WebApplicationFactory, Testcontainers or EF InMemory/SQLite; asks about mocking ILogger, HttpClient or DbContext, test coverage, dotnet test in CI, or "what should I test". Also apply it when reviewing .NET test code or generating tests for C# code the user just wrote, even if they don't say the word "test".
Install
Copilot (VS Code, Visual Studio, Copilot CLI and github.com) reads skills from the repository — commit them so the whole team gets them.
npx skills add AGCO-Global/org-skills --skill dotnet-testing -a github-copilot
# or with the org installer (adds .github/skills/dotnet-testing):
npx -y github:AGCO-Global/org-skills add skill dotnet-testing
Uses the open skills CLI. Works with Claude Code, Codex, Cursor, Copilot, Gemini CLI, OpenCode, Windsurf and 60+ others — it asks which agent to install into.
Installs the backend-dotnet-skills plugin, which bundles all Backend / .NET skills and keeps them updated.
/plugin marketplace add AGCO-Global/org-skills
/plugin install backend-dotnet-skills@org-skills
# or just this skill, in this repository:
npx skills add AGCO-Global/org-skills --skill dotnet-testing -a claude-code
Use Download .zip above, then upload it under Customize → Skills → Upload skill. Team and Enterprise admins can sync this repo as a plugin marketplace instead.
Installs into .agents/skills/, which Codex reads.
npx skills add AGCO-Global/org-skills --skill dotnet-testing -a codex
Installs into Cursor's skills folder.
npx skills add AGCO-Global/org-skills --skill dotnet-testing -a cursor
Installs into Gemini CLI's skills folder.
npx skills add AGCO-Global/org-skills --skill dotnet-testing -a gemini-cli
Any tool with rules, instructions or custom prompts: use Copy SKILL.md above and paste it in. It is plain Markdown.
Commands use your normal git sign-in to GitHub, so they work while the repository is private. Node.js 20+ required.
Skill contents
.NET Testing
Tests exist so the team can refactor and upgrade .NET versions without fear. Optimise for confidence per minute of maintenance: test behaviour through public seams with real infrastructure where it's cheap, and mock only what you don't own.
1. The pyramid (what to write, how much)
Layer
Tooling
Share
Tests what
Static
Nullable + warnings-as-errors, .NET analyzers, dotnet format
Free
Null bugs, API misuse, style
Unit
xUnit + assertions
~40%
Domain types, value objects, pure services, validators, mapping
Integration (in-process)
WebApplicationFactory + Testcontainers
~45%
Endpoints through the real pipeline: routing, auth, validation, EF, serialization
Contract
Pact, or OpenAPI diff, or published message schemas
~5%
Agreements with consumers/providers
Architecture
NetArchTest / ArchUnitNET
Few
Module and layer boundaries
End-to-end
Playwright/HTTP smoke against a deployed env
~5–10%
A handful of critical journeys
Push toward integration tests at the HTTP boundary for ASP.NET Core services: they catch DI, middleware, serialization and query bugs that unit tests with mocks cannot.
2. Framework conventions
xUnit is the common default (a new instance per test, constructor = setup, IDisposable/IAsyncLifetime = teardown, IClassFixture<T>/collection fixtures for shared expensive state). NUnit and MSTest are fine — stay consistent with the repo.
One test project per production project (Orders.Tests, Orders.IntegrationTests); mirror namespaces.
Name tests as behaviour: PlaceOrder_WithEmptyCart_ReturnsValidationProblem or a sentence in [Fact(DisplayName = ...)].
Arrange / Act / Assert, one behaviour per test; [Theory] + [InlineData]/[MemberData] for input tables.
Async tests return Task; pass TestContext.Current.CancellationToken (xUnit v3) or a timeout token to I/O.
Inject TimeProvider (and use FakeTimeProvider) instead of DateTime.UtcNow; never Thread.Sleep.
3. Unit tests of domain logic
[Fact]
public void Cancel_ShippedOrder_Throws()
{
var order = new OrderBuilder().Shipped().Build();
var act = () => order.Cancel("changed mind");
act.ShouldThrow<DomainException>().Message.ShouldContain("shipped");
}
Test invariants, state transitions and edge cases (boundaries, empty, null, max) of aggregates and value objects — no mocks needed if the domain is pure.
Don't unit-test framework glue (endpoint mapping, EF configuration); integration tests cover it.
4. Integration tests with WebApplicationFactory
public sealed class ApiFactory : WebApplicationFactory<Program>, IAsyncLifetime
{
private readonly PostgreSqlContainer _db = new PostgreSqlBuilder().WithImage("postgres:16-alpine").Build();
protected override void ConfigureWebHost(IWebHostBuilder builder) =>
builder.UseEnvironment("Testing").ConfigureTestServices(services =>
{
services.RemoveAll<DbContextOptions<OrdersDbContext>>();
services.AddDbContext<OrdersDbContext>(o => o.UseNpgsql(_db.GetConnectionString()));
});
public Task InitializeAsync() => _db.StartAsync(); // then apply migrations
Task IAsyncLifetime.DisposeAsync() => _db.DisposeAsync().AsTask(); // xUnit v3 uses ValueTask
}
Expose Program to tests (public partial class Program; or InternalsVisibleTo).
Replace only true externals in ConfigureTestServices: payment gateways, email, message transport (use the broker library's test harness), clock.
Auth: a test authentication handler issuing claims per test, rather than disabling authorisation.
Assert on HTTP status, ProblemDetails body and resulting database state — not on internal calls.
Reset data between tests (Respawn, transaction rollback, or a fresh schema per class); never depend on test order.
5. Which database for tests?
Option
Use when
Trade-off
Testcontainers with the real engine (SQL Server, PostgreSQL)
Default for integration tests
Needs Docker in CI; slower startup — share one container per test run/collection
SQLite in-memory
Fast checks where SQL dialect doesn't matter
Different SQL, constraints and functions; false confidence on complex queries
EF Core InMemory provider
Almost never — Microsoft discourages it for testing
Not relational: no transactions, constraints or real LINQ translation
Repository/handler fakes
Pure unit tests of application logic
Doesn't test the query at all
6. Mocking — and when not to
Libraries: NSubstitute (terse) or Moq (widely used; its 2023 SponsorLink episode led some teams to switch — either is acceptable, follow the repo). FakeItEasy is also fine.
Mock what you don't own and can't run cheaply: external HTTP APIs (prefer a stub HttpMessageHandler or WireMock.Net), email/SMS, clocks.
Don't mock: DbContext/DbSet (use a real database), your own domain types, value objects, IOptions<T> (use Options.Create), ILogger<T> (use NullLogger<T> or FakeLogger<T> when asserting logs).
Assert interactions only when the interaction is the behaviour (a message was published, an email sent) — otherwise assert outcomes.
7. Test data
Builders with sensible defaults (new OrderBuilder().WithLines(3).Build()) keep tests readable and resilient to constructor changes.
AutoFixture (optionally with AutoNSubstitute/AutoMoq) or Bogus for filling irrelevant data; keep values that matter explicit in the test.
No shared mutable static fixtures; each test creates what it asserts on.
8. Assertions
Built-in Assert is fine. Shouldly and FluentAssertions give better messages.
FluentAssertions v8+ is under a commercial licence for commercial use (v7 and earlier remain Apache 2.0) — confirm licensing before adding or upgrading; Shouldly or built-in asserts are free alternatives.
Compare objects structurally (BeEquivalentTo, records' value equality) rather than field-by-field noise.
9. Contract and architecture tests
Consumer-driven contracts (PactNet) between services owned by different teams; for public APIs, diff the generated OpenAPI document in CI and fail on breaking changes.
Snapshot-test message/JSON contracts (e.g. Verify) so accidental property renames fail.
Architecture rules with NetArchTest or ArchUnitNET: Domain has no dependency on Infrastructure/EF, modules reference only other modules' Contracts, handlers are internal sealed.
10. Coverage, flakiness and CI
Coverage via coverlet (coverlet.collector + dotnet test --collect:"XPlat Code Coverage") or Microsoft's code coverage tooling; report with ReportGenerator. Gate on changed-lines coverage, not a global number ratcheting forever.
CI shape: dotnet build -warnaserror → unit tests → integration tests (Docker available) → publish results (TRX/JUnit) and coverage.
Run tests in Release in CI with --no-build; set TZ/culture explicitly (CultureInfo.InvariantCulture) to catch locale bugs.
Flaky symptom
Likely cause
Fix
Passes alone, fails in suite
Shared DB rows or static state; parallel collections
Reset data per test, isolate collections, remove statics
Real delays, un-awaited tasks, background services racing
Await everything, disable/replace hosted services in tests, poll with deadline
Order-dependent
Tests relying on data created by others
Each test arranges its own data
Deliverable format
When asked to write tests:
List the behaviours to cover (happy path, validation, authorisation, not found, concurrency/edge cases) before code.
State the layer chosen for each and why.
Provide complete test classes plus any fixture/builder additions, reusing existing factories and builders.
Give the command to run them and note any prerequisites (Docker for Testcontainers).
Anti-patterns to reject
Mocking DbContext/DbSet or testing EF queries against the InMemory provider.
Tests that assert only "no exception" or verify every mock call.
Thread.Sleep, DateTime.Now, real external services, and shared databases without reset.
One giant [Fact] covering five behaviours; tests coupled to private methods via reflection.
Disabling authorisation in integration tests instead of issuing test identities.
Coverage targets chased with assertion-free tests.
Skipped/quarantined tests with no owner or issue link.
---
name: dotnet-testing
description: Design and write a .NET test strategy and the tests themselves — xUnit (or NUnit/MSTest) conventions, unit tests of domain logic, ASP.NET Core integration tests with WebApplicationFactory, real databases with Testcontainers, mocking with NSubstitute or Moq and when not to mock, test data builders, assertion libraries, contract and architecture tests, coverage with coverlet, flaky-test triage and CI. Use this whenever the user asks how to test a C# class, handler, controller, minimal API endpoint, DbContext or background service; creates a *.Tests.csproj; sets up WebApplicationFactory, Testcontainers or EF InMemory/SQLite; asks about mocking ILogger, HttpClient or DbContext, test coverage, dotnet test in CI, or "what should I test". Also apply it when reviewing .NET test code or generating tests for C# code the user just wrote, even if they don't say the word "test".
metadata:
technology: .NET
type: testing
---
# .NET Testing
Tests exist so the team can refactor and upgrade .NET versions without fear. Optimise for confidence per minute of maintenance: test behaviour through public seams with real infrastructure where it's cheap, and mock only what you don't own.
## 1. The pyramid (what to write, how much)
| Layer | Tooling | Share | Tests what |
|---|---|---|---|
| Static | Nullable + warnings-as-errors, .NET analyzers, `dotnet format` | Free | Null bugs, API misuse, style |
| Unit | xUnit + assertions | ~40% | Domain types, value objects, pure services, validators, mapping |
| Integration (in-process) | `WebApplicationFactory` + Testcontainers | ~45% | Endpoints through the real pipeline: routing, auth, validation, EF, serialization |
| Contract | Pact, or OpenAPI diff, or published message schemas | ~5% | Agreements with consumers/providers |
| Architecture | NetArchTest / ArchUnitNET | Few | Module and layer boundaries |
| End-to-end | Playwright/HTTP smoke against a deployed env | ~5–10% | A handful of critical journeys |
Push toward integration tests at the HTTP boundary for ASP.NET Core services: they catch DI, middleware, serialization and query bugs that unit tests with mocks cannot.
## 2. Framework conventions
- **xUnit** is the common default (a new instance per test, constructor = setup, `IDisposable`/`IAsyncLifetime` = teardown, `IClassFixture<T>`/collection fixtures for shared expensive state). NUnit and MSTest are fine — stay consistent with the repo.
- One test project per production project (`Orders.Tests`, `Orders.IntegrationTests`); mirror namespaces.
- Name tests as behaviour: `PlaceOrder_WithEmptyCart_ReturnsValidationProblem` or a sentence in `[Fact(DisplayName = ...)]`.
- Arrange / Act / Assert, one behaviour per test; `[Theory]` + `[InlineData]`/`[MemberData]` for input tables.
- Async tests return `Task`; pass `TestContext.Current.CancellationToken` (xUnit v3) or a timeout token to I/O.
- Inject `TimeProvider` (and use `FakeTimeProvider`) instead of `DateTime.UtcNow`; never `Thread.Sleep`.
## 3. Unit tests of domain logic
```csharp
[Fact]
public void Cancel_ShippedOrder_Throws()
{
var order = new OrderBuilder().Shipped().Build();
var act = () => order.Cancel("changed mind");
act.ShouldThrow<DomainException>().Message.ShouldContain("shipped");
}
```
- Test invariants, state transitions and edge cases (boundaries, empty, null, max) of aggregates and value objects — no mocks needed if the domain is pure.
- Don't unit-test framework glue (endpoint mapping, EF configuration); integration tests cover it.
## 4. Integration tests with WebApplicationFactory
```csharp
public sealed class ApiFactory : WebApplicationFactory<Program>, IAsyncLifetime
{
private readonly PostgreSqlContainer _db = new PostgreSqlBuilder().WithImage("postgres:16-alpine").Build();
protected override void ConfigureWebHost(IWebHostBuilder builder) =>
builder.UseEnvironment("Testing").ConfigureTestServices(services =>
{
services.RemoveAll<DbContextOptions<OrdersDbContext>>();
services.AddDbContext<OrdersDbContext>(o => o.UseNpgsql(_db.GetConnectionString()));
});
public Task InitializeAsync() => _db.StartAsync(); // then apply migrations
Task IAsyncLifetime.DisposeAsync() => _db.DisposeAsync().AsTask(); // xUnit v3 uses ValueTask
}
```
- Expose `Program` to tests (`public partial class Program;` or `InternalsVisibleTo`).
- Replace only true externals in `ConfigureTestServices`: payment gateways, email, message transport (use the broker library's test harness), clock.
- Auth: a test authentication handler issuing claims per test, rather than disabling authorisation.
- Assert on HTTP status, ProblemDetails body and resulting database state — not on internal calls.
- Reset data between tests (Respawn, transaction rollback, or a fresh schema per class); never depend on test order.
## 5. Which database for tests?
| Option | Use when | Trade-off |
|---|---|---|
| **Testcontainers** with the real engine (SQL Server, PostgreSQL) | Default for integration tests | Needs Docker in CI; slower startup — share one container per test run/collection |
| SQLite in-memory | Fast checks where SQL dialect doesn't matter | Different SQL, constraints and functions; false confidence on complex queries |
| EF Core InMemory provider | Almost never — Microsoft discourages it for testing | Not relational: no transactions, constraints or real LINQ translation |
| Repository/handler fakes | Pure unit tests of application logic | Doesn't test the query at all |
## 6. Mocking — and when not to
- Libraries: **NSubstitute** (terse) or **Moq** (widely used; its 2023 SponsorLink episode led some teams to switch — either is acceptable, follow the repo). FakeItEasy is also fine.
- Mock **what you don't own and can't run cheaply**: external HTTP APIs (prefer a stub `HttpMessageHandler` or WireMock.Net), email/SMS, clocks.
- **Don't mock**: `DbContext`/`DbSet` (use a real database), your own domain types, value objects, `IOptions<T>` (use `Options.Create`), `ILogger<T>` (use `NullLogger<T>` or `FakeLogger<T>` when asserting logs).
- Assert interactions only when the interaction *is* the behaviour (a message was published, an email sent) — otherwise assert outcomes.
## 7. Test data
- **Builders** with sensible defaults (`new OrderBuilder().WithLines(3).Build()`) keep tests readable and resilient to constructor changes.
- AutoFixture (optionally with `AutoNSubstitute`/`AutoMoq`) or Bogus for filling irrelevant data; keep values that matter explicit in the test.
- No shared mutable static fixtures; each test creates what it asserts on.
## 8. Assertions
- Built-in `Assert` is fine. Shouldly and FluentAssertions give better messages.
- **FluentAssertions v8+ is under a commercial licence** for commercial use (v7 and earlier remain Apache 2.0) — confirm licensing before adding or upgrading; Shouldly or built-in asserts are free alternatives.
- Compare objects structurally (`BeEquivalentTo`, records' value equality) rather than field-by-field noise.
## 9. Contract and architecture tests
- Consumer-driven contracts (PactNet) between services owned by different teams; for public APIs, diff the generated OpenAPI document in CI and fail on breaking changes.
- Snapshot-test message/JSON contracts (e.g. Verify) so accidental property renames fail.
- Architecture rules with **NetArchTest** or **ArchUnitNET**: `Domain` has no dependency on `Infrastructure`/EF, modules reference only other modules' `Contracts`, handlers are `internal sealed`.
## 10. Coverage, flakiness and CI
- Coverage via **coverlet** (`coverlet.collector` + `dotnet test --collect:"XPlat Code Coverage"`) or Microsoft's code coverage tooling; report with ReportGenerator. Gate on changed-lines coverage, not a global number ratcheting forever.
- CI shape: `dotnet build -warnaserror` → unit tests → integration tests (Docker available) → publish results (TRX/JUnit) and coverage.
- Run tests in Release in CI with `--no-build`; set `TZ`/culture explicitly (`CultureInfo.InvariantCulture`) to catch locale bugs.
| Flaky symptom | Likely cause | Fix |
|---|---|---|
| Passes alone, fails in suite | Shared DB rows or static state; parallel collections | Reset data per test, isolate collections, remove statics |
| Fails on CI only | Time zone/culture, slower machine, Docker startup | `FakeTimeProvider`, invariant culture, container wait strategies |
| Intermittent timeout | Real delays, un-awaited tasks, background services racing | Await everything, disable/replace hosted services in tests, poll with deadline |
| Order-dependent | Tests relying on data created by others | Each test arranges its own data |
## Deliverable format
When asked to write tests:
1. List the **behaviours** to cover (happy path, validation, authorisation, not found, concurrency/edge cases) before code.
2. State the **layer** chosen for each and why.
3. Provide complete test classes plus any fixture/builder additions, reusing existing factories and builders.
4. Give the command to run them and note any prerequisites (Docker for Testcontainers).
## Anti-patterns to reject
- Mocking `DbContext`/`DbSet` or testing EF queries against the InMemory provider.
- Tests that assert only "no exception" or verify every mock call.
- `Thread.Sleep`, `DateTime.Now`, real external services, and shared databases without reset.
- One giant `[Fact]` covering five behaviours; tests coupled to private methods via reflection.
- Disabling authorisation in integration tests instead of issuing test identities.
- Coverage targets chased with assertion-free tests.
- Skipped/quarantined tests with no owner or issue link.