00:00

Spring Boot Testing – A Simple and Practical Guide

Tests are what let you change code with confidence. A good test suite tells you within seconds whether your last change broke something, long before a user notices. Spring Boot comes with everything you need to write tests at every level, from tiny unit tests to tests that start the whole application against a real database.

This guide walks through each kind of test with working examples from an online bookstore: plain unit tests, mocks, controller and repository tests, full integration tests, real databases with Testcontainers, test data and coverage.

1. Why Testing Matters

  • Catch bugs early, when they are cheapest to fix.
  • Change code safely: refactor knowing the tests will warn you about broken behavior.
  • Document behavior: a well-named test explains what the code is supposed to do.
  • Release with confidence, with automated checks on every build.

Without tests, every change has to be checked by hand, which becomes slower and less reliable as the application grows.

2. The Testing Pyramid

Different kinds of tests trade speed for realism. A healthy test suite has many fast tests and fewer slow ones:

The testing pyramid
            /\
           /  \        Few:  full integration tests (@SpringBootTest)
          /----\
         /      \      Some: slice tests (@WebMvcTest, @DataJpaTest)
        /--------\
       /          \    Many: unit tests (JUnit + Mockito, no Spring)
      /____________\
Test typeWhat it checksStarts Spring?Typical speed
Unit testOne class, with its dependencies replaced by mocksNoMilliseconds
Slice testOne layer, such as controllers or repositoriesPartlyAbout a second
Integration testSeveral layers working togetherFullySeveral seconds

Put most of your business logic under fast unit tests, use slice tests for the web and data layers, and keep a smaller number of integration tests for the most important end-to-end paths.

3. What spring-boot-starter-test Includes

Every Spring Boot project generated by Spring Initializr already contains the test starter:

pom.xml
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>
LibraryUsed for
JUnit Jupiter (JUnit 5 and later)Writing and running tests
AssertJReadable assertions, such as assertThat(total).isEqualTo(...)
MockitoCreating mock objects
Spring Test and Spring Boot TestLoading Spring contexts, MockMvc and test slices
JsonPath and JSONassertChecking JSON responses
HamcrestAn alternative style of assertions

One dependency, and every tool in this guide is ready to use. Spring Boot manages compatible versions of all of them.

4. Unit Testing with JUnit

A unit test checks one class on its own, without Spring. Start with plain business logic, such as this discount calculator:

DiscountCalculator.java
public class DiscountCalculator {

    public BigDecimal discountFor(BigDecimal orderTotal, boolean member) {
        if (orderTotal.signum() < 0) {
            throw new IllegalArgumentException("Order total cannot be negative");
        }
        BigDecimal rate = BigDecimal.ZERO;
        if (orderTotal.compareTo(new BigDecimal("100")) >= 0) {
            rate = new BigDecimal("0.10");                  // 10% from 100 upwards
        }
        if (member) {
            rate = rate.add(new BigDecimal("0.05"));        // members get 5% extra
        }
        return orderTotal.multiply(rate).setScale(2, RoundingMode.HALF_UP);
    }
}
DiscountCalculatorTest.java
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

import java.math.BigDecimal;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;

class DiscountCalculatorTest {

    private final DiscountCalculator calculator = new DiscountCalculator();

    @Test
    @DisplayName("Orders below 100 get no discount for non-members")
    void noDiscountForSmallOrders() {
        assertThat(calculator.discountFor(new BigDecimal("80"), false))
                .isEqualByComparingTo("0.00");
    }

    @ParameterizedTest(name = "total {0}, member {1} -> discount {2}")
    @CsvSource({
            "50,  true,  2.50",
            "100, false, 10.00",
            "200, true,  30.00"
    })
    void calculatesDiscounts(BigDecimal total, boolean member, BigDecimal expected) {
        assertThat(calculator.discountFor(total, member)).isEqualByComparingTo(expected);
    }

    @Test
    void rejectsNegativeTotals() {
        assertThatThrownBy(() -> calculator.discountFor(new BigDecimal("-1"), false))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessageContaining("negative");
    }
}
FeaturePurpose
@TestMarks a test method
@DisplayNameA readable name shown in reports
@ParameterizedTest + @CsvSourceRuns the same test with several sets of data
assertThat(...)AssertJ’s readable, chainable assertions
assertThatThrownBy(...)Checks that code throws the expected exception

Use isEqualByComparingTo for BigDecimal: 10.0 and 10.00 are numerically equal, but not equal according to equals().

5. Mocking Dependencies with Mockito

Most classes depend on others, such as repositories or remote clients. In a unit test, replace them with mocks: fake objects whose behavior you control:

OrderService.java
@Service
public class OrderService {

    private final OrderRepository orderRepository;
    private final InventoryClient inventoryClient;
    private final DiscountCalculator discountCalculator;

    public OrderService(OrderRepository orderRepository, InventoryClient inventoryClient,
                        DiscountCalculator discountCalculator) {
        this.orderRepository = orderRepository;
        this.inventoryClient = inventoryClient;
        this.discountCalculator = discountCalculator;
    }

    public Order placeOrder(String isbn, int quantity, BigDecimal price, boolean member) {
        if (!inventoryClient.isAvailable(isbn, quantity)) {
            throw new OutOfStockException(isbn);
        }
        BigDecimal total = price.multiply(BigDecimal.valueOf(quantity));
        BigDecimal discount = discountCalculator.discountFor(total, member);
        return orderRepository.save(new Order(isbn, quantity, total.subtract(discount)));
    }
}
OrderServiceTest.java
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {

    @Mock
    private OrderRepository orderRepository;

    @Mock
    private InventoryClient inventoryClient;

    @Spy
    private DiscountCalculator discountCalculator = new DiscountCalculator();   // real logic

    @InjectMocks
    private OrderService orderService;

    @Test
    void savesOrderWithDiscountedTotal() {
        when(inventoryClient.isAvailable("9780134685991", 2)).thenReturn(true);
        when(orderRepository.save(any(Order.class))).thenAnswer(call -> call.getArgument(0));

        Order order = orderService.placeOrder("9780134685991", 2, new BigDecimal("60"), false);

        assertThat(order.getTotal()).isEqualByComparingTo("108.00");   // 120 minus 10%

        ArgumentCaptor<Order> saved = ArgumentCaptor.forClass(Order.class);
        verify(orderRepository).save(saved.capture());
        assertThat(saved.getValue().getQuantity()).isEqualTo(2);
    }

    @Test
    void rejectsOrderWhenOutOfStock() {
        when(inventoryClient.isAvailable("9780134685991", 5)).thenReturn(false);

        assertThatThrownBy(() -> orderService.placeOrder("9780134685991", 5, new BigDecimal("60"), false))
                .isInstanceOf(OutOfStockException.class);

        verifyNoInteractions(orderRepository);   // nothing was saved
    }
}
Mockito featureMeaning
@MockA fake object; every method returns a default unless you say otherwise
@SpyA real object whose calls you can still verify or override
@InjectMocksCreates the class under test and passes the mocks into its constructor
when(...).thenReturn(...)Defines what a mock returns
verify(...)Checks that a method was called
ArgumentCaptorCaptures the argument passed to a mock, to inspect it

No Spring context is started, so these tests run in milliseconds. That is why business logic in services deserves mostly unit tests.

6. Writing Readable Tests

A test should read like a short story. The Given–When–Then structure makes that easy:

OrderServiceTest.java
@Test
void membersGetExtraDiscount() {
    // Given: a book in stock and a member customer
    when(inventoryClient.isAvailable("9780134685991", 1)).thenReturn(true);
    when(orderRepository.save(any(Order.class))).thenAnswer(call -> call.getArgument(0));

    // When: the member places an order
    Order order = orderService.placeOrder("9780134685991", 1, new BigDecimal("200"), true);

    // Then: they pay 15% less
    assertThat(order.getTotal()).isEqualByComparingTo("170.00");
}
  • Name tests after behavior, such as rejectsOrderWhenOutOfStock, not testPlaceOrder2.
  • Test one behavior per test, so a failure points to one clear problem.
  • Keep tests independent: no test should rely on another having run first.
  • Prefer clear values over clever setup; a reader should see the expected result at a glance.

7. Integration Testing with @SpringBootTest

An integration test starts the whole application context, so real beans work together, from controller to database:

OrderFlowIntegrationTest.java
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
class OrderFlowIntegrationTest {

    @Autowired
    private MockMvc mockMvc;

    @Autowired
    private OrderRepository orderRepository;

    @MockitoBean
    private InventoryClient inventoryClient;   // the only external system: mocked

    @Test
    void placingAnOrderStoresItInTheDatabase() throws Exception {
        when(inventoryClient.isAvailable("9780134685991", 1)).thenReturn(true);

        mockMvc.perform(post("/api/orders")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("""
                                {"isbn": "9780134685991", "quantity": 1, "price": 45.00, "member": false}
                                """))
                .andExpect(status().isCreated())
                .andExpect(jsonPath("$.total").value(45.00));

        assertThat(orderRepository.count()).isEqualTo(1);
    }
}

Here, the controller, validation, service, transaction and repository all run for real. Only the inventory system, which lives outside this application, is replaced with @MockitoBean, available from Spring Boot 3.4; older versions use @MockBean.

To test against a real HTTP server on a random port, use @SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT). For most cases, MockMvc is faster and just as useful.

8. Testing Controllers with @WebMvcTest

@WebMvcTest loads only the web layer: controllers, JSON conversion, validation and exception handlers. Services are mocked, so the test focuses on HTTP behavior:

OrderControllerTest.java
@WebMvcTest(OrderController.class)
class OrderControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @MockitoBean
    private OrderService orderService;

    @Test
    void returns201WithTheCreatedOrder() throws Exception {
        when(orderService.placeOrder("9780134685991", 2, new BigDecimal("60.00"), false))
                .thenReturn(new Order(1L, "9780134685991", 2, new BigDecimal("108.00")));

        mockMvc.perform(post("/api/orders")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("""
                                {"isbn": "9780134685991", "quantity": 2, "price": 60.00, "member": false}
                                """))
                .andExpect(status().isCreated())
                .andExpect(header().exists("Location"))
                .andExpect(jsonPath("$.id").value(1))
                .andExpect(jsonPath("$.total").value(108.00));
    }

    @Test
    void returns400ForInvalidQuantity() throws Exception {
        mockMvc.perform(post("/api/orders")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("""
                                {"isbn": "9780134685991", "quantity": 0, "price": 60.00}
                                """))
                .andExpect(status().isBadRequest());

        verifyNoInteractions(orderService);
    }
}
CheckMockMvc expression
Status codestatus().isOk(), .isCreated(), .isNotFound()
JSON fieldjsonPath("$.total").value(108.00)
Array sizejsonPath("$.items.length()").value(3)
Headerheader().string("Location", ...)
Print everything (debugging).andDo(print())

For full examples with validation and error responses, see the Spring Boot REST API and Exception Handling tutorials.

9. Testing Repositories with @DataJpaTest

@DataJpaTest loads only the JPA layer: entities, repositories and an embedded database. Each test runs in a transaction that is rolled back afterwards, so tests never affect each other:

OrderRepository.java
public interface OrderRepository extends JpaRepository<Order, Long> {

    @Query("SELECT o FROM Order o WHERE o.total >= :minimum ORDER BY o.total DESC")
    List<Order> findLargeOrders(@Param("minimum") BigDecimal minimum);
}
OrderRepositoryTest.java
@DataJpaTest
class OrderRepositoryTest {

    @Autowired
    private TestEntityManager entityManager;

    @Autowired
    private OrderRepository orderRepository;

    @Test
    void findsLargeOrdersSortedByTotal() {
        entityManager.persist(new Order("ISBN-1", 1, new BigDecimal("50.00")));
        entityManager.persist(new Order("ISBN-2", 3, new BigDecimal("300.00")));
        entityManager.persist(new Order("ISBN-3", 2, new BigDecimal("150.00")));
        entityManager.flush();

        List<Order> large = orderRepository.findLargeOrders(new BigDecimal("100"));

        assertThat(large)
                .extracting(Order::getIsbn)
                .containsExactly("ISBN-2", "ISBN-3");
    }
}

TestEntityManager inserts test data directly, so the test checks only the repository method itself. Test your own queries this way; there is no need to test methods Spring Data generates, such as findById.

10. Other Test Slices

AnnotationLoadsUse it to test
@WebMvcTestSpring MVC controllers and web componentsREST endpoints
@DataJpaTestJPA, repositories, an embedded databaseEntities and queries
@JsonTestJackson and JSON supportHow objects are written to and read from JSON
@RestClientTestRestClient / RestTemplate with a mock serverClients that call other APIs
@JdbcTestJDBC and an embedded databasePlain SQL code
@WebFluxTestSpring WebFlux controllersReactive endpoints

Here is a client test with @RestClientTest, which fakes the remote server so no real network call is made:

InventoryClientTest.java
@RestClientTest(InventoryClient.class)
class InventoryClientTest {

    @Autowired
    private InventoryClient inventoryClient;

    @Autowired
    private MockRestServiceServer server;

    @Test
    void reportsAvailableStock() {
        server.expect(requestTo("/api/stock/9780134685991?quantity=2"))
              .andRespond(withSuccess("""
                      {"available": true}
                      """, MediaType.APPLICATION_JSON));

        assertThat(inventoryClient.isAvailable("9780134685991", 2)).isTrue();
    }
}

In Spring Boot 4, the packages of these slice annotations changed as the test support was split into modules. Let your IDE add the imports for your version.

11. Testing with Real Databases (Testcontainers)

Embedded databases like H2 are fast, but they are not your production database: some SQL that works in H2 fails in PostgreSQL, and the other way round. Testcontainers starts a real database in Docker for your tests:

pom.xml
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-testcontainers</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>postgresql</artifactId>
    <scope>test</scope>
</dependency>
OrderRepositoryPostgresTest.java
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Testcontainers
class OrderRepositoryPostgresTest {

    @Container
    @ServiceConnection
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16");

    @Autowired
    private OrderRepository orderRepository;

    @Test
    void savesAndReadsOrdersOnRealPostgres() {
        Order saved = orderRepository.save(new Order("ISBN-1", 1, new BigDecimal("25.00")));

        assertThat(orderRepository.findById(saved.getId())).isPresent();
    }
}

@ServiceConnection (Spring Boot 3.1+) connects the application to the container automatically, and replace = NONE stops @DataJpaTest from swapping in an embedded database. With Flyway, your real migrations run against the container too; see Database Migration with Flyway.

12. Test Configuration

Give tests their own settings with a test profile:

src/test/resources/application-test.properties
spring.jpa.show-sql=false
logging.level.com.javabytechie=DEBUG
shop.inventory.base-url=http://localhost:9999

Activate it with @ActiveProfiles("test"). For a single value in one test class, use @TestPropertySource. For values only known at runtime, such as a port chosen by a container, use @DynamicPropertySource:

Test configuration options
@SpringBootTest
@TestPropertySource(properties = "shop.discounts.enabled=false")
class CheckoutWithoutDiscountsTest { ... }

@SpringBootTest
class InventoryIntegrationTest {

    static WireMockServer inventoryServer = new WireMockServer(0);   // random port

    @DynamicPropertySource
    static void inventoryUrl(DynamicPropertyRegistry registry) {
        inventoryServer.start();
        registry.add("shop.inventory.base-url", inventoryServer::baseUrl);
    }
}

To add or replace beans only for tests, use a nested @TestConfiguration class, for example to provide a fixed Clock:

ReportServiceTest.java
@SpringBootTest
class ReportServiceTest {

    @TestConfiguration
    static class FixedClockConfig {
        @Bean
        @Primary
        Clock testClock() {
            return Clock.fixed(Instant.parse("2026-10-04T10:00:00Z"), ZoneOffset.UTC);
        }
    }

    // ReportService now always sees 4 October 2026 as "today"
}

13. Test Data and Isolation

Tests must not depend on each other’s data. Choose one of these approaches:

ApproachHowNotes
Roll back automatically@Transactional on the test class, or @DataJpaTestSimple; but code that runs in other threads, or REQUIRES_NEW, is not rolled back
Clean up before each testrepository.deleteAll() in @BeforeEachWorks with any transaction setup
Load data from SQL@Sql("/test-data/orders.sql")Good for larger, shared datasets
OrderReportTest.java
@SpringBootTest
@Sql(scripts = "/test-data/orders.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD)
@Sql(scripts = "/test-data/cleanup.sql", executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD)
class OrderReportTest {

    @Autowired
    private OrderReportService reportService;

    @Test
    void countsTodaysOrders() {
        assertThat(reportService.ordersToday()).isEqualTo(3);
    }
}

When you test transaction behavior itself, such as a rollback after an error, do not put @Transactional on the test; see the example in Transaction Management.

14. Measuring Test Coverage

Code coverage shows which lines your tests execute. JaCoCo is the common open-source tool for Java:

pom.xml
<plugin>
    <groupId>org.jacoco</groupId>
    <artifactId>jacoco-maven-plugin</artifactId>
    <version>0.8.12</version>
    <executions>
        <execution>
            <goals>
                <goal>prepare-agent</goal>
            </goals>
        </execution>
        <execution>
            <id>report</id>
            <phase>test</phase>
            <goals>
                <goal>report</goal>
            </goals>
        </execution>
    </executions>
</plugin>
Terminal
./mvnw test
# open target/site/jacoco/index.html in a browser

The report highlights untested lines and branches in red. Use it to find important code without tests, not to chase a number: 100% coverage with weak assertions proves little, while 75% coverage of the business logic with strong assertions is very valuable.

15. Common Testing Annotations

AnnotationPurpose
@TestMarks a test method (JUnit)
@BeforeEach / @AfterEachRuns code before or after every test
@ParameterizedTestRuns one test with several inputs
@ExtendWith(MockitoExtension.class)Enables @Mock and @InjectMocks without Spring
@SpringBootTestLoads the full application context
@WebMvcTestLoads only the web layer
@DataJpaTestLoads only the JPA layer, with rollback
@AutoConfigureMockMvcAdds MockMvc to a @SpringBootTest
@MockitoBeanReplaces a Spring bean with a Mockito mock (Spring Boot 3.4+; was @MockBean)
@MockitoSpyBeanWraps a real Spring bean in a Mockito spy (was @SpyBean)
@ActiveProfilesActivates profiles for the test
@TestConfigurationAdds beans only for tests
@SqlRuns SQL scripts before or after tests

16. Best Practices

  • Test behavior, not implementation: check results and side effects, not which private method was called.
  • Follow the pyramid: many fast unit tests, fewer slice tests, few full integration tests.
  • Use the smallest context that works: plain JUnit before @WebMvcTest, and @WebMvcTest before @SpringBootTest.
  • Test failure paths, such as invalid input and exceptions, not only the happy path.
  • Mock only what you do not own or what is slow, such as external services; use real objects for simple classes.
  • Use the production database type for repository and integration tests, with Testcontainers.
  • Keep tests fast and independent, so they are run often.
  • Run all tests on every build, for example in your CI pipeline.

17. Common Mistakes to Avoid

MistakeWhat goes wrongFix
Using @SpringBootTest for everythingSlow test suites that developers stop runningUse unit tests and slices where possible
Tests without assertionsThey pass even when the code is wrongAssert on results and important side effects
Mocking everythingTests pass while the real objects would fail togetherMock boundaries; use real objects inside them
Tests depending on order or shared dataRandom failuresMake each test set up its own data
Using Thread.sleep to waitSlow and flaky testsWait for a condition, for example with Awaitility
Comparing BigDecimal with isEqualTo10.0 and 10.00 are reported as differentUse isEqualByComparingTo
Still using @MockBeanDeprecation warnings; removed in Spring Boot 4Switch to @MockitoBean

18. Summary

Spring Boot gives you a complete testing toolbox in one starter. Write fast unit tests with JUnit and Mockito for business logic, use @WebMvcTest and @DataJpaTest to test the web and data layers in isolation, and keep a smaller set of @SpringBootTest integration tests for complete flows. Test against your real database with Testcontainers, give tests their own configuration and data, and use coverage reports to find gaps, not to chase numbers. A test suite built this way runs quickly, catches real bugs and lets you change your code with confidence.