dotnet-testing-strategy
Deciding how to test .NET code. Unit vs integration vs E2E decision tree, test doubles.
By wshaddix · 583 installs
npx skills add wshaddix/dotnet-skills --skill dotnet-testing-strategy
Source repository · Upstream listing
dotnet testing strategy
Decision framework for choosing the right test type, organizing test projects, and selecting test doubles in .NET applications. Covers unit vs integration vs E2E trade offs with concrete criteria, naming conventions, and when to use mocks vs fakes vs stubs.
Out of scope: Test project scaffolding (directory layout, xUnit project creation, coverlet setup, editorconfig overrides) is owned by [skill:dotnet add testing]. Code coverage tooling and mutation testing are covered by [skill:dotnet test quality]. CI test reporting and pipeline integration see [skill:dotnet gha build test] and [skill:dotnet ado build test].
Prerequisites: Run [skill:dotnet project analysis] to understand the solution structure before designing a test strategy.
Cross references: [skill:dotnet xunit] for xUnit v3 testing framework features, [skill:dotnet integration testing] for WebApplicationFactory and Testcontainers patterns, [skill:dotnet snapshot testing] for Verify based approval testing, [skill:dotnet test quality] for coverage and mutation testing, [skill:dotnet add testing] for test project scaffolding.
Test Type Decision Tree
Use this decision tree to determine which test type fits a given scenario. Start at the top and follow the first matching criterion.
Concrete Criteria by Test Type
Test Type Infrastructure Speed Scope When to Use
Unit None (mocked/faked) <10ms per test Single class/method Pure logic, domain rules, value objects, transformations, validators
Integration Real (DB, HTTP) 100ms 5s per test Multiple components Repository queries, API contract verification, serialization round trips, middleware behavior
E2E / Functional Full stack 1 30s per test Entire request pipeline Critical user flows, auth + routing + middleware combined, cross cutting concern verification
Cost Benefit Guidance
Prefer unit tests for business logic. They run fast, pinpoint failures precisely, and have no infrastructure requirements.
Use integration tests to verify infrastructure boundaries work correctly. A repository unit test with a mocked DbContext proves nothing about actual SQL generation use a real database via Testcontainers.
Use E2E tests sparingly for critical paths only. They are slow, brittle, and expensive to maintain. Cover the happy path and one or two critical failure scenarios.
The testing pyramid is a guideline, not a rule. Some applications (CRUD APIs with minimal logic) benefit from more integration tests than unit tests. Match the strategy to the application's complexity profile.
Test Organization
Project Naming Convention
Mirror the src/ project structure under tests/ with a suffix indicating test type:
.UnitTests isolated tests, no external dependencies
.IntegrationTests real infrastructure (database, HTTP, file system)
.FunctionalTests full application stack via WebApplicationFactory
See [skill:dotnet add testing] for creating these projects with proper package references and build configuration.
Test Class Organization
One test class per production class. Place test files in a namespace that mirrors the production namespace:
For large production classes, split test classes by method:
Test Naming Conventions
Use the Method Scenario ExpectedBehavior pattern. This reads naturally in test explorer output and makes failures self documenting:
Alternative naming styles (choose one per project and stay consistent):
Style Example
Method Scenario Expected CalculateTotal EmptyCart ReturnsZero
Should Expected When Scenario Should ReturnZero When CartIsEmpty
Given When Then GivenEmptyCart WhenCalculatingTotal ThenReturnsZero
Arrange Act Assert Pattern
Every test follows the AAA structure. Keep each section clearly separated:
Guideline: If you cannot clearly label the three sections, the test may be doing too much. Split into multiple tests.
Test Doubles: When to Use What
Terminology
Double Type Behavior State Verification Use When
Stub Returns canned data No You need a dependency to return specific values so the code under test can proceed
Mock Verifies interactions Yes (interaction) You need to verify that the code under test called a dependency in a specific way
Fake Working implementation Yes (state) You need a lightweight but functional substitute (in memory repository, in memory message bus)
Spy Records calls for later assertion Yes (interaction) You need to verify calls happened without prescribing them upfront
Decision Guidance
Example: Stub vs Mock vs Fake
When to Prefer Fakes Over Mocks
Domain heavy applications: Fakes give more realistic behavior for complex interactions. An in memory repository catches bugs that mocks miss (e.g., duplicate key violations).
Overuse of mocks is a test smell. If a test has more mock setup than actual assertions, consider whether a fake would be clearer and more maintainable.
Integration boundaries are better tested with real infrastructure via [skill:dotnet integration testing] than with mocks. A mocked DbContext does not verify that your LINQ translates to valid SQL.
Testing Anti Patterns
1. Testing Implementation Details
2. Excessive Mock Setup
3. Non Deterministic Tests
Tests must not depend on system clock, random values, or external network. Inject abstractions:
Key Principles
Test behavior, not implementation. Assert on observable outcomes (return values, state changes, published events), not internal method calls.
One logical assertion per test. Multiple Assert calls are fine if they verify one logical concept (e.g., all properties of a returned object). Multiple unrelated assertions indicate the test should be split.
Keep tests independent. No test should depend on another test's execution or ordering. Use fresh fixtures for each test.
Name tests so failures are self documenting. A failing test name should tell you what broke without reading the test body.
Match test type to risk. High risk code (payments, auth) deserves integration and E2E coverage. Low risk code (simple mapping) needs only unit tests.
Use TimeProvider for time dependent logic (.NET 8+). It is the framework provided abstraction; do not create custom IClock interfaces.
Agent Gotchas
1. Do not mock types you do not own. Mocking HttpClient , DbContext , or framework types leads to brittle tests that do not reflect real behavior. Use WebApplicationFactory or Testcontainers instead see [skill:dotnet integration testing].
2. Do not create test projects without checking for existing structure. Run [skill:dotnet project analysis] first; duplicating test infrastructure causes build conflicts.
3. Do not use Thread.Sleep in tests. Use Task.Delay with a cancellation token, or better, use FakeTimeProvider.Advance() to control time deterministically.
4. Do not test private methods directly. If a private method needs its own tests, it should be extracted into its own class. Test through the public API.
5. Do not hard code connection strings in integration tests. Use Testcontainers for disposable infrastructure or WebApplicationFactory for in process testing see [skill:dotnet integration testing].
References
[.NET Testing Best Practices](https://learn.microsoft.com/en us/dotnet/core/testing/unit testing best practices)
[Unit testing C with xUnit](https://learn.microsoft.com/en us/dotnet/core/testing/unit testing with dotnet test)
[Integration tests in ASP.NET Core](https://learn.microsoft.com/en us/aspnet/core/test/integration tests)
[NSubstitute documentation](https://nsubstitute.github.io/help/getting started/)
[TimeProvider in .NET 8](https://learn.microsoft.com/en us/dotnet/api/system.timeprovider)