The Test Pyramid: A Practical Guide
Every development team knows they should write tests. But what kind of tests, how many, and at what level? The Test Pyramid, introduced by Mike Cohn, provides a simple but powerful framework for answering these questions.
The core idea: write many fast, cheap unit tests at the base; some integration tests in the middle; and few slow, expensive end-to-end tests at the top. This gives you maximum confidence with minimum maintenance cost.
The Base: Unit Tests (~75% of Your Tests)
Unit tests verify individual functions, methods, or classes in isolation. They're the foundation of your test suite because they're fast, reliable, and pinpoint exactly where something broke.
What Makes a Good Unit Test
- Fast — runs in milliseconds, no I/O, no network, no database
- Isolated — tests one thing, mocks external dependencies
- Deterministic — same input always produces same output
- Self-contained — no shared state between tests
// Example: Unit test for a discount calculator
@Test
void shouldApply20PercentDiscountForPremiumUsers() {
DiscountCalculator calculator = new DiscountCalculator();
User premiumUser = new User(UserTier.PREMIUM);
Money discount = calculator.calculate(
new Money(100.00), premiumUser
);
assertEquals(new Money(20.00), discount);
}
@Test
void shouldApplyNoDiscountForFreeUsers() {
DiscountCalculator calculator = new DiscountCalculator();
User freeUser = new User(UserTier.FREE);
Money discount = calculator.calculate(
new Money(100.00), freeUser
);
assertEquals(new Money(0.00), discount);
}
What to Unit Test
- Business logic and calculations
- Data transformations and mappings
- Validation rules
- Edge cases and error handling
- State machines and workflows
What NOT to Unit Test
- Simple getters/setters with no logic
- Framework code (Spring, Hibernate — they have their own tests)
- Third-party library behavior
- Configuration and wiring (that's integration testing territory)
The Middle: Integration Tests (~20% of Your Tests)
Integration tests verify that components work together correctly. They test the boundaries between your code and external systems — databases, APIs, message queues, and other services.
Types of Integration Tests
- Database integration — test that your queries, migrations, and repository layer work with a real database (use Testcontainers)
- API integration — test your REST/gRPC endpoints with real HTTP requests
- Message queue integration — test that messages are published and consumed correctly
- Contract tests — verify that service-to-service contracts haven't broken (using Pact or Spring Cloud Contract)
// Example: Integration test with Testcontainers
@Testcontainers
@SpringBootTest
class OrderRepositoryIntegrationTest {
@Container
static PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:15");
@Autowired
private OrderRepository orderRepository;
@Test
void shouldPersistAndRetrieveOrder() {
Order order = new Order("user-123",
List.of(new LineItem("Widget", 2, 9.99)));
orderRepository.save(order);
Order found = orderRepository
.findById(order.getId()).orElseThrow();
assertEquals("user-123", found.getUserId());
assertEquals(1, found.getLineItems().size());
}
}
The Top: End-to-End Tests (~5% of Your Tests)
End-to-end (E2E) tests verify complete user workflows through the entire system. They simulate real user behavior — clicking buttons, filling forms, navigating pages.
Characteristics of E2E Tests
- Slow — minutes per test (browser rendering, network calls, database setup)
- Brittle — break easily when UI changes, even if functionality is fine
- Expensive — require full environment setup
- High confidence — if they pass, the user flow works
What to E2E Test
Only test critical user journeys:
- User registration and login
- Core purchase/checkout flow
- Payment processing
- Key business workflows
If you have 500 E2E tests and they take 2 hours to run, developers will stop running them. Keep E2E tests focused on the 5-10 most critical user journeys.
The Anti-Pattern: The Ice Cream Cone
Many teams accidentally build an inverted pyramid — the "ice cream cone" — with lots of E2E tests, some integration tests, and few unit tests. This happens when:
- Teams test through the UI because it "feels more realistic"
- Business logic lives in controllers instead of testable service classes
- Code isn't designed for testability (tight coupling, no dependency injection)
- Manual QA is the primary quality gate
Contract Testing: The Missing Layer for Microservices
In a microservices architecture, the test pyramid still applies, but there's a critical gap: how do you verify that services can talk to each other correctly without running expensive E2E tests? This is where contract testing comes in.
Contract testing verifies that the API contract between a consumer (the service calling the API) and a provider (the service exposing the API) hasn't been broken. Each side tests independently against a shared contract definition.
How Contract Testing Works
- Consumer defines expectations — the Order Service writes a test saying "when I call POST /payments with this body, I expect a 200 response with paymentId and status"
- Contract is generated — the test produces a contract file (a Pact file) that captures these expectations
- Provider verifies the contract — the Payment Service runs the contract against its actual API to verify it still satisfies the consumer's expectations
- Both sides run in CI — if the provider makes a breaking change, the contract test fails before it reaches production
Contract Testing Tools
- Pact — the most popular consumer-driven contract testing framework, supports multiple languages
- Spring Cloud Contract — contract testing for Spring Boot microservices, generates tests from contract definitions
- Specmatic — contract testing from OpenAPI specifications
Where Contract Tests Fit in the Pyramid
Contract tests sit between integration and E2E tests. They're faster than E2E tests (no full environment needed) but verify cross-service compatibility that unit and integration tests can't catch. In a microservices architecture, contract tests often replace most E2E tests, giving you the same confidence at a fraction of the cost.
Additional Microservices Testing Strategies
- Component Testing — test a single service in isolation with its dependencies mocked or stubbed.
- Chaos Testing — inject failures (network latency, service crashes) to verify resilience. Tools: Chaos Monkey, Litmus.
Practical Tips for Building a Good Test Suite
- Design for testability — use dependency injection, separate business logic from I/O, keep functions pure where possible.
- Use Testcontainers — spin up real databases, message queues, and caches in Docker for integration tests. No more "works on my machine."
- Run tests in CI — every commit should trigger the full test suite. Unit tests on every push, integration tests on PR, E2E tests before deploy.
- Keep tests fast — if your test suite takes 30+ minutes, developers will skip it. Parallelize, cache, and optimize.
- Test behavior, not implementation — tests should verify what the code does, not how it does it. This makes tests resilient to refactoring.
- Delete flaky tests — a test that fails randomly is worse than no test. Fix it or remove it.
Conclusion
The test pyramid isn't a rigid rule — it's a guideline that helps you build a test suite that's fast, reliable, and maintainable. The key insight is that different types of tests serve different purposes, and you need the right balance.
Invest heavily in unit tests for fast feedback. Add integration tests for boundary verification. Use E2E tests sparingly for critical user journeys. And always design your code for testability from the start.
At TechTrailCamp, testing strategy is a core part of every track. You'll learn to build production-grade test suites through hands-on, 1:1 mentoring with real-world projects.
Want to build better test suites?
Join TechTrailCamp's 1:1 training and learn testing strategies that work in production.
Start Your Learning Journey
TechTrailCamp