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):
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:
- Quarantine immediately (mark
[Skip]with link to issue). - Investigate: timing, ordering, shared state.
- 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 tests —
if/switchin tests = bug factory. - Interdependent — order matters → flaky.
- Names that don't describe behavior —
Test1,TestMethod.
Code coverage caveats
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
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
✅ Correct: threshold + quality review
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.