Metadata Card
- Prerequisites: Chapter 1 (Software Construction Foundations)
- Estimated Time: 50 minutes
- Core Difficulty: Foundation-building
- Completion Milestone: Be able to design a test strategy with appropriate coverage for unit/integration/E2E tests, master TDD basics
Your Progress
You've built a forge with measuring tools, organized your raw materials with ADT rigor, and created a blueprint from requirements. Before you start mass-producing parts, you run a QA check on the first piece.
The City of Artisans motto: "Test what you forge, or the sword will break in battle." Your Task
Testing isn't an afterthought. It's a continuum: unit tests validate individual components, integration tests validate component interaction, end-to-end tests validate the complete system. The test pyramid tells you which to write more of. TDD (Test-Driven Development) flips the order: write the test before the code. This chapter covers the full spectrum of testing strategies.
Chapter tiers
- Required reading: Test pyramid, unit testing, TDD cycle (red-green-refactor)
- Selective: Integration testing, test doubles (mocks/stubs/fakes)
- Advanced: Property-based testing, mutation testing
This chapter won't require you to master
- Formal test coverage criteria (MC/DC)
- Performance and load testing
Breaking Ground · Tracing the Source
You wrote a ranking algorithm:
public int calculateRank(int score, int totalPlayers) {
// Some complicated logic
return rank;
}You test it once manually — it works. But three months later, a requirements change moves the ranking logic. You change 5 lines. How do you know you didn't break the other 35 lines? If you had a test:
@Test
void topScoreGetsRankOne() {
int rank = rankingService.calculateRank(1000, 100);
assertEquals(1, rank);
}Run the tests. Green — everything still works. That's confidence.
First Layer: The Test Pyramid
/\
/ \ E2E Tests (few)
/ \
/------\
/ \ Integration Tests (some)
/ \
/------------\
/ \ Unit Tests (many)
/________________\- Unit tests: Test a single class/method in isolation. Fast (ms per test). Very many.
- Integration tests: Test interactions between components (database, network). Slower (s per test). Some.
- E2E tests: Test the full system from external interface to database. Slow (m per test). Few.
The pyramid shape is intentional: write many fast, reliable unit tests; write fewer integration tests that verify your components connect correctly; write even fewer E2E tests to validate critical user paths.
If your test suite looks like an inverted pyramid (many E2E tests, few unit tests), you have a slow, brittle test suite that developers will stop running.
Second Layer: Unit Testing With TDD
TDD cycle: Red → Green → Refactor
- Red: Write a test that fails (the feature doesn't exist yet)
- Green: Write the minimum code to make it pass
- Refactor: Improve the code while keeping tests green
Example: Ranking service
// Step 1: Red — write a failing test
public class RankingServiceTest {
@Test
void highestScoreGetsRankOne() {
RankingService service = new RankingService();
int rank = service.calculateRank(1000, 100);
assertEquals(1, rank);
}
}
// Step 2: Green — minimum code to pass
public class RankingService {
public int calculateRank(int score, int totalPlayers) {
return 1; // Hard-coded to pass the first test
}
}
// Step 3: Add more tests
@Test
void zeroScoreGetsLastRank() {
assertEquals(100, service.calculateRank(0, 100));
}
// Step 4: Generalize the implementation
public int calculateRank(int score, int totalPlayers) {
return totalPlayers - (score * totalPlayers / 1000) + 1;
}TDD produces testable code by forcing you to think about the interface before the implementation. A side effect: code written TDD tends to have cleaner interfaces and better separation of concerns.
Third Layer: Test Doubles
Unit tests should run in isolation, without real databases or network calls. Test doubles replace real dependencies:
// Real service — depends on database
public class TournamentService {
private final PlayerRepository repo;
public TournamentService(PlayerRepository repo) {
this.repo = repo;
}
public boolean canRegister(String playerId) {
Player player = repo.findById(playerId);
return player != null && player.getLevel() >= 5;
}
}
// Fake — lightweight in-memory implementation, perfect for testing
public class FakePlayerRepository implements PlayerRepository {
private final Map<String, Player> store = new HashMap<>();
@Override
public Player findById(String id) {
return store.get(id);
}
@Override
public void save(Player player) {
store.put(player.getId(), player);
}
}
// Unit test — no database needed
@Test
void playerWithSufficientLevelCanRegister() {
FakePlayerRepository repo = new FakePlayerRepository();
repo.save(new Player("p1", "test", 10));
TournamentService service = new TournamentService(repo);
assertTrue(service.canRegister("p1"));
}Mock vs Stub vs Fake:
- Stub: Returns fixed values. Used when you need to control the test scenario.
- Mock: Records interactions and can verify behavior. Used when you need to check "was this method called?"
- Fake: A lightweight but fully functional implementation (like in-memory database). Best choice when available.
Fourth Layer: Integration Testing
Integration tests verify that your code works correctly with real external systems. They're slower and more complex, but essential for catching issues that unit tests miss.
@SpringBootTest
@AutoConfigureTestDatabase
public class TournamentRepositoryIntegrationTest {
@Autowired
private TournamentRepository repository;
@Test
void savesAndRetrievesTournament() {
Tournament tournament = new Tournament("Test Cup", 32);
repository.save(tournament);
Tournament found = repository.findById(tournament.getId());
assertEquals("Test Cup", found.getName());
}
}Integration tests should still be fast enough to run as part of CI. If a test talks to the network, it's not an integration test—it's a smoke test.
Fifth Layer: E2E Testing
End-to-end tests exercise the entire system from user action to database write and back.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class TournamentE2ETest {
@Autowired
private TestRestTemplate rest;
@Test
void playerRegistrationFlow() {
// Register a player via REST
PlayerRegistration reg = rest.postForObject(
"/api/players", new RegisterRequest("alice"), PlayerRegistration.class);
assertNotNull(reg.getId());
// Create a tournament
TournamentInfo tournament = rest.postForObject(
"/api/tournaments", new CreateTournament("Alice's Cup", 8),
TournamentInfo.class);
// Register player for tournament
ResponseEntity<Void> response = rest.postForEntity(
"/api/tournaments/" + tournament.getId() + "/players/" + reg.getId(),
null, Void.class);
assertEquals(201, response.getStatusCodeValue());
}
}E2E tests are slow, brittle (fail due to network issues, data state), and expensive to maintain. Limit them to critical user journeys (login, payment, core feature).
Sixth Layer: Test Coverage — What It Does and Doesn't Mean
Line coverage = percentage of lines executed during tests. 100% line coverage does not mean 100% bug-free. It means "these lines ran," not "these lines were tested for all edge cases."
Coverage targets by layer:
| Layer | Target | Notes |
|---|---|---|
| Unit tests | 80-90% | Core logic. High coverage is feasible. |
| Integration tests | 60-70% | Main paths. Not every error scenario needs an integration test. |
| E2E tests | 20-30% | Critical paths only. Not line coverage—scenario coverage. |
Never set a coverage gate that forces developers to write tests just to hit a number. Tests written to increase coverage are worse than no tests (false confidence).
Common Pitfalls
Pitfall 1: Testing implementation details, not behavior. A test that checks "assert that the private method x was called" will break when you refactor. Test the public interface — what goes in and what comes out.
Pitfall 2: Over-mocking. Mocking every dependency creates brittle tests that verify "the code called what I expected" rather than "the code produced the right result." Use real implementations (or fakes) where possible, mock only at system boundaries.
Pitfall 3: Flaky tests. Tests that pass sometimes and fail sometimes. Root causes: shared mutable state, time-dependent assertions, network calls. A flaky test is worse than no test — it erodes trust in the entire suite.
Pitfall 4: Slow test suites. If the full suite takes 30 minutes, developers won't run it locally. Break into categories: fast tests (unit) run on every commit, slow tests (integration/E2E) run on PR merge.
Passing Challenges
- Warm up: Pick a method you wrote recently. Write 3 unit tests for it: happy path, edge case, error case.
- Challenge: Set up TDD for your next feature. Write the test first. Go through Red → Green → Refactor for at least 3 test cases.
- Observe: Run your project's test suite.
mvn test(Java) orpytest(Python) ornpm test(JS). How many tests? How long do they take? What's the unit/integration/E2E split?
Traveler's Notes
Testing is a continuum, not a binary "tested or not." The test pyramid guides distribution, TDD flips the order to design by test, test doubles provide isolation, and different test levels serve different confidence needs. A good test suite is fast, reliable, and gives you the confidence to change code without fear.
Preview of Next Chapter
Your tests catch bugs. But what about design problems? Code that works but is a nightmare to maintain? The next skill is recognizing when code is "smelling bad" and knowing how to fix it without breaking anything. Next: Refactoring.