Spring Boot Integration Test With @SpringBootTest and H2

Write Spring Boot integration tests that start the real app with @SpringBootTest, call the REST API over HTTP with RestTestClient and check an H2 database. Covers @Sql test data, @MockitoBean for external systems, MockMvc, TestRestTemplate in Spring Boot 4, Failsafe and context caching.

Spring boot integration test example

A Spring Boot integration test starts the real application context with @SpringBootTest and checks that the controller, the service, the repository and the database work together. A unit test checks one class with its dependencies replaced by mocks, whereas an integration test sends a request through all the layers and checks what comes back.

We write integration tests for the bugs that unit tests cannot see, such as a JSON field with the wrong name, a validation annotation that never runs, a broken SQL query or a missing transaction. For example, a table booking REST API can pass every unit test and still return HTTP 500 in production because the service calls an SMS provider that nobody configured.

The following example starts a Spring Boot 4.1.1 app on a random port with an H2 in-memory database, books a table through a real HTTP call with RestTestClient, and checks the response, the database row and the call to the SMS client.

@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@AutoConfigureRestTestClient
@Sql(statements = "DELETE FROM reservation")
class ReservationApiIT {

  @Autowired
  RestTestClient client;

  @Autowired
  ReservationRepository repository;

  @MockitoBean
  SmsClient smsClient;

  @Test
  void booksATable() {
    ReservationRequest request = new ReservationRequest("Lokesh", 4, LocalDate.of(2030, 5, 20));

    client.post().uri("/reservations")
        .contentType(MediaType.APPLICATION_JSON)
        .body(request)
        .exchange()
        .expectStatus().isCreated()                                   // 201
        .expectHeader().valueMatches("Location", "/reservations/\\d+")
        .expectBody()
        .jsonPath("$.guestName").isEqualTo("Lokesh")
        .jsonPath("$.partySize").isEqualTo(4);

    assertThat(repository.count()).isEqualTo(1);                      // row in H2
    verify(smsClient).send("Lokesh", "Table for 4 on 2030-05-20");    // mock called once
  }
}
[INFO] Running com.howtodoinjava.reservations.ReservationApiIT
... o.s.boot.tomcat.TomcatWebServer          : Tomcat started on port 41387 (http) with context path '/'
... c.h.reservations.ReservationApiIT        : Started ReservationApiIT in 1.558 seconds (process running for 17.418)
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 2.322 s -- in com.howtodoinjava.reservations.ReservationApiIT
[INFO] BUILD SUCCESS

Notice that only the SmsClient is a mock. The HTTP request, the JSON conversion, the bean validation, the transaction and the SQL all run as they do in production, and the Tomcat port changes on every run.

Next, we compare integration tests with unit and slice tests, set up the Maven dependencies for Spring Boot 4, and write tests for errors and test data. After that, we look at the MockMvc variant without a server, TestRestTemplate, running the tests with Failsafe and keeping the build fast with context caching.

1. Integration Tests vs Unit Tests and Slice Tests

Spring Boot offers several test types, and they differ in how much of the application they load. A unit test loads nothing from Spring. A slice test such as @WebMvcTest or @DataJpaTest loads one layer, and @SpringBootTest loads the whole application context, that is, every bean of the app.

The webEnvironment attribute of @SpringBootTest decides whether a real server starts.

  • MOCK (the default) creates the web context without a server, and tests send requests through MockMvc.
  • RANDOM_PORT starts the embedded Tomcat on a free port, and tests send real HTTP requests.
  • DEFINED_PORT starts Tomcat on server.port, 8080 by default, which fails when another process uses the port.
  • NONE creates a context without any web environment, for batch jobs and message listeners.
Comparison grid of five test types against six layers. A unit test has a real controller and a Mockito mock for the service. @WebMvcTest has MockMvc, a real controller and a @MockitoBean service. @DataJpaTest has a real repository and a real H2 database. @SpringBootTest with MOCK has MockMvc and real controller, service, repository and database. @SpringBootTest with RANDOM_PORT adds RestTestClient and a real Tomcat port. An arrow at the bottom goes from milliseconds without a Spring context to seconds with a full context and server.
The two @SpringBootTest columns are integration tests. A unit test or a slice test loads less and runs faster, but checks less.

Each kind of test catches a different class of bugs, so a typical project has many unit tests, some slice tests and a few integration tests for the main flows.

Test typeLoadsTypical speedFinds bugs in
Unit test with Mockitoone classmillisecondsbusiness logic
@WebMvcTestcontrollers, JSON, validationabout a secondrequest mapping, status codes
@DataJpaTestrepositories, JPA, databaseabout a secondqueries, mappings
@SpringBootTest (MOCK)whole context, no serversecondswiring, transactions, layers together
@SpringBootTest (RANDOM_PORT)whole context and Tomcatsecondsthe full HTTP path, filters, error responses

For the unit-test side of the same controller, see testing the controller, service and DAO layers.

2. Setting Up a Spring Boot 4 Integration Test

The example app takes table reservations for a restaurant. ReservationController exposes three endpoints, ReservationService saves the booking with ReservationRepository and sends a text message through SmsClient, and an H2 database holds the reservation table. The project uses Java 25 and Spring Boot 4.1.1.

2.1. Maven Dependencies

Spring Boot 4 splits the test support into modules, and each technology has its own test starter. The starter spring-boot-starter-webmvc-test brings JUnit, AssertJ, Mockito, MockMvc and RestTestClient. For TestRestTemplate, we also add spring-boot-starter-restclient-test.

<parent>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-parent</artifactId>
  <version>4.1.1</version>
  <relativePath/>
</parent>

<dependencies>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-webmvc</artifactId>
  </dependency>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
  </dependency>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
  </dependency>
  <dependency>
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
    <scope>runtime</scope>
  </dependency>

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

Without spring-boot-starter-restclient-test, a test with @AutoConfigureTestRestTemplate fails to start the context with NoClassDefFoundError: org/springframework/boot/restclient/RestTemplateBuilder. Projects that use only RestTestClient or MockMvc don’t need that starter.

2.2. The Controller Under Test

The controller validates the request body, returns 201 Created with a Location header for a new booking, and returns 404 Not Found for an unknown id. With spring.mvc.problemdetails.enabled=true in application.properties, Spring MVC returns validation errors as ProblemDetail JSON.

@RestController
@RequestMapping("/reservations")
public class ReservationController {

  private final ReservationService service;

  public ReservationController(ReservationService service) {
    this.service = service;
  }

  @PostMapping
  public ResponseEntity<Reservation> book(@Valid @RequestBody ReservationRequest request) {
    Reservation saved = service.book(request);
    return ResponseEntity.created(URI.create("/reservations/" + saved.getId())).body(saved);
  }

  @GetMapping("/{id}")
  public ResponseEntity<Reservation> find(@PathVariable long id) {
    return ResponseEntity.of(service.find(id));
  }

  @GetMapping
  public List<Reservation> forDate(@RequestParam LocalDate date) {
    return service.forDate(date);
  }
}

The request is a record with Jakarta Bean Validation constraints, so a party of 20 or a date in the past never reaches the service.

public record ReservationRequest(
    @NotBlank String guestName,
    @Min(1) @Max(12) int partySize,
    @NotNull @Future LocalDate date) {
}

3. Testing the API on a Random Port

With RANDOM_PORT, the test and the app run in the same JVM, but every request goes over a real HTTP connection to Tomcat. The annotation @AutoConfigureRestTestClient registers a RestTestClient bean that already knows the random port, so we write /reservations instead of a full URL. If a test needs the port number itself, e.g. for a WebSocket client, we inject it with @LocalServerPort.

Flow of an integration test with RANDOM_PORT. On the left, the test thread with ReservationApiIT sends HTTP requests through RestTestClient and runs @Sql scripts over JDBC. On the right, the application context has Tomcat on a random port, the controller, the service and the repository, which uses the H2 in-memory database. The service also calls SmsClient, which is replaced by a @MockitoBean. A note says that the server runs in its own thread, so @Transactional on the test cannot roll back its writes.
Everything inside the application context is real, except the SmsClient that would call an external provider.

3.1. Checking Error Responses

Integration tests are most useful on error paths, because the response depends on validation, exception handling and the JSON settings together. The next two tests send a party of 20 and ask for a reservation that does not exist.

@Test
void rejectsPartyLargerThanTwelve() {
  ReservationRequest request = new ReservationRequest("Lokesh", 20, LocalDate.of(2030, 5, 20));

  client.post().uri("/reservations")
      .contentType(MediaType.APPLICATION_JSON)
      .body(request)
      .exchange()
      .expectStatus().isBadRequest()                                        // 400
      .expectHeader().contentType(MediaType.APPLICATION_PROBLEM_JSON)
      .expectBody()
      .jsonPath("$.detail").isEqualTo("Invalid request content.");

  assertThat(repository.count()).isZero();                                  // nothing saved
}

@Test
void returns404ForUnknownReservation() {
  client.get().uri("/reservations/999")
      .exchange()
      .expectStatus().isNotFound();                                         // 404
}

With curl, the same request to the running app returns this response.

HTTP/1.1 400
Content-Type: application/problem+json

{"detail":"Invalid request content.","instance":"/reservations","status":400,"title":"Bad Request"}

3.2. Replacing Only External Systems With @MockitoBean

An integration test replaces only the parts that leave our process, such as an SMS gateway, a payment provider or another team’s REST API. Everything we own stays real, otherwise the test checks the mocks instead of the app.

The @MockitoBean annotation replaces the SmsClient bean in the application context with a Mockito mock, and Spring injects the mock into ReservationService. In Spring Boot 4, @MockitoBean from Spring Framework replaces the removed @MockBean. Without the mock, the real SmsClient throws an IllegalStateException because no provider is configured, and the same booking request returns 500 Internal Server Error.

@MockitoBean
SmsClient smsClient;

// in the test, after the POST request
verify(smsClient).send("Lokesh", "Table for 4 on 2030-05-20");   // the service sent the text

4. Test Data With @Sql

Integration tests share one database, so the data from one test can break another test. A typical case is a test that counts the bookings for one evening and fails only when it runs after a test that added a booking for the same evening.

The @Sql annotation runs SQL statements or scripts before each test method. On the class, DELETE FROM reservation empties the table before every test. On a method, @Sql(“/reservations.sql”) loads the rows that the test needs.

INSERT INTO reservation (id, guest_name, party_size, date) VALUES (101, 'Priya', 2, DATE '2030-05-20');
INSERT INTO reservation (id, guest_name, party_size, date) VALUES (102, 'Anna', 6, DATE '2030-05-20');
INSERT INTO reservation (id, guest_name, party_size, date) VALUES (103, 'Tom', 3, DATE '2030-05-21');
@Test
@Sql("/reservations.sql")
void listsReservationsForOneDate() {
  client.get().uri("/reservations?date=2030-05-20")
      .exchange()
      .expectStatus().isOk()
      .expectBody()
      .jsonPath("$.length()").isEqualTo(2)                    // Anna and Priya, not Tom
      .jsonPath("$[0].guestName").isEqualTo("Anna")
      .jsonPath("$[1].guestName").isEqualTo("Priya");
}

By default, a method-level @Sql replaces the class-level @Sql, so the DELETE does not run before this test. When booksATable() runs first, its booking for 2030-05-20 is still in the table, and the test fails.

[ERROR] com.howtodoinjava.reservations.ReservationApiIT.listsReservationsForOneDate -- Time elapsed: 0.242 s <<< FAILURE!
java.lang.AssertionError: JSON path "$.length()" expected:<2> but was:<3>

The annotation @SqlMergeMode(MergeMode.MERGE) on the class tells Spring to run the class-level statements first and the method-level script after them, so every test starts from an empty table.

@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@AutoConfigureRestTestClient
@Sql(statements = "DELETE FROM reservation")
@SqlMergeMode(MergeMode.MERGE)
class ReservationApiIT {
  // fields and tests as before
}

We can’t use @Transactional for cleanup in a RANDOM_PORT test. The HTTP request runs in a Tomcat thread with its own transaction, and that transaction commits before the response comes back, so the rollback at the end of the test does not undo it.

5. Integration Tests Without a Server Using MockMvc

With the default MOCK environment, Spring Boot loads the full context but starts no server. The annotation @AutoConfigureMockMvc sets up MockMvc, which calls the DispatcherServlet in the test thread. MockMvcTester wraps MockMvc with AssertJ assertions.

Because the request runs in the test thread, @Transactional on the test class works here. Spring rolls back every test at the end, so the booking from the first test is gone when the third test checks the table.

@SpringBootTest
@AutoConfigureMockMvc
@Transactional
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class ReservationMockMvcIT {

  @Autowired
  MockMvcTester mvc;

  @Autowired
  ReservationRepository repository;

  @MockitoBean
  SmsClient smsClient;

  @Test
  @Order(1)
  void booksATableWithoutServer() {
    assertThat(mvc.post().uri("/reservations")
        .contentType(MediaType.APPLICATION_JSON)
        .content("""
            {"guestName": "Lokesh", "partySize": 4, "date": "2030-05-20"}
            """))
        .hasStatus(HttpStatus.CREATED)
        .bodyJson().extractingPath("$.guestName").isEqualTo("Lokesh");

    assertThat(repository.count()).isEqualTo(1);    // rolled back after the test
  }

  @Test
  @Order(3)
  void bookingFromFirstTestWasRolledBack() {
    assertThat(repository.count()).isZero();        // 0
  }
}

A MOCK test is faster and gives us rollback, but it skips the servlet container. A RANDOM_PORT test also covers the things that only exist in a real server, such as servlet filters registered with the container, the error page handling of Tomcat and the HTTP connection itself.

6. Using TestRestTemplate

Older tests use TestRestTemplate, which is based on RestTemplate. In Spring Boot 4, it moved to the package org.springframework.boot.resttestclient, and the test class needs @AutoConfigureTestRestTemplate plus the spring-boot-starter-restclient-test dependency from section 2.1.

@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@AutoConfigureTestRestTemplate
class ReservationTestRestTemplateIT {

  @Autowired
  TestRestTemplate restTemplate;

  @Test
  void returns404ForUnknownReservation() {
    ResponseEntity<String> response = restTemplate.getForEntity("/reservations/999", String.class);

    assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);   // 404
  }
}

For new tests, we prefer RestTestClient, which the Spring Boot reference documentation uses for servers on a random port. Its chained expectStatus() and jsonPath() assertions replace the manual ResponseEntity checks. For more TestRestTemplate calls, see TestRestTemplate POST examples.

7. Running Integration Tests With Maven Failsafe

Integration tests take seconds, not milliseconds, so many teams keep them out of mvn test. The Maven Failsafe plugin runs the classes whose names end with IT in the integration-test phase, after the package phase. The Spring Boot parent already configures the Failsafe goals, so we only declare the plugin.

<build>
  <plugins>
    <plugin>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-maven-plugin</artifactId>
    </plugin>
    <plugin>
      <groupId>org.apache.maven.plugins</groupId>
      <artifactId>maven-failsafe-plugin</artifactId>
    </plugin>
  </plugins>
</build>

The command mvn test runs only the unit tests, and mvn verify also runs the three IT classes.

[INFO] --- failsafe:3.5.6:integration-test (default) @ spring-boot-integration-test ---
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 9.331 s -- in com.howtodoinjava.reservations.ReservationMockMvcIT
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 1.882 s -- in com.howtodoinjava.reservations.ReservationTestRestTemplateIT
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 1.375 s -- in com.howtodoinjava.reservations.ReservationApiIT
[INFO] Tests run: 8, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS

To run JUnit tests with Maven in general, see the JUnit 5 Maven dependency setup.

8. Keeping Integration Tests Fast With Context Caching

Starting the application context is the slow part of an integration test. The Spring TestContext framework keeps every context it creates in a static cache and reuses it for every test class with the same configuration in the same JVM. The cache holds up to 32 contexts by default.

The cache key includes the annotations, the active profiles, the properties and the @MockitoBean fields. For example, when a copy of ReservationApiIT with the same configuration runs first, the copy starts the context in 8.4 seconds. ReservationApiIT reuses that context, so its log has no “Started” line and its four tests take 0.146 seconds.

[INFO] Running com.howtodoinjava.reservations.ReservationApiCopyIT
... Started ReservationApiCopyIT in 8.415 seconds (process running for 10.339)
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 10.80 s -- in com.howtodoinjava.reservations.ReservationApiCopyIT
...
[INFO] Running com.howtodoinjava.reservations.ReservationApiIT
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.146 s -- in com.howtodoinjava.reservations.ReservationApiIT

So we keep the test configuration of the integration test classes the same where we can. Every different set of @MockitoBean fields, a different @ActiveProfiles value and every @DirtiesContext creates another context, and each one costs another startup.

9. Spring Boot Integration Testing FAQs

9.1. Should Integration Tests Use H2 or a Real Database?

H2 is fine when the app uses plain JPA and standard SQL, because the tests run without any setup. When the app uses database-specific features such as PostgreSQL JSON columns or MySQL-specific SQL, we test against the same database as production. Testcontainers starts that database in a Docker container for the test run, and Spring Boot’s @ServiceConnection connects the app to it. Testcontainers needs Docker on the developer machine and on the CI server.

9.2. What Is the Difference Between @SpringBootTest and @WebMvcTest?

The annotation @SpringBootTest loads every bean of the application, whereas @WebMvcTest loads only the web layer, such as controllers, JSON conversion and validation. In a @WebMvcTest, we replace the services with @MockitoBean, so it checks the request mapping but not the service logic or the database.

9.3. How Do I Use Different Properties in Integration Tests?

We put the test values in src/test/resources/application.properties, set them for one class with @SpringBootTest(properties = “…”), or activate a profile with @ActiveProfiles. Test-only beans go into a class with @TestConfiguration.

10. Conclusion

A Spring Boot integration test loads the real application context with @SpringBootTest and checks a request through all the layers. With RANDOM_PORT and RestTestClient, the test also covers the HTTP connection and the servlet container, and with the default MOCK environment and MockMvcTester, the test runs faster and can roll back its data with @Transactional.

Good integration tests replace only external systems with @MockitoBean, load their own data with @Sql, and clean up before every test. In Spring Boot 4, the test support comes from spring-boot-starter-webmvc-test, and TestRestTemplate needs the extra spring-boot-starter-restclient-test dependency. Failsafe keeps the slower tests in mvn verify, and a shared test configuration lets the context cache start the app only once.

11. References

Happy Learning !!

Source Code on Github

About Us

HowToDoInJava provides tutorials and how-to guides on Java and related technologies.

It also shares the best practices, algorithms & solutions and frequently asked interview questions.