Testing Spring Applications
Spring was designed with testability as a goal.
Introduction
Spring was designed with testability as a goal. Because beans are plain Java, most of your code can be tested with JUnit alone. When you do need the container, Spring offers a powerful test framework with slices that load only the part of the app you actually care about.
A well-tested Spring app has a pyramid: many fast unit tests, fewer slice tests, and a handful of full integration tests. Inverting that pyramid is the #1 reason CI pipelines slow to a crawl.
Understanding the topic
The testing pyramid in a Spring app:
- Unit tests — instantiate the class with mocks; no Spring at all. Milliseconds per test.
- Slice tests — load only one layer (
@WebMvcTest,@DataJpaTest,@JsonTest). Hundreds of ms per test. - Integration tests — full
@SpringBootTestor@ContextConfiguration; real database via Testcontainers. Seconds per test.
The rule: push as much logic as possible into plain classes; only reach for the container when you are testing wiring, web binding, or actual SQL behaviour.
Syntax reference
A controller slice test:
@WebMvcTest(OrderController.class)class OrderControllerTest {@Autowired MockMvc mvc;@MockBean OrderService orders;@Testvoid returns_404_for_unknown_order() throws Exception {when(orders.find(42L)).thenThrow(new OrderNotFoundException(42L));mvc.perform(get("/api/orders/42")).andExpect(status().isNotFound()).andExpect(jsonPath("$.code").value("ORDER_NOT_FOUND"));}}
Informative example
A repository slice test against a real database via Testcontainers:
@DataJpaTest@Testcontainers@AutoConfigureTestDatabase(replace = Replace.NONE)class OrderRepositoryTest {@Containerstatic PostgreSQLContainer<?> pg =new PostgreSQLContainer<>("postgres:16-alpine");@DynamicPropertySourcestatic void props(DynamicPropertyRegistry r) {r.add("spring.datasource.url", pg::getJdbcUrl);r.add("spring.datasource.username", pg::getUsername);r.add("spring.datasource.password", pg::getPassword);}@Autowired OrderRepository orders;@Testvoid finds_orders_by_customer() {orders.save(new Order(1L, 99L, new BigDecimal("49.00")));assertEquals(1, orders.findByCustomerId(99L).size());}}
Testcontainers gives you a throwaway real Postgres in CI, so your tests catch the bugs your in-memory H2 silently allowed.
Real-world use
Production-grade teams keep their @SpringBootTest count low (single digits or low tens) and lean on slice + unit tests for everything else. CI run time stays under a few minutes even for apps with thousands of tests.
Best practices
- Push as much logic as possible into plain classes — they don't need Spring to test.
- Reach for slice tests before full integration tests; they run 10× faster.
- Use Testcontainers instead of H2 for repository tests — H2 lies about Postgres/MySQL behaviour.
- Name tests after the behaviour they verify, not the method they call.
Common mistakes
- Loading the full context for tests that only need one bean — a slow suite is an ignored suite.
- Mocking the database in repository tests — you end up testing the mock, not the SQL.
- Relying on test order — Spring contexts are cached and reused; tests must be independent.
Hands-on exercise
Build it: take any service you wrote in earlier lessons and add three tests for it: (1) a pure unit test with hand-rolled fakes, (2) a @WebMvcTest slice for its controller, (3) a @DataJpaTest slice for its repository. Time each test and compare.