MCPInstall

dotnet-testing-strategy

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

Installs
610
Last 24h
—
Trending rank
—
Security
Not audited

Install

Works with every agent the skills CLI supports.

npx skills add https://github.com/wshaddix/dotnet-skills --skill dotnet-testing-strategy
  1. 01

    Install

    Run the command in your project. The CLI asks which agents to install for.

  2. 02

    Describe the task

    Ask your agent for something this skill covers. It loads dotnet-testing-strategy automatically when relevant.

  3. 03

    Review the result

    The agent follows the SKILL.md steps, scripts and references bundled with the skill.

SKILL.md

The instructions your agent reads when the skill is triggered.

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)
            +-- NO  --> Stub (return canned values)

Example: Stub vs Mock vs Fake

// STUB: Returns canned data -- verifying the code under test's logic
var priceService = Substitute.For<IPriceService>();
priceService.GetPriceAsync("SKU-001").Returns(29.99m);  // canned return

var total = await calculator.CalculateTotalAsync(items);
Assert.Equal(59.98m, total);  // assert on the result, not the call

// MOCK: Verifies interaction -- ensuring a side effect happened
var emailSender = Substitute.For<IEmailSender>();

await orderService.CompleteAsync(order);

await emailSender.Received(1).SendAsync(             // assert on the call
    Arg.Is<string>(to => to == order.CustomerEmail),
    Arg.Any<string>(),
    Arg.Any<string>());

// FAKE: In-memory implementation -- realistic behavior without infrastructure
public class FakeOrderRepository : IOrderRepository
{
    public Dictionary<Guid, Order> Orders { get; } = new();

    public Task<Order?> GetByIdAsync(Guid id, CancellationToken ct = default)
        => Task.FromResult(Orders.GetValueOrDefault(id));

    public Task SaveAsync(Order order, CancellationToken ct = default)
    {
        Orders[order.Id] = order;
        return Task.CompletedTask;
    }
}

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

// BAD: Breaks when refactoring internals
repository.Received(1).GetByIdAsync(Arg.Is<Guid>(id => id == orderId));
repository.Received(1).SaveAsync(Arg.Any<Order>());
// ... five more Received() calls verifying the exact call sequence

// GOOD: Test the observable outcome
var result = await service.ProcessAsync(orderId);
Assert.Equal(OrderStatus.Completed, result.Status);

2. Excessive Mock Setup

// BAD: Mock setup is longer than the actual test
var repo = Substitute.For<IOrderRepository>();
var pricing = Substitute.For<IPricingService>();
var inventory = Substitute.For<IInventoryService>();
var shipping = Substitute.For<IShippingService>();
var notification = Substitute.For<INotificationService>();
var audit = Substitute.For<IAuditService>();
// ... 20 lines of .Returns() setup

// BETTER: Use a builder or fake that encapsulates setup
var fixture = new OrderServiceFixture()
    .WithOrder(testOrder)
    .WithPrice("SKU-001", 29.99m);
var result = await fixture.Service.ProcessAsync(testOrder.Id);

3. Non-Deterministic Tests

Tests must not depend on system clock, random values, or external network. Inject abstractions:

// BAD: Uses DateTime.UtcNow directly
public bool IsExpired() => ExpiresAt < DateTime.UtcNow;

// GOOD: Inject TimeProvider (.NET 8+)
public bool IsExpired(TimeProvider time) => ExpiresAt < time.GetUtcNow();

// In test
var fakeTime = new FakeTimeProvider(new DateTimeOffset(2025, 6, 15, 0, 0, 0, TimeSpan.Zero));
Assert.True(order.IsExpired(fakeTime));

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

FAQ

What does the dotnet-testing-strategy skill do?

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

How do I install dotnet-testing-strategy?

Run npx skills add https://github.com/wshaddix/dotnet-skills --skill dotnet-testing-strategy in your project. The skills CLI detects the agents you use and copies the skill into the right folder. For Claude Code you can also place the skill folder at ~/.claude/skills/dotnet-testing-strategy/ (personal) or .claude/skills/dotnet-testing-strategy/ (project).

Which AI agents can use dotnet-testing-strategy?

Any agent that supports the open Agent Skills format (a folder with a SKILL.md file), including Claude Code, Codex, Cursor, GitHub Copilot, Gemini CLI, Windsurf, Cline, OpenCode and more.

Is dotnet-testing-strategy safe to install?

No partner security audit is available yet. Skills can run commands and scripts, so read the SKILL.md and any bundled files before installing.

Who maintains dotnet-testing-strategy?

It is published by wshaddix in wshaddix/dotnet-skills. It has 610 recorded installs.

More from wshaddix/dotnet-skills

View all →

dotnet-xunit

wshaddix/dotnet-skills

Writing xUnit tests. v3 Fact/Theory, fixtures, parallelism, IAsyncLifetime, v2 compatibility.

Backend & APIs613 installs

modern-csharp-coding-standards

wshaddix/dotnet-skills

Write modern, high-performance C code using records, pattern matching, value objects, async/await, Span /Memory , and best-practice API design patterns. Emphasizes functional-style programming with C 12+ features. Use when writing new C code or refactoring existing code, designing public APIs for libraries or services, optimizing performance-critical code paths, or building async/await-heavy applications.

csharp-wolverinefx

wshaddix/dotnet-skills

Build .NET applications with WolverineFX for messaging, HTTP services, and event sourcing. Use when implementing command handlers, message handlers, HTTP endpoints with WolverineFx.HTTP, transactional outbox patterns, event sourcing with Marten, CQRS architectures, cascading messages, batch message processing, or configuring transports like RabbitMQ, Azure Service Bus, or Amazon SQS.

Backend & APIs570 installs

testcontainers

wshaddix/dotnet-skills

Patterns for using Testcontainers in .NET integration tests to spin up real dependencies like databases and message queues. Use when writing integration tests that require real databases, testing with message brokers like RabbitMQ or Kafka, or isolating test dependencies with Docker containers.

Testing & QA565 installs

Dependency injection patterns and best practices using Microsoft.Extensions.DependencyInjection for .NET applications. Use when configuring DI containers in .NET, choosing between service lifetimes (Singleton, Scoped, Transient), or implementing decorator patterns and service interception.

Security549 installs

logging-observability

wshaddix/dotnet-skills

Production-grade logging and observability patterns for ASP.NET Core Razor Pages. Covers structured logging with Serilog, correlation IDs, health checks, request logging, OpenTelemetry integration, and diagnostic best practices. Use when setting up structured logging in ASP.NET Core applications, implementing distributed tracing with OpenTelemetry, or configuring health checks and observability.

DevOps & Cloud542 installs

Popular testing & qa skills

View category →

tdd

mattpocock/skills

Test-driven development. Use when the user wants to build features or fix bugs test-first, mentions "red-green-refactor", or wants integration tests.

Testing & QA1.2M installs

grilling

mattpocock/skills

Grill the user relentlessly about a plan, decision, or idea. Use when the user wants to stress-test their thinking, or uses any 'grill' trigger phrases.

Testing & QA996.2K installs

migrate-to-shoehorn

mattpocock/skills

Migrate test files from as type assertions to @total-typescript/shoehorn. Use when user mentions shoehorn, wants to replace as in tests, or needs partial test data.

Testing & QA484.1K installs

run-train

lllllllama/rigorpilot-skills

Rigor Train skill for deep learning research repositories. Use when a documented or selected training command should be run conservatively for startup verification, short-run verification, full kickoff, or resume, with command, config, seed, log, checkpoint, status, and metric evidence written to standardized trainoutputs/. Do not use for environment setup, exploratory sweeps, speculative idea implementation, or end-to-end orchestration.

Testing & QA337.8K installs

test-driven-development

obra/superpowers

Use when implementing any feature or bugfix, before writing implementation code

Testing & QA261.8K installs

Use when implementation is complete, all tests pass, and you need to decide how to integrate the work

Testing & QA216.8K installs