Start Learning
Javaneer
Back to stage
Module 9Β·Capstone: Build BookVault

Building the Test Pyramid

Make it trustworthy: fast unit tests, focused slice tests for web and data, and full integration tests - the pyramid that lets you change BookVault fearlessly.

16 min readAdvanced
On this page

A service you can't change with confidence is a liability. Tests are what let you refactor, add features, and upgrade dependencies without fear. This lesson builds BookVault's test pyramid - many fast tests at the base, fewer slow ones on top - so every layer is covered at the right level.

The pyramid, bottom to top

             β•±β•²          few   full integration (@SpringBootTest, Testcontainers)
            β•±  β•²               - whole app, real DB; slowest, highest confidence
           ╱────╲        some  slice tests (@WebMvcTest, @DataJpaTest)
          β•±      β•²             - one layer with real Spring wiring
         ╱────────╲     many   unit tests (plain JUnit + Mockito)
        β•±__________β•²           - pure logic, no Spring; milliseconds

The shape matters: fast unit tests catch most bugs cheaply and run constantly; a few slow integration tests confirm the whole thing works. Invert it - mostly slow end-to-end tests - and your suite becomes too slow to run and too brittle to trust.

Unit tests: pure logic, no Spring

The borrow rules are pure logic, so test them with mocked repositories - no container, no database, milliseconds per test:

@Test
void borrowReservesACopyAndRecordsALoan() {
    when(books.findByIsbn("978-0134685991")).thenReturn(Optional.of(book));   // 2 copies
    when(loans.save(any(Loan.class))).thenAnswer(inv -> inv.getArgument(0));

    Loan loan = service.borrow("978-0134685991", 1L);

    assertThat(book.getCopiesAvailable()).isEqualTo(1);   // reserved one
    verify(loans).save(any(Loan.class));                  // recorded the loan
}

Slice tests: one layer, really wired

Some things only work with Spring's machinery - request mapping, JSON, JPA queries. Slice tests load just that slice:

  • @WebMvcTest boots the web layer (controllers, JSON, validation) with the service mocked - so you test that POST returns 201 and bad input returns a 400 ProblemDetail, without a database.
  • @DataJpaTest boots JPA and repositories against an in-memory database - so you test that findByIsbn and your JOIN FETCH queries actually return what you expect.

Integration tests: the whole thing

At the top, @SpringBootTest loads the entire application. BookVault's security integration test performs a real JWT login and checks the role rules end to end; a Testcontainers test runs the full stack against a real PostgreSQL in a container, catching anything H2 would hide:

@SpringBootTest
@Testcontainers
class LendingPostgresIntegrationTest {
    @Container
    static PostgreSQLContainer<?> db = new PostgreSQLContainer<>("postgres:16");
    // ... exercises borrow/return against real Postgres
}

Match the test to the risk

Test each concern at the cheapest level that's honest. Business rules? Unit tests. JSON mapping and status codes? A web slice. Query correctness? A data slice. 'Does the whole secured flow work against a real database?' Integration. Writing a slow @SpringBootTest for logic a unit test could cover just makes your suite slow for no extra confidence.

Tests that need Docker should skip gracefully

BookVault's Testcontainers test is annotated to skip when Docker isn't available, so ./mvnw test stays green on any machine and the PostgreSQL test runs in CI (or locally with Docker) via ./mvnw verify. A test suite that fails merely because an optional dependency is absent trains people to ignore red - keep the default run always-green.

Place three tests on the pyramid

For each, name the right test type and why: (a) 'borrowing the last copy leaves zero and a second borrow is rejected'; (b) 'POST /api/books with a blank title returns 400 with a ProblemDetail'; (c) 'a LIBRARIAN can log in and delete a book, a MEMBER cannot'. Order them from fastest to slowest.

What is the test pyramid, as BookVault applies it?

Key takeaways

  • A test pyramid has many fast unit tests, some slice tests, and few full integration tests - inverting it makes the suite slow and brittle.
  • Unit tests cover pure logic (borrow rules) with mocked repositories - no Spring, milliseconds each.
  • @WebMvcTest boots the web slice (mapping, JSON, validation) with the service mocked; @DataJpaTest boots JPA and repositories against an in-memory DB.
  • @SpringBootTest loads the whole app for end-to-end checks - a real JWT login and role rules, or the full stack on real PostgreSQL via Testcontainers.
  • Test each concern at the cheapest honest level, and make Docker-dependent tests skip gracefully so the default run stays green.
  • A good pyramid is what lets you change BookVault fearlessly.
Was this lesson helpful?
Edit this page on GitHub