Testing Spring Boot Applications

Testing in Spring Boot

18 min Lesson 1 of 13

Testing in Spring Boot

Writing code that works once is easy. Writing code that keeps working — through refactors, dependency upgrades, and team changes — is the real engineering challenge. Automated testing is the discipline that makes that possible, and Spring Boot ships with a first-class testing stack out of the box. This lesson frames the problem, introduces the foundational concepts, and walks through every component that spring-boot-starter-test pulls onto your classpath so you know what you are working with before you write a single @Test.

Why Testing Matters More in a Spring Application

A Spring Boot service is a composition of many collaborating beans — controllers, services, repositories, event listeners, scheduled jobs — wired together by the IoC container. Any one of those beans can break silently if a dependency contract changes. Automated tests catch those breaks in seconds, not during a production incident at 2 AM.

There is also a subtler benefit: testable code is almost always better-designed code. A service that is hard to test in isolation is a signal that it has too many responsibilities or is too tightly coupled. The act of writing tests therefore pushes your design toward smaller, cleaner, more composable components.

The Testing Pyramid

The testing pyramid is a mental model introduced by Mike Cohn that describes the ideal distribution of tests across three layers:

  • Unit tests (base — wide): Test a single class or method in complete isolation. All collaborators are replaced by test doubles (mocks or stubs). These tests are fast (sub-millisecond each), numerous, and form the backbone of your suite.
  • Integration tests (middle — narrower): Test two or more real components working together — for example, a service interacting with a real (or in-memory) database, or a controller wired to a real service layer. They are slower and fewer.
  • End-to-end tests (top — narrow): Test the whole application from the outside — an HTTP request hits the server and exercises every layer down to the database. These are the slowest, most brittle, and should be the fewest.
The pyramid is a guide, not a law. A typical Spring Boot project might have 200 unit tests, 40 slice/integration tests, and 10 end-to-end smoke tests. If you invert this ratio — many slow end-to-end tests and few unit tests — your feedback loop collapses and developers stop running the suite locally.

Spring Boot's testing tools are designed so that you can write tests at every level of the pyramid without boilerplate. The framework handles starting and wiring the context for you.

What spring-boot-starter-test Provides

Add the test starter to your pom.xml with test scope:

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency>

This single dependency transitively pulls in a carefully curated set of libraries. Understanding each one tells you which tool to reach for in a given testing scenario.

JUnit 5 (Jupiter)

JUnit 5 is the test runner — the engine that discovers and executes your test methods. It replaced JUnit 4 as the Spring Boot default starting with Spring Boot 2.2. Key annotations you will use constantly:

  • @Test — marks a method as a test case.
  • @BeforeEach / @AfterEach — setup and teardown around every test method.
  • @BeforeAll / @AfterAll — static methods that run once per test class (expensive setup, like starting a container).
  • @DisplayName — human-readable name shown in IDE and CI reports.
  • @Nested — groups related tests inside an inner class, improving readability.
  • @ParameterizedTest with @ValueSource / @CsvSource / @MethodSource — run the same test with multiple inputs without copy-pasting.
import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.*; @DisplayName("PriceCalculator unit tests") class PriceCalculatorTest { private final PriceCalculator calc = new PriceCalculator(); @Test @DisplayName("applies 10% discount when order exceeds threshold") void appliesDiscount() { double result = calc.calculate(200.0, 0.10); assertEquals(180.0, result, 0.001, "Expected 10% off 200 to equal 180"); } @Test @DisplayName("throws when discount rate is negative") void rejectsNegativeDiscount() { assertThrows(IllegalArgumentException.class, () -> calc.calculate(100.0, -0.05)); } }

AssertJ — Fluent Assertions

JUnit 5 ships with basic Assertions methods, but AssertJ is included in the starter and is almost universally preferred because its fluent API produces far clearer failure messages.

import static org.assertj.core.api.Assertions.*; // Instead of: assertEquals(3, list.size()); assertTrue(list.contains("Alice")); // AssertJ: assertThat(list) .hasSize(3) .contains("Alice") .doesNotContain("Bob");

When a test fails, AssertJ prints exactly what it found versus what it expected, including the full list contents — not just a generic "expected true but was false".

Mockito — Mocking Collaborators

Mockito lets you replace real dependencies with controlled fakes at the unit-test level. You will use three patterns repeatedly:

  • mock(SomeClass.class) — creates a mock that returns defaults (null, 0, empty collections) unless you stub it.
  • when(...).thenReturn(...) — stubs a method to return a specific value.
  • verify(...) — asserts that a method was called with the expected arguments.
import org.junit.jupiter.api.Test; import static org.mockito.Mockito.*; import static org.assertj.core.api.Assertions.*; class OrderServiceTest { private final OrderRepository repo = mock(OrderRepository.class); private final OrderService service = new OrderService(repo); @Test void findsOrderById() { Order expected = new Order(42L, "PENDING"); when(repo.findById(42L)).thenReturn(Optional.of(expected)); Order actual = service.getOrder(42L); assertThat(actual.getStatus()).isEqualTo("PENDING"); verify(repo).findById(42L); // assert the repository was called } }
Prefer constructor injection in your beans. A service that receives its dependencies through the constructor can be instantiated in a plain JUnit test with no Spring context at all — just new OrderService(mockRepo). This is the fastest possible test and the one you should reach for first.

Hamcrest — Matcher Library

Hamcrest matchers are also on the classpath (they predate AssertJ). You may encounter them in older code or in Spring MVC test result assertions (MockMvcResultMatchers uses Hamcrest by default). Both AssertJ and Hamcrest coexist without conflict.

JSONassert and JsonPath

When your tests need to assert the structure of JSON responses from REST endpoints, the starter includes two helpers:

  • JSONassert — compares JSON strings structurally, ignoring formatting and field order.
  • JsonPath — lets you extract values from a JSON document using path expressions (e.g., $.orders[0].id). Spring's MockMvc integrates directly with JsonPath.

spring-test — The Spring Integration Layer

The spring-test module (part of the core Spring Framework) provides the infrastructure that integrates JUnit 5 with the IoC container:

  • @ExtendWith(SpringExtension.class) — JUnit 5 extension that starts a Spring context for annotated test classes (automatically included by @SpringBootTest and the slice annotations).
  • @ContextConfiguration — low-level annotation to specify which configuration classes to load.
  • MockMvc — a test-only HTTP layer that sends requests to your controllers without starting an actual servlet container.
  • TestRestTemplate — a wrapper around RestTemplate for full-stack HTTP tests that DO start a server.
  • @TestPropertySource / @DynamicPropertySource — override configuration properties for a specific test class.

The Slice Annotations — Spring Boot Test Speciality

Spring Boot adds a layer on top of spring-test: the slice annotations. Each slice starts only the subset of the application context relevant to one layer, making tests faster and more focused:

  • @WebMvcTest — loads only the web layer (controllers, filters, converters). No service or repository beans.
  • @DataJpaTest — loads only JPA repositories and an embedded database. No controllers.
  • @JsonTest — loads only JSON serialization/deserialization components.
  • @SpringBootTest — loads the full application context. Use for true integration tests.
Avoid reaching for @SpringBootTest by reflex. Starting the full context takes several seconds and loads every bean, datasource, and external integration. For a class with 30 test methods that only need a controller, that overhead multiplies painfully. Use the narrowest slice that covers what you need to test.

Summary

The testing pyramid gives you a strategy: many fast unit tests, fewer integration tests, and a small number of end-to-end tests. The spring-boot-starter-test dependency brings JUnit 5 as the runner, AssertJ for fluent assertions, Mockito for mocks, and Spring's own slice annotations so you can load just the right amount of context for each test. In the next lesson you will put JUnit 5 and Mockito to work writing unit tests for a service class — no Spring context required.