Learnixo
Back to blog
AI Systemsintermediate

Test Strategy — Pyramid, Coverage, and What to Skip

Build an effective test strategy: the test pyramid for Clean Architecture, what coverage metrics actually tell you, which tests to write first, and the signals that tell you when tests are wrong.

Asma Hafeez KhanMay 16, 20266 min read
TestingTest Strategy.NETTDDIntegration Tests
Share:𝕏

The Test Pyramid for Clean Architecture

          ┌─────────────────────────┐
          │    E2E / Contract Tests │  ← Few, slow, test system boundaries
          ├─────────────────────────┤
          │   Integration Tests     │  ← Some: real DB, real HTTP
          ├─────────────────────────┤
          │   Architecture Tests    │  ← Always run: layer boundaries
          ├─────────────────────────┤
          │       Unit Tests        │  ← Many: domain, handlers, fast
          └─────────────────────────┘

Target distribution:
  Unit tests:         70%+ of all tests — milliseconds each
  Integration tests:  20-25% — test DB, HTTP pipeline
  Architecture tests: always run — catches structural drift
  E2E tests:          5-10% — critical user journeys only

What to Unit Test

C#
// ✓ Domain entity invariants
[Fact] public void Patient_created_with_future_dob_should_fail() { }
[Fact] public void Prescription_added_to_inactive_patient_should_fail() { }
[Fact] public void INR_reading_outside_therapeutic_range_should_flag() { }

// ✓ Value object validation
[Theory]
[InlineData(0m,   "mg",    "AmountMustBePositive")]
[InlineData(-1m,  "mg",    "AmountMustBePositive")]
[InlineData(100m, "pints", "InvalidUnit")]
public void Dosage_invalid_values_should_fail(decimal amount, string unit, string code) { }

// ✓ Application handler logic
[Fact] public async Task Handle_duplicate_mrn_should_not_save() { }
[Fact] public async Task Handle_valid_command_should_return_id() { }

// ✓ Business rules (domain services)
[Fact] public void Drug_interaction_checker_should_flag_warfarin_aspirin() { }

What NOT to Unit Test

Skip unit tests for:
  ✗ Controllers — they call handlers; test handlers instead
  ✗ Repository implementations — use integration tests with real DB
  ✗ DI registration — if the app starts, registrations work
  ✗ Simple DTOs with no logic (just properties)
  ✗ Trivial delegation (method A calls method B with no logic)
  ✗ Framework code (ASP.NET model binding, EF Core change tracking)
  ✗ Third-party library behavior (FluentValidation's own validation logic)

Unit test: when there is branching logic you could implement incorrectly
Skip:      when there is no logic — only pass-through or framework behavior

What to Integration Test

Write an integration test when:
  ✓ DB query behavior (Contains, ordering, filtering)
  ✓ Unique constraints and FK behavior
  ✓ Migration correctness
  ✓ HTTP endpoint → handler → DB → response (full pipeline)
  ✓ Authentication/authorization in the HTTP pipeline
  ✓ Cache invalidation after mutations
  ✓ Domain events triggering downstream effects
  ✓ Error responses (400, 401, 403, 404, 409 returned correctly)

Coverage — What It Tells You and What It Does Not

Coverage percentage tells you:
  ✓ Which lines of code have been executed by tests
  ✓ Branches that are never taken by any test

Coverage percentage does NOT tell you:
  ✗ Whether the tested behavior is correct
  ✗ Whether edge cases are covered
  ✗ Whether production bugs are prevented

Example:
  var result = patient.Create(name, dob, mrn);
  result.IsSuccess.Should().BeTrue();  // 100% line coverage
  // But does NOT assert the patient's properties are correct
  // Does NOT test failure cases
  // 100% coverage with 0% useful assertions

Target: 70-80% meaningful coverage, not 100% coverage at any cost

Production issue I've seen: A team enforced 90% code coverage in CI as a quality gate. Developers wrote tests that exercised every branch but asserted only result.IsSuccess.Should().BeTrue(). The coverage was 91%. A drug dosage calculation bug (wrong unit conversion) deployed to production — the test exercised the code but never checked the dosage value. Coverage without assertion quality is theater.


Error Path Testing

Happy path tests are written by everyone.
Error path tests catch the bugs that reach production.

For every handler, write the failure tests first:
  - Not found → correct 404 response
  - Duplicate → correct 409 with right error code
  - Invalid input → correct 422 with validation errors
  - Unauthorized → correct 401
  - Forbidden (right auth, wrong role) → correct 403
  - Concurrent conflict → correct 409 conflict

Each of these is a production bug waiting to happen if untested.

Architecture Tests

C#
// NetArchTest — always run, catches architectural drift
[Fact]
public void Domain_should_not_reference_application()
{
    var result = Types.InAssembly(DomainAssembly)
        .Should().NotHaveDependencyOn(ApplicationAssembly.GetName().Name!)
        .GetResult();

    result.IsSuccessful.Should().BeTrue(
        "Domain must not reference Application layer");
}

[Fact]
public void Domain_should_not_reference_infrastructure()
{
    var result = Types.InAssembly(DomainAssembly)
        .Should().NotHaveDependencyOn(InfrastructureAssembly.GetName().Name!)
        .GetResult();

    result.IsSuccessful.Should().BeTrue();
}

[Fact]
public void All_handlers_should_be_sealed()
{
    var result = Types.InAssembly(ApplicationAssembly)
        .That().HaveNameEndingWith("Handler")
        .Should().BeSealed()
        .GetResult();

    result.IsSuccessful.Should().BeTrue(
        "Handlers should be sealed — they are not designed for inheritance");
}

Test Quality Signals

Signs your tests are well-designed:
  ✓ Tests fail when production bugs are introduced
  ✓ Tests pass when behavior is correct (not just when code runs)
  ✓ Failure messages explain what went wrong without reading the code
  ✓ Tests survive refactoring (as long as behavior stays the same)
  ✓ New developers understand what the system does by reading the tests

Signs your tests are poorly designed:
  ✗ Tests break on every refactoring even when behavior is unchanged
  ✗ Test failure message: "Expected True but was False"
  ✗ Happy paths only — failures are not tested
  ✗ Mocking DbContext directly
  ✗ Tests that test the test framework, not your code
  ✗ 100% coverage but no confidence the system works

TDD Workflow for Handlers

1. Write the failing test first (red)
   - What is the input?
   - What should happen (success or failure)?
   - What side effects should occur (save, email, event)?

2. Write the minimum code to make it pass (green)
   - No premature optimization
   - No extra features

3. Refactor (if needed)
   - Extract patterns
   - Clean names
   - All tests still green

Benefit: tests are written while the behavior is clear in your mind,
         not retroactively to hit a coverage target

Red Flag / Green Answer

Red Flag: "We write tests after the feature is done to get coverage above 80%."

Tests written after implementation tend to test what the code does, not what it should do. They verify the current behavior (including bugs) rather than the intended behavior. TDD or at least writing failure tests first prevents this.

Green Answer:

Write the handler test first — failure cases, then happy path. Implementation follows. Tests verify intent, not just current behavior. Coverage is a side effect of good test strategy, not a goal.


Key Takeaway

A good test strategy is a pyramid: many fast unit tests for domain and handler logic, some integration tests with real DB (Testcontainers) for persistence and HTTP pipeline, architecture tests always running, E2E tests for critical journeys only. Coverage is a floor, not a ceiling — 70% meaningful tests beat 95% assertion-free coverage. Always test error paths — they are where production bugs live.

Enjoyed this article?

Explore the AI Systems learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.