wshaddix/dotnet-skills1 file

Dotnet Testing Strategy

Deciding how to test .NET code. Unit vs integration vs E2E decision tree, test doubles.

Specification
Skill ID
wshaddix/dotnet-skills/dotnet-testing-strategy
Publisher
wshaddix
Repository
dotnet-skills
Installs
0
Files
1
Synced
Sep 23, 2026
How to use it

Open any RiverX project, open the Skills panel in the chat, and search for this identifier. The files are fetched from the source repository at install time.

wshaddix/dotnet-skills/dotnet-testing-strategyInstalls these files
  • SKILL.md

What this skill tells the agent

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.

Does the code under test depend on external infrastructure?
  (database, HTTP service, file system, message broker)
|
+-- YES --> Is the infrastructure behavior critical to correctness?
|           |
|           +-- YES --> Does it need the full application stack (middleware, auth, routing)?
|           |           |
|           |           +-- YES --> E2E / Functional Test
|           |           |           (WebApplicationFactory or Playwright)
|           |           |
|           |           +-- NO  --> Integration Test
|           |                       (WebApplicationFactory or Testcontainers)
|           |
|           +-- NO  --> Unit Test with test doubles
|                        (mock the infrastructure boundary)
|
+-- NO  --> Is this pure logic (calculations, transformations, validation)?
            |
            +-- YES --> Unit Test (no test doubles needed)
            |
            +-- NO  --> Unit Test with test doubles
                        (mock collaborator interfaces)

Concrete Criteria by Test Type

Test TypeInfrastructureSpeedScopeWhen to Use
UnitNone (mocked/faked)<10ms per testSingle class/methodPure logic, domain rules, value objects, transformations, validators
IntegrationReal (DB, HTTP)100ms-5s per testMultiple componentsRepository queries, API contract verification, serialization round-trips, middleware behavior
E2E / FunctionalFull stack1-30s per testEntire request pipelineCritical 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:

MyApp/
  src/
    MyApp.Domain/
    MyApp.Application/
    MyApp.Api/
    MyApp.Infrastructure/
  tests/
    MyApp.Domain.UnitTests/
    MyApp.Application.UnitTests/
    MyApp.Api.IntegrationTests/
    MyApp.Api.FunctionalTests/
    MyApp.Infrastructure.IntegrationTests/
  • *.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:

// Production: src/MyApp.Domain/Orders/OrderService.cs
// Test:       tests/MyApp.Domain.UnitTests/Orders/OrderServiceTests.cs
namespace MyApp.Domain.UnitTests.Orders;

public class OrderServiceTests
{
    // Group by method, then by scenario
}

For large production classes, split test classes by method:

// OrderService_CreateTests.cs
// OrderService_CancelTests.cs
// OrderService_RefundTests.cs

Test Naming Conventions

Use the Method_Scenario_ExpectedBehavior pattern. This reads naturally in test explorer output and makes failures self-documenting:

public class OrderServiceTests
{
    [Fact]
    public void CalculateTotal_WithDiscountCode_AppliesPercentageDiscount()
    {
        // ...
    }

    [Fact]
    public void CalculateTotal_WithExpiredDiscount_ThrowsInvalidOperationException()
    {
        // ...
    }

    [Fact]
    public async Task SubmitOrder_WhenInventoryInsufficient_ReturnsOutOfStockError()
    {
        // ...
    }
}

Alternative naming styles (choose one per project and stay consistent):

StyleExample
Method_Scenario_ExpectedCalculateTotal_EmptyCart_ReturnsZero
Should_Expected_When_ScenarioShould_ReturnZero_When_CartIsEmpty
Given_When_ThenGivenEmptyCart_WhenCalculatingTotal_ThenReturnsZero

Arrange-Act-Assert Pattern

Every test follows the AAA structure. Keep each section clearly separated:

[Fact]
public async Task CreateOrder_WithValidItems_PersistsAndReturnsOrder()
{
    // Arrange
    var repository = new FakeOrderRepository();
    var service = new OrderService(repository);
    var request = new CreateOrderRequest
    {
        CustomerId = "cust-123",
        Items = [new OrderItem("SKU-001", Quantity: 2, UnitPrice: 29.99m)]
    };

    // Act
    var result = await service.CreateAsync(request);

    // Assert
    Assert.NotNull(result);
    Assert.Equal("cust-123", result.CustomerId);
    Assert.Single(result.Items);
    Assert.True(repository.SavedOrders.ContainsKey(result.Id));
}

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 TypeBehaviorState VerificationUse When
StubReturns canned dataNoYou need a dependency to return specific values so the code under test can proceed
MockVerifies interactionsYes (interaction)You need to verify that the code under test called a dependency in a specific way
FakeWorking implementationYes (state)You need a lightweight but functional substitute (in-memory repository, in-memory message bus)
SpyRecords calls for later assertionYes (interaction)You need to verify calls happened without prescribing them upfront

Decision Guidance

Do you need to verify HOW a dependency was called?
|
+-- YES --> Do you need a working implementation too?
|           |
|           +-- YES --> Spy (record calls on a fake)
|           +-- NO  --> Mock (NSubstitute / Moq)
|
+-- NO  --> Do you need the dependency to DO something realistic?
            |
            +-- YES --> Fake (in-memory implementation)

Use this skill in a project

Open any RiverX project, search the Skills panel for wshaddix/dotnet-skills/dotnet-testing-strategy, and install it in one click.

Use this skill