Spring Framework Tutorial 0/14 lessons ~6 min read Lesson 14

    Testing Spring Applications

    Spring was designed with testability as a goal.

    Course progress0%
    Focus
    8 guided sections
    Practice signal
    Examples included
    Career prep
    Foundation builder

    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 @SpringBootTest or @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:

    java
    @WebMvcTest(OrderController.class)
    class OrderControllerTest {
    @Autowired MockMvc mvc;
    @MockBean OrderService orders;
    @Test
    void 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:

    java
    @DataJpaTest
    @Testcontainers
    @AutoConfigureTestDatabase(replace = Replace.NONE)
    class OrderRepositoryTest {
    @Container
    static PostgreSQLContainer<?> pg =
    new PostgreSQLContainer<>("postgres:16-alpine");
    @DynamicPropertySource
    static 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;
    @Test
    void 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.

    Ready to mark this lesson complete?Track your journey across the entire course.