The test pyramid is a framework for deciding how many tests to write at each level of the stack. Its core insight: tests at lower levels are faster, cheaper to write, and catch bugs earlier — but they only test one unit in isolation. Tests at higher levels confirm that units work together but are slower and more brittle. A healthy test suite has many unit tests, a moderate number of integration tests, and a small number of E2E tests — the pyramid shape. The “ice cream cone” anti-pattern (many E2E, few unit tests) is slow, fragile, and expensive.
The Three Tiers for BlogApp
-- ── Test Pyramid for the BlogApp ─────────────────────────────────────────
--
-- /\
-- / \ E2E Tests (Cypress)
-- / ↑↑ \ ~20 tests
-- /------\
-- / Integr. \ Integration Tests (WebApplicationFactory)
-- / Tests \ ~80 tests
-- /-------------\
-- / Unit Tests \ Unit Tests (xUnit + Moq)
-- / (many! ~300) \
-- /------------------\
--
-- ── Unit tests — what to unit test in the BlogApp ─────────────────────────
-- ✅ PostsService business rules:
-- - CreateAsync: validates slug uniqueness, sets AuthorId from current user
-- - PublishAsync: checks post is in 'review' status and owned by author
-- - SoftDeleteAsync: sets Status = 'archived', IsPublished = false
-- ✅ JwtTokenService: generates correct claims (sub, email, roles, exp)
-- ✅ AuthService.Login: validates credentials, handles locked account
-- ✅ SlugGenerator.Generate: converts titles to URL-safe slugs
-- ✅ BlogHub.SendComment: validates post exists, saves, broadcasts
-- ❌ SKIP: PostsRepository.GetByIdAsync (just an EF Core call — integration test territory)
-- ❌ SKIP: Migrations, DbContext setup, configuration binding
-- ── Integration tests — what to integration test ─────────────────────────
-- ✅ POST /api/posts: full pipeline including validation, auth, EF Core, response shape
-- ✅ GET /api/posts/{slug}: correct 404 for missing slug
-- ✅ PUT /api/posts/{id} with stale ETag: returns 412
-- ✅ DELETE /api/posts/{id} as non-owner: returns 403
-- ✅ POST /api/auth/login with wrong password: returns 401
-- ✅ File upload with invalid file type: returns 400 with ProblemDetails
-- ── E2E tests — what to E2E test ──────────────────────────────────────────
-- ✅ Login flow: enter credentials → navbar shows user name
-- ✅ Admin post creation: fill form → publish → appears in public list
-- ✅ Comment submission: submit comment → appears in real-time (two tabs)
-- ❌ SKIP: CRUD operations (covered by integration tests)
-- ❌ SKIP: Validation error display (covered by Angular unit tests)
Test Characteristics Comparison
| Property | Unit Tests | Integration Tests | E2E Tests |
|---|---|---|---|
| Speed | Milliseconds | Seconds | Minutes |
| Isolation | Full (mocked deps) | Partial (real DB) | None (full stack) |
| Maintenance | Low | Medium | High |
| Confidence | Low (mocked reality) | High (real pipeline) | Very High |
| Failure diagnosis | Instant — exact line | Quick — service layer | Slow — where in the stack? |
| BlogApp count | ~300 | ~80 | ~20 |
Common Mistakes
Mistake 1 — Testing implementation details (tests break on refactor)
❌ Wrong — testing that _repository.GetByIdAsync() was called with specific params; breaks when implementation changes.
✅ Correct — test observable behaviour (return value, state change, exception thrown), not which methods were called internally.
Mistake 2 — E2E tests for validation errors (slow, brittle, overkill)
❌ Wrong — Cypress test for “form shows error when title is empty”; a 30-second browser test for a 100ms unit assertion.
✅ Correct — Angular unit test for the reactive form validator; E2E only for critical user journeys.