.NET Testing Approach
This skill captures the approach, not a single library. The principles below apply regardless of which test runner, substitute library, or assertion library a project picks. Library routing is in §Library choices.
Floor: .NET 8 / C# 12. Testing classic ASP.NET on .NET Framework 4.8 (in-memory OWIN TestServer, HttpContextBase) is references/net-framework-48.md.
Test strategy by responsibility (architecture-neutral)
The strategy keys off the role a unit plays, not a layer name - so it maps onto whatever architecture the project picked (the pick-one rule lives with the architecture decision, not here). In a layered (Clean / Onion) project the roles below are the layers; in a vertical-slice / modular project they are the parts of a feature folder (the domain types, the handler / endpoint logic, the infrastructure wiring) - test each part the same way regardless of where it physically lives.
- Domain / business rules - pure unit tests, no substitutes. Cover entities, value objects, domain services, domain events, invariants, guard clauses, factory methods, and every branch of a business rule including exception paths - this is the code where an uncovered branch is never acceptable.
- Use cases / handlers / orchestration (the application logic of a slice or layer) - unit tests with all ports and abstractions substituted. Cover success paths, validation failures, exception handling, and orchestration branches.
- Infrastructure / adapters - test logic-bearing code only (mappers, parsers, serializers, policy classes, retry/backoff, non-trivial query logic). Use SQLite in-memory or Testcontainers when query logic is non-trivial (
references/testcontainers.md) - never the EF Core InMemory provider for relational behavior: it enforces no relational constraints and translates no SQL, so it passes queries the real database rejects. Do not write tests that only assert a substitute was configured.
- Integration / E2E - defined per project in project CLAUDE.md. For an Aspire-orchestrated app the harness is
references/aspire-integration-testing.md.
- Negative-security paths - assert the deny paths, not just the happy path: an expired or tampered token returns 401, N failed logins trip 429, and one user reading another's resource id returns 404. Explicit negative-security tests belong in the integration suite, not just the auth unit tests.
Coverage
- The % bar is the USER's, owned and recorded by the
project-test-coverage-analyzer capture
(asked at capture time, kept in its COVERAGE.md) - this skill sets no number.
- What this skill owns is the mechanics: coverage is computed after exclusions so the number
reflects real logic coverage, not padding - the exclusion catalog below is that list for .NET.
Standard exclusions (via [ExcludeFromCodeCoverage] or coverlet filters)
Program.cs, Main, generic host bootstrap
- DI registration extensions
- Pure DTOs / records / POCOs with no behavior; plain auto-properties
- EF Core migrations and
DbContext.OnModelCreating
- Generated code and framework configuration
Test quality rules (framework-agnostic)
- AAA structure (Arrange / Act / Assert). One logical behavior per test.
- Every test asserts on observable behavior or state - no assertion-free or coverage-padding tests.
- Cover edge cases: nulls, empty / boundary values, cancellation tokens, concurrency where relevant, and every thrown-exception path.
- Deterministic: no real time - the clock seam (inject
TimeProvider, never call DateTime.UtcNow directly) is csharp's baseline rule; the test side is advancing that seam explicitly with FakeTimeProvider instead of waiting on the wall clock. No real I/O, no network, no Thread.Sleep. Seed any randomness.
- Parameterized tests for branch and boundary matrices instead of duplicated single-case tests.
- Test naming:
Do_Something_When_Condition (PascalCase with underscores) regardless of runner.
- If production code is untestable (hidden statics, sealed deps, no seams, hidden side effects), refactor for testability (extract interface, constructor injection) rather than writing a bad test. Flag these explicitly.
- Substitute behavior, not implementation. Verify the call your code makes to its collaborator (the boundary), not the internal sequence of calls. Avoid asserting on private methods or implementation details.
Library choices
Runner, substitute library, and assertion library are project-level decisions. Pick one per category and stay consistent across the test project.
Test runners
| Runner |
When to pick |
| xUnit |
Default for new projects. [Fact] / [Theory] + [InlineData] / [MemberData] / [ClassData]. No [SetUp] / [TearDown] - use constructor + IDisposable / IAsyncLifetime. Parallel by default. |
| NUnit |
When the project already uses it, or for parameterized-test ergonomics ([TestCase], [TestCaseSource], [Values], [ValueSource]). |
| MSTest |
When the project ships with it (Visual Studio templates, internal Microsoft tooling). [TestClass] / [TestMethod] / [DataRow] / [DynamicData]. |
Do not mix runners in one project. Migrate, don't blend.
Substitute / mock libraries
| Library |
API style |
When to pick |
| NSubstitute |
Substitutes (Substitute.For<T>()); record/replay-free, terse syntax (x.M(Arg.Any<int>()).Returns(...)); loose by default - unconfigured members return defaults; Received() throws only when an expected call was not made. |
Default for new projects. Fluent, readable in AAA. |
| Moq |
Mocks (new Mock<T>()); .Setup(...).Returns(...), .Verify(...). Loose by default; MockBehavior.Strict opts into strict. |
When project already uses Moq, or when tooling/team familiarity argues for it. |
| FakeItEasy |
Fakes (A.Fake<T>()); A.CallTo(() => fake.M(...)).Returns(...), A.CallTo(...).MustHaveHappened(). |
Project preference; mature alternative with natural English DSL. |
Same project = one substitute library. Don't half-port.
Common rules regardless of library:
- Substitute only what you cannot construct (external services, ports, infrastructure). Prefer real instances for value objects, records, simple aggregates.
- Default to loose / non-strict; only assert calls that are part of the contract under test.
- Do not call
Received() / Verify() / MustHaveHappened() on every interaction - verify the boundary that matters, leave the rest implicit.
Assertion libraries
| Library |
When to pick |
| FluentAssertions 7.x |
Default. Rich diff output, structural equality, async support. Stay on 7.x: v8+ moved to a paid commercial license - upgrading a client project is a licensing decision, not a routine bump. |
| AwesomeAssertions |
Apache-2.0 community fork taken from FluentAssertions' last Apache-licensed release (v7) and developed forward independently. Drop-in choice when you want a permissive license and ongoing fixes without FA v8's commercial terms. |
| Shouldly |
Project preference. Simpler API; good when FA's surface area feels heavy. |
xUnit/NUnit/MSTest built-in Assert |
When the project has no FA/Shouldly dependency and stays minimal. |
Snapshot / Verify assertions - approving serialized output instead of hand-written asserts - are references/snapshot-testing.md.
Coverage collection
- coverlet is the default collector (msbuild or runsettings). Combined with
dotnet test --collect:"XPlat Code Coverage".
- Reports via
ReportGenerator for HTML / Cobertura / OpenCover formats.
- For CRAP-score risk hotspots (cyclomatic complexity weighed against coverage), pair the report with the .NET code-quality skill's
references/crap-analysis.md; with no such skill installed, rank by uncovered branches alone.
Test project conventions
- One test project per production project, mirroring namespace and folder structure.
- Folder layout inside test project mirrors the SUT's folder layout.
- Shared fixtures live in
*.TestSupport / *.Testing projects when reused across multiple test projects; otherwise inline.
- Run the suite at minimal verbosity so the captured output stays lean:
dotnet test -v minimal (or --logger "console;verbosity=minimal"), and read a failure by windowing to the first error / failed assertion, not the whole log - test output is context every seat that runs the gate pays for.
Cancellation and async
- Every async path under test that accepts
CancellationToken gets a cancellation test (token already cancelled, token cancelled mid-flight where realistic).
- Async tests return
Task / ValueTask - never async void.
Isolation and shared state
- Tests must pass regardless of order. No reliance on side effects from earlier tests, no mutable static state.
- Per-test fresh fixture by default (xUnit constructor, NUnit
[SetUp], MSTest [TestInitialize]). Reuse only for expensive resources (Testcontainers, web factory) via IClassFixture / ICollectionFixture and only when the resource is read-only or reset between tests.
- Database integration tests: each test gets its own transaction or schema, rolled back at teardown. Never assume row IDs.
- Test data via one canonical builder per aggregate, not literal-soup constructors. Prefer a
record builder with init defaults; when the type under test is itself a record, derive case variations with a with expression from a canonical instance (var large = baseOrder with { Total = new(1500m, "USD") };) instead of re-running setup. Use a fluent OrderBuilder().WithCustomer(...).Build() only when a setter needs computation or validation.
What NOT to test
- Auto-properties with no logic.
- Generated code, EF migrations, framework-provided types.
- DI registration extension methods (cover via integration test, not unit test).
- Pure DTOs / records used only as data carriers.
- Other people's libraries - assume
FluentValidation, Polly, EF Core work. Test your wiring of them, not them.
Auditing an existing suite
The rules above are for writing tests; reviewing an existing suite is its own lens - a test that passes can still prove nothing. When asked 'are these tests any good?' (or to run mutation testing), load references/suite-audit.md: the false-confidence catalog to scan first (assertion-free / always-true, coverage-touching, tautological, missing-await, swallowed-exception, disabled assertions), the assertion-depth and mock-usage passes, and Stryker.NET mutation testing.
Routing (cross-skill)
Availability - a row whose skill is not in your skill list means the area is absent here, not a broken pointer.
- Performance microbenchmarks ->
dotnet-diagnostics (its references/microbenchmarking.md); crash / hang dump capture -> dotnet-diagnostics (its references/dumps.md).
- Reward-hacking / coverage-gaming check before 'done' ->
dotnet-code-quality; CRAP-score risk hotspots -> its references/crap-analysis.md (paired at §Coverage above).
- Testability refactors, the clock seam, and async-returns-
Task-not-void are baseline rules owned by csharp; exception / Result shapes under assertion -> dotnet-web-error-handling. Full .NET index: dotnet.
1---2name: dotnet-testing3description: .NET testing hub - the architecture-neutral approach for unit / integration / E2E tests, not a single library: AAA structure, a test strategy keyed off responsibility, coverage mechanics (the exclusion catalog + after-exclusions semantics; the % bar itself is user-set via project-test-coverage-analyzer), and runner / substitute / assertion library routing (xUnit, NSubstitute, FluentAssertions 7.x as defaults). Floors at .NET 8 / C# 12. Load before writing, modifying, or reviewing .NET tests, auditing test quality / smells, running mutation testing, or configuring coverage - do not rely on recall. Companions: csharp, dotnet-web-error-handling; Testcontainers, Aspire-orchestrated integration, and Verify/snapshot testing are folded in here as references/. Do NOT load for Angular/Jasmine/Karma/Jest (angular-testing) or plain TS/JS suites (ts-js-testing).4---56# .NET Testing Approach78This skill captures the **approach**, not a single library. The principles below apply regardless of which test runner, substitute library, or assertion library a project picks. Library routing is in §Library choices.910**Floor: .NET 8 / C# 12.** Testing classic ASP.NET on .NET Framework 4.8 (in-memory OWIN `TestServer`, `HttpContextBase`) is `references/net-framework-48.md`.1112## Test strategy by responsibility (architecture-neutral)1314The strategy keys off the *role* a unit plays, not a layer name - so it maps onto whatever architecture the project picked (the pick-one rule lives with the architecture decision, not here). In a layered (Clean / Onion) project the roles below are the layers; in a vertical-slice / modular project they are the parts of a feature folder (the domain types, the handler / endpoint logic, the infrastructure wiring) - test each part the same way regardless of where it physically lives.1516- **Domain / business rules** - pure unit tests, no substitutes. Cover entities, value objects, domain services, domain events, invariants, guard clauses, factory methods, and every branch of a business rule including exception paths - this is the code where an uncovered branch is never acceptable.17- **Use cases / handlers / orchestration** (the application logic of a slice or layer) - unit tests with all ports and abstractions substituted. Cover success paths, validation failures, exception handling, and orchestration branches.18- **Infrastructure / adapters** - test logic-bearing code only (mappers, parsers, serializers, policy classes, retry/backoff, non-trivial query logic). Use SQLite in-memory or Testcontainers when query logic is non-trivial (`references/testcontainers.md`) - never the EF Core InMemory provider for relational behavior: it enforces no relational constraints and translates no SQL, so it passes queries the real database rejects. Do not write tests that only assert a substitute was configured.19- **Integration / E2E** - defined per project in project CLAUDE.md. For an Aspire-orchestrated app the harness is `references/aspire-integration-testing.md`.20- **Negative-security paths** - assert the deny paths, not just the happy path: an expired or tampered token returns 401, N failed logins trip 429, and one user reading another's resource id returns 404. Explicit negative-security tests belong in the integration suite, not just the auth unit tests.2122## Coverage2324- The % bar is the USER's, owned and recorded by the `project-test-coverage-analyzer` capture25 (asked at capture time, kept in its COVERAGE.md) - this skill sets no number.26- What this skill owns is the mechanics: coverage is computed after exclusions so the number27 reflects real logic coverage, not padding - the exclusion catalog below is that list for .NET.2829## Standard exclusions (via `[ExcludeFromCodeCoverage]` or coverlet filters)3031- `Program.cs`, `Main`, generic host bootstrap32- DI registration extensions33- Pure DTOs / records / POCOs with no behavior; plain auto-properties34- EF Core migrations and `DbContext.OnModelCreating`35- Generated code and framework configuration3637## Test quality rules (framework-agnostic)3839- **AAA structure** (Arrange / Act / Assert). One logical behavior per test.40- Every test asserts on **observable behavior or state** - no assertion-free or coverage-padding tests.41- Cover edge cases: nulls, empty / boundary values, cancellation tokens, concurrency where relevant, and every thrown-exception path.42- **Deterministic**: no real time - the clock seam (inject `TimeProvider`, never call `DateTime.UtcNow` directly) is `csharp`'s baseline rule; the test side is advancing that seam explicitly with `FakeTimeProvider` instead of waiting on the wall clock. No real I/O, no network, no `Thread.Sleep`. Seed any randomness.43- **Parameterized tests** for branch and boundary matrices instead of duplicated single-case tests.44- **Test naming**: `Do_Something_When_Condition` (PascalCase with underscores) regardless of runner.45- If production code is **untestable** (hidden statics, sealed deps, no seams, hidden side effects), refactor for testability (extract interface, constructor injection) rather than writing a bad test. Flag these explicitly.46- **Substitute behavior, not implementation.** Verify the call your code makes to its collaborator (the boundary), not the internal sequence of calls. Avoid asserting on private methods or implementation details.4748## Library choices4950Runner, substitute library, and assertion library are project-level decisions. Pick one per category and stay consistent across the test project.5152### Test runners5354| Runner | When to pick |55|---|---|56| **xUnit** | Default for new projects. `[Fact]` / `[Theory]` + `[InlineData]` / `[MemberData]` / `[ClassData]`. No `[SetUp]` / `[TearDown]` - use constructor + `IDisposable` / `IAsyncLifetime`. Parallel by default. |57| **NUnit** | When the project already uses it, or for parameterized-test ergonomics (`[TestCase]`, `[TestCaseSource]`, `[Values]`, `[ValueSource]`). |58| **MSTest** | When the project ships with it (Visual Studio templates, internal Microsoft tooling). `[TestClass]` / `[TestMethod]` / `[DataRow]` / `[DynamicData]`. |5960Do not mix runners in one project. Migrate, don't blend.6162### Substitute / mock libraries6364| Library | API style | When to pick |65|---|---|---|66| **NSubstitute** | Substitutes (`Substitute.For<T>()`); record/replay-free, terse syntax (`x.M(Arg.Any<int>()).Returns(...)`); loose by default - unconfigured members return defaults; `Received()` throws only when an expected call was not made. | Default for new projects. Fluent, readable in AAA. |67| **Moq** | Mocks (`new Mock<T>()`); `.Setup(...).Returns(...)`, `.Verify(...)`. Loose by default; `MockBehavior.Strict` opts into strict. | When project already uses Moq, or when tooling/team familiarity argues for it. |68| **FakeItEasy** | Fakes (`A.Fake<T>()`); `A.CallTo(() => fake.M(...)).Returns(...)`, `A.CallTo(...).MustHaveHappened()`. | Project preference; mature alternative with natural English DSL. |6970Same project = one substitute library. Don't half-port.7172Common rules regardless of library:73- Substitute only what you cannot construct (external services, ports, infrastructure). Prefer real instances for value objects, records, simple aggregates.74- Default to loose / non-strict; only assert calls that are part of the contract under test.75- Do not call `Received()` / `Verify()` / `MustHaveHappened()` on every interaction - verify the boundary that matters, leave the rest implicit.7677### Assertion libraries7879| Library | When to pick |80|---|---|81| **FluentAssertions 7.x** | Default. Rich diff output, structural equality, async support. Stay on 7.x: v8+ moved to a paid commercial license - upgrading a client project is a licensing decision, not a routine bump. |82| **AwesomeAssertions** | Apache-2.0 community fork taken from FluentAssertions' last Apache-licensed release (v7) and developed forward independently. Drop-in choice when you want a permissive license and ongoing fixes without FA v8's commercial terms. |83| **Shouldly** | Project preference. Simpler API; good when FA's surface area feels heavy. |84| **xUnit/NUnit/MSTest built-in `Assert`** | When the project has no FA/Shouldly dependency and stays minimal. |8586Snapshot / Verify assertions - approving serialized output instead of hand-written asserts - are `references/snapshot-testing.md`.8788### Coverage collection8990- **coverlet** is the default collector (msbuild or runsettings). Combined with `dotnet test --collect:"XPlat Code Coverage"`.91- Reports via `ReportGenerator` for HTML / Cobertura / OpenCover formats.92- For CRAP-score risk hotspots (cyclomatic complexity weighed against coverage), pair the report with the .NET code-quality skill's `references/crap-analysis.md`; with no such skill installed, rank by uncovered branches alone.9394## Test project conventions9596- One test project per production project, mirroring namespace and folder structure.97- Folder layout inside test project mirrors the SUT's folder layout.98- Shared fixtures live in `*.TestSupport` / `*.Testing` projects when reused across multiple test projects; otherwise inline.99- Run the suite at minimal verbosity so the captured output stays lean: `dotnet test -v minimal` (or `--logger "console;verbosity=minimal"`), and read a failure by windowing to the first error / failed assertion, not the whole log - test output is context every seat that runs the gate pays for.100101## Cancellation and async102103- Every async path under test that accepts `CancellationToken` gets a cancellation test (token already cancelled, token cancelled mid-flight where realistic).104- Async tests return `Task` / `ValueTask` - never `async void`.105106## Isolation and shared state107108- Tests must pass regardless of order. No reliance on side effects from earlier tests, no mutable static state.109- Per-test fresh fixture by default (xUnit constructor, NUnit `[SetUp]`, MSTest `[TestInitialize]`). Reuse only for expensive resources (Testcontainers, web factory) via `IClassFixture` / `ICollectionFixture` and only when the resource is read-only or reset between tests.110- Database integration tests: each test gets its own transaction or schema, rolled back at teardown. Never assume row IDs.111- Test data via one canonical builder per aggregate, not literal-soup constructors. Prefer a `record` builder with `init` defaults; when the type under test is itself a `record`, derive case variations with a `with` expression from a canonical instance (`var large = baseOrder with { Total = new(1500m, "USD") };`) instead of re-running setup. Use a fluent `OrderBuilder().WithCustomer(...).Build()` only when a setter needs computation or validation.112113## What NOT to test114115- Auto-properties with no logic.116- Generated code, EF migrations, framework-provided types.117- DI registration extension methods (cover via integration test, not unit test).118- Pure DTOs / records used only as data carriers.119- Other people's libraries - assume `FluentValidation`, `Polly`, `EF Core` work. Test your wiring of them, not them.120121## Auditing an existing suite122123The rules above are for *writing* tests; reviewing an existing suite is its own lens - a test that passes can still prove nothing. When asked 'are these tests any good?' (or to run mutation testing), load `references/suite-audit.md`: the false-confidence catalog to scan first (assertion-free / always-true, coverage-touching, tautological, missing-await, swallowed-exception, disabled assertions), the assertion-depth and mock-usage passes, and Stryker.NET mutation testing.124125## Routing (cross-skill)126127**Availability** - a row whose skill is not in your skill list means the area is absent here, not a broken pointer.128129- Performance microbenchmarks -> `dotnet-diagnostics` (its `references/microbenchmarking.md`); crash / hang dump capture -> `dotnet-diagnostics` (its `references/dumps.md`).130- Reward-hacking / coverage-gaming check before 'done' -> `dotnet-code-quality`; CRAP-score risk hotspots -> its `references/crap-analysis.md` (paired at §Coverage above).131- Testability refactors, the clock seam, and async-returns-`Task`-not-`void` are baseline rules owned by `csharp`; exception / Result shapes under assertion -> `dotnet-web-error-handling`. Full .NET index: `dotnet`.