The Test Pyramid — Unit, Integration and End-to-End Tests

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)
Note: The “right” ratio depends on the application. A pure calculation engine might be 95% unit tests. A data-entry CRUD app might be 60% integration tests. For the BlogApp — which has complex business rules (post lifecycle, JWT auth, real-time), UI logic (reactive forms, routing, signals), and critical API contracts — a 60% unit / 30% integration / 10% E2E split is reasonable. The key principle: don’t write E2E tests for things that can be verified at a lower, faster level.
Tip: Use the “testing honeycomb” mental model for microservices or heavily integrated systems where integration tests are cheap (in-memory WebApplicationFactory is fast). For the BlogApp’s single API + single Angular app, the classic pyramid is appropriate. But if the BlogApp grows into multiple services, integration tests between services become more valuable and the pyramid shifts toward a honeycomb shape with more integration tests relative to unit tests.
Warning: The ice cream cone anti-pattern — where E2E tests outnumber unit and integration tests — creates a brittle, slow test suite. E2E tests are 10-100x slower than unit tests, fail for unrelated reasons (network flakiness, timing issues, test data state), and are expensive to diagnose when they fail. If your CI pipeline takes 30 minutes because of E2E tests, developers stop running tests locally and start skipping CI failures. Fast feedback (sub-1-minute unit test run) is the most important property of a test suite.

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.

🧠 Test Yourself

The BlogApp has 500 tests: 100 unit, 350 integration (slow WebApplicationFactory), 50 E2E. The CI pipeline takes 45 minutes. What is the most impactful fix?