Skip to content

Test Architecture & Coverage

Key Points

  • Test pyramid (still right): many unit, fewer integration, very few E2E. Inverted pyramid (mostly E2E) = slow, flaky, costly.
  • Senior view: "trophy" or "honeycomb" — heavy integration tests can replace shallow unit tests when they're fast enough. Integration tests give better confidence per minute.
  • Coverage is a floor (gate at 60–80%), not a goal. 100% coverage with weak assertions is worthless.
  • What to NOT test: trivial getters, framework code, third-party libraries, generated code. Test your logic and edges.
  • Project structure: one test project per main project, mirroring namespaces. Keep test fixtures and helpers separate from tests.

Concepts (deep dive)

The test pyramid

            /\
           /E2E\          5%   slow, fragile
          /------\
         / Integ. \      25%   real boundaries
        /----------\
       /   Unit     \    70%   fast, isolated
      /--------------\
  • Unit: pure logic, no I/O. Milliseconds.
  • Integration: with DB / queue / HTTP. Hundreds of ms.
  • E2E: full system through a browser/UI. Seconds.

The "trophy" model

         /\
        /  \    E2E     5%
       /----\
      /      \  Integ. 50%
     /--------\
    / Unit     \ 30%
   /------------\
  /   Static    \ 15%   types, linters

Increasingly common in modern stacks. Fast integration tests (in-memory factory + Testcontainers) replace fragile unit-mocked tests.

What to test

Yes: - Business rules, domain logic. - Edge cases (empty, null, boundary, max). - Error handling. - API contracts (request/response shape). - Critical workflows end-to-end. - Bug fixes (regression test for each).

No: - Trivial getters/setters. - Framework code (ASP.NET routing, EF behavior). - Third-party libraries. - Generated code (unless you're authoring the generator).

Coverage tools

dotnet test --collect:"XPlat Code Coverage"
# Cobertura output

dotnet tool install -g dotnet-reportgenerator-globaltool
reportgenerator -reports:**/coverage.cobertura.xml -targetdir:report -reporttypes:Html

Or use Coverlet (built into Microsoft.NET.Test.Sdk):

<PackageReference Include="coverlet.collector" Version="6.*" />

In CI:

- run: dotnet test --collect:"XPlat Code Coverage" --results-directory ./coverage
- uses: codecov/codecov-action@v5

Coverage thresholds

<!-- in csproj -->
<Threshold>80</Threshold>
<ThresholdType>line,branch,method</ThresholdType>
<ThresholdStat>total</ThresholdStat>

Or via Coverlet args. Failed threshold = failed build.

Sane gates: 70–80% line coverage. Branch coverage tighter (>60%). 100% is rarely worth the marginal cost.

Test project structure

src/
├── MyApp.Domain/
├── MyApp.Application/
├── MyApp.Infrastructure/
└── MyApp.Api/

tests/
├── MyApp.Domain.UnitTests/
├── MyApp.Application.UnitTests/
├── MyApp.Infrastructure.IntegrationTests/
└── MyApp.Api.IntegrationTests/

Mirror src structure. One test project per main project. Avoid mixing unit and integration tests in the same project (different speeds).

Naming and organization

// Classes named after the SUT (system under test):
namespace MyApp.Domain.UnitTests;

public class OrderTests
{
    public class WhenPlacing : OrderTests
    {
        [Fact] public void With_no_lines_throws() { /* ... */ }
        [Fact] public void With_negative_qty_throws() { /* ... */ }
        [Fact] public void With_valid_lines_succeeds() { /* ... */ }
    }

    public class WhenCancelling : OrderTests
    {
        [Fact] public void Already_cancelled_throws() { /* ... */ }
    }
}

Nested classes group related tests. Reads like specifications.

Test data builders

public class OrderBuilder
{
    private CustomerId _customer = new(Guid.NewGuid());
    private List<OrderLine> _lines = new() { new OrderLine(/* defaults */) };

    public OrderBuilder For(CustomerId c) { _customer = c; return this; }
    public OrderBuilder With(OrderLine l) { _lines.Add(l); return this; }
    public Order Build() => Order.Place(_customer, _lines);
}

[Fact]
public void Place_with_two_lines()
{
    var order = new OrderBuilder()
        .With(new OrderLine(productA, 2, 5m))
        .With(new OrderLine(productB, 1, 10m))
        .Build();
    Assert.Equal(20m, order.Total);
}

Reduces test setup ceremony. Fluent and readable.

Object Mother pattern

public static class Orders
{
    public static Order Pending() => /* ... */;
    public static Order PaidWithTwoLines() => /* ... */;
    public static Order WithStatus(OrderStatus s) => /* ... */;
}

[Fact]
public void Cancel_paid_throws()
{
    var order = Orders.PaidWithTwoLines();
    Assert.Throws<InvalidOperationException>(order.Cancel);
}

Centralizes complex test object construction.

Assertion organization

For multi-step assertions:

// One Act, multiple Asserts on the result:
[Fact]
public void Place_creates_pending_order()
{
    var order = Order.Place(/* ... */);

    using (new AssertionScope())   // FluentAssertions
    {
        order.Status.Should().Be(OrderStatus.Pending);
        order.Total.Should().Be(20m);
        order.LineCount.Should().Be(2);
    }
}

AssertionScope reports all failures, not just the first.

Test categorization / tags

[Trait("Category", "Integration")]
public class IntegrationTests { /* ... */ }

// Run only some:
dotnet test --filter "Category=Integration"

Useful for splitting fast unit tests vs slow integration tests in CI.

CI pipeline

- name: Restore
  run: dotnet restore
- name: Build
  run: dotnet build --configuration Release --no-restore
- name: Unit tests (parallel)
  run: dotnet test --filter "Category!=Integration" --no-build
- name: Integration tests (serial)
  run: dotnet test --filter "Category=Integration" --no-build

Two-phase: fast feedback from unit, slower from integration.

Flaky test management

When a test occasionally fails:

  1. Quarantine immediately (mark [Skip] with link to issue).
  2. Investigate: timing, ordering, shared state.
  3. Fix or delete. Keep the suite green.

Flaky tests destroy trust in the suite — within a month, real failures are ignored.

Test smells

  • Long setup — refactor builders/mothers.
  • Mock-heavy — abstractions are wrong.
  • Brittle — too coupled to internals.
  • Conditional logic in testsif/switch in tests = bug factory.
  • Interdependent — order matters → flaky.
  • Names that don't describe behaviorTest1, TestMethod.

Code coverage caveats

80% coverage: 80% of *lines* executed by some test.

Lines executed ≠ behavior verified. A test that calls a method but asserts nothing covers it.

Mutation testing (Snapshot & Mutation Testing) is the better measure for test strength.

When tests slow you down

If tests take >10 minutes locally, the suite has lost its purpose. Strategies:

  • Move slow tests to "nightly" tier.
  • Parallelize aggressively (xUnit defaults are good).
  • Reuse fixtures (collection fixture).
  • Investigate slowest 10% — usually they're the bottleneck.

Code: correct vs wrong

❌ Wrong: testing implementation

[Fact]
public void GetUser_calls_repo_GetById_once()
{
    /* mocks repo, verifies the call */
}

Couples test to implementation. Refactor breaks tests.

✅ Correct: testing behavior

[Fact]
public async Task GetUser_returns_existing_user()
{
    var user = await _sut.GetAsync(1);
    Assert.Equal("Alice", user.Name);
}

❌ Wrong: arbitrary thresholds

"We need 90% coverage on all PRs!" — pushed via meaningless tests.

✅ Correct: threshold + quality review

80% line coverage required. Test additions reviewed for assertion quality.

Design patterns for this topic

Pattern 1 — "Test pyramid (or trophy)"

  • Intent: balance speed vs confidence.

Pattern 2 — "Builders + Object Mothers"

  • Intent: readable test setup.

Pattern 3 — "One project per layer"

  • Intent: mirrors src structure.

Pattern 4 — "Categorized tests in CI"

  • Intent: fast feedback first.

Pattern 5 — "Quarantine then fix flaky tests"

  • Intent: preserve suite trust.

Pros & cons / trade-offs

Aspect Pros Cons
Pyramid Fast feedback Many small mocks
Trophy Better integration Slower
100% coverage Strict Diminishing returns
Coverage gate Catches drift Encourages low-quality tests
Builders Readable Maintenance

When to use / when to avoid

  • Use unit tests for pure logic.
  • Use integration tests for orchestration.
  • Use E2E sparingly (smoke / golden flows).
  • Avoid testing trivial code.
  • Avoid coverage worship — focus on assertion strength.

Interview Q&A

Q1. Test pyramid vs trophy? Pyramid: many unit, few integration, very few E2E. Trophy: heavy integration replacing shallow units, with strong static analysis. Modern stacks favor trophy.

Q2. Why is coverage misleading? Measures execution, not assertion strength. 100% coverage with empty Assert.True(true) is worthless. Mutation testing measures real strength.

Q3. How structure test projects? One test project per main project, mirroring namespaces. Categorize fast/slow.

Q4. What's a test data builder? Fluent class for assembling test objects. Reduces setup ceremony.

Q5. Object Mother pattern? Static class returning common test scenarios — Orders.Pending(), Customers.WithUnpaidBalance(). Centralized.

Q6. How handle flaky tests? Quarantine, investigate, fix or delete. Don't accept "occasional flake".

Q7. What's a test smell? Indicator of bad test: long setup, mock-heavy, brittle, conditional logic, interdependence.

Q8. Why nest test classes? Group by behavior — WhenPlacing, WhenCancelling. Reads like a spec.

Q9. Sane coverage threshold? 70–80% line coverage with branch coverage check. Hard 100% rarely worth it.

Q10. How fast should the test suite be? Local: <10s for unit, <2 min for full. CI: <10 min full. Slower = ignored.


Gotchas / common mistakes

  • ⚠️ Coverage as goal instead of floor.
  • ⚠️ Testing private methods via reflection.
  • ⚠️ Conditional logic in tests — debug nightmare.
  • ⚠️ Order-dependent tests — flaky.
  • ⚠️ Same test asserting many things — when one fails, you can't see the others.
  • ⚠️ No test categorization — slow suite feedback loop.

Further reading