Spring Boot Integration Testing with @SpringBootTest and H2

Test a Spring Boot 4.1.2 REST API with @SpringBootTest and H2. Compare real HTTP and MockMvc tests, seed data with @Sql, and mock only the notification boundary.

Spring boot integration test example

An integration test checks that several application components work together. In a Spring Boot REST API, that can include the HTTP controller, a Spring Data repository, JPA mapping and a database. @SpringBootTest loads the application context so the test can exercise that path rather than replacing every collaborator with a mock.

This tutorial uses Spring Boot 4.1.2, JUnit and an in-memory H2 database to test an order API. One test sends a request to a real HTTP server. Another uses MockMvc with the same application components but without opening a server port. The difference matters when deciding what a passing test establishes.

1. Choose the Test Boundary

@SpringBootTest loads the application context. Its default MOCK web environment does not start an HTTP server. Set RANDOM_PORT when the request must cross a real network boundary. If the test only needs Spring MVC request handling, combine the default environment with @AutoConfigureMockMvc.

Test Loaded components HTTP server Typical question
@WebMvcTest(OrderController.class) MVC slice; no JPA repository No Does the controller map the request and response correctly?
@SpringBootTest with @AutoConfigureMockMvc Full context, including JPA and H2 No Does the MVC path work with the real repository?
@SpringBootTest(webEnvironment = RANDOM_PORT) Full context, including JPA and H2 Yes Does an HTTP client reach the complete application?

The annotations for the real-server option are short, but the test still needs assertions against a meaningful result:

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Sql("/orders.sql")
class OrderHttpIntegrationTest {
  @LocalServerPort int port;
  @Autowired OrderRepository orders;

  private RestClient client() {
    return RestClient.create("http://localhost:" + port);
  }
}

The Spring Boot testing reference defines these web environments. @WebMvcTest is useful for controller-focused tests, but it does not load the data layer. Use a full context when the question includes persistence.

2. Create the Order API and Test Database

The example application accepts a new order and returns an existing order by ID. A CustomerOrder has an ID, item and status. OrderRepository extends JpaRepository, so Spring Data provides its query and save operations. The controller receives that repository through its constructor.

@Entity
public class CustomerOrder {
  @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
  private Long id;
  private String item;
  private String status;

  protected CustomerOrder() {}
  public CustomerOrder(String item, String status) {
    this.item = item;
    this.status = status;
  }
  public Long getId() { return id; }
  public String getItem() { return item; }
  public String getStatus() { return status; }
}

public interface OrderRepository extends JpaRepository<CustomerOrder, Long> {}

The GET method returns 200 with the order when findById() finds one, or 404 when it returns Optional.empty(). The POST method saves a new order, notifies an OrderNotifier and returns 201 with a Location header. OrderNotifier is an interface with an orderCreated(Long) method. The project includes a LoggingOrderNotifier implementation, which records a local log message. A later test replaces that bean to show how a notification boundary can be excluded without mocking persistence.

@RestController
@RequestMapping("/orders")
public class OrderController {
  private final OrderRepository orders;
  private final OrderNotifier notifier;

  public OrderController(OrderRepository orders, OrderNotifier notifier) {
    this.orders = orders;
    this.notifier = notifier;
  }

  @GetMapping("/{id}")
  public ResponseEntity<CustomerOrder> find(@PathVariable Long id) {
    return orders.findById(id).map(ResponseEntity::ok)
        .orElseGet(() -> ResponseEntity.notFound().build());
  }

  @PostMapping
  public ResponseEntity<CustomerOrder> create(@RequestBody NewOrder request) {
    CustomerOrder saved = orders.save(new CustomerOrder(request.item(), "NEW"));
    notifier.orderCreated(saved.getId());
    return ResponseEntity.created(URI.create("/orders/" + saved.getId()))
        .body(saved);
  }

  public record NewOrder(String item) {}
}

The Maven project uses the Web MVC and Data JPA starters for the application. Its test dependencies provide Boot 4 test support and H2. H2 has test scope, so this example does not silently choose it as the production database.

<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-webmvc-test</artifactId><scope>test</scope></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-jpa-test</artifactId><scope>test</scope></dependency>
<dependency><groupId>com.h2database</groupId><artifactId>h2</artifactId><scope>test</scope></dependency>

The test’s application.properties asks Hibernate to create and drop the schema for the test context. A unique H2 database name prevents separate test contexts from sharing one in-memory database accidentally. This is appropriate for the small example; an application with managed schema migrations should test those migrations explicitly.

spring.jpa.hibernate.ddl-auto=create-drop
spring.datasource.generate-unique-name=true

3. Test a Real HTTP Request and an H2 Query

@Sql(“/orders.sql”) runs before each test method and resets the order table to one known row. The script gives order 17 the item Notebook and status PACKED, so both the HTTP response and repository lookup have a definite expected value.

DELETE FROM customer_order;
INSERT INTO customer_order (id, item, status)
VALUES (17, 'Notebook', 'PACKED');

RestClient sends a GET request to the server port assigned by @LocalServerPort. The response passes through Tomcat, Spring MVC, the controller, Spring Data JPA and H2. Checking the repository afterward confirms the seeded row exists in the same database used by the application.

@Test
void readsSeededOrderThroughHttpAndJpa() {
  CustomerOrder response = client().get().uri("/orders/17")
      .retrieve().body(CustomerOrder.class);

  assertThat(response.getItem()).isEqualTo("Notebook");
  assertThat(response.getStatus()).isEqualTo("PACKED");
  assertThat(orders.findById(17L)).isPresent();
}

The next test changes only the ID. RestClient.retrieve() normally throws on a 4xx response, so this test uses exchange() to inspect the 404 status directly. It verifies the controller’s missing-order branch while the rest of the stack remains real.

@Test
void returns404ForUnknownOrder() {
  var status = client().get().uri("/orders/999")
      .exchange((request, response) -> response.getStatusCode());
  assertThat(status).isEqualTo(HttpStatus.NOT_FOUND);
}

Both tests pass against the H2 test database. That proves the application’s mapping and JPA operations work with H2. It does not prove that the same SQL or schema behaves identically on PostgreSQL, MySQL or another production database.

4. Test a POST and Its Database Effect

A POST test needs more than a 201 assertion. It should check that the returned ID was persisted and that the Location header points to that order. The database query after the request observes the server’s committed write.

@Test
void postPersistsOrderInServerTransaction() {
  ResponseEntity<CustomerOrder> response = client().post().uri("/orders")
      .body(new OrderController.NewOrder("Pencil"))
      .retrieve().toEntity(CustomerOrder.class);

  assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED);
  Long id = response.getBody().getId();
  assertThat(orders.findById(id)).get().extracting(CustomerOrder::getItem)
      .isEqualTo("Pencil");
  assertThat(response.getHeaders().getLocation().toString())
      .isEqualTo("/orders/" + id);
}

In a RANDOM_PORT test, the client and server run in different threads and transactions. Adding @Transactional to the test method would roll back the test’s transaction, but it would not roll back a write committed by the server request. The @Sql reset before each test method keeps this example’s starting data predictable. The Spring Framework @Sql reference explains when and how test scripts run.

5. Run the Full Context Without an HTTP Server

If the test needs the real repository and MVC request handling but not a listening port, use @SpringBootTest with @AutoConfigureMockMvc. The test still loads JPA and H2; MockMvc sends a request through the MVC infrastructure in memory. That is a narrower runtime boundary than the real HTTP test in section 3.

@SpringBootTest
@AutoConfigureMockMvc
@Sql("/orders.sql")
class OrderMockMvcIntegrationTest {
  @Autowired MockMvc mvc;

  @Test
  void readsDatabaseWithoutStartingServer() throws Exception {
    mvc.perform(get("/orders/17"))
        .andExpect(status().isOk())
        .andExpect(jsonPath("$.status").value("PACKED"));
  }
}

This test confirms that the seeded database row reaches the controller response without a network request. A @WebMvcTest would not make the same assertion against OrderRepository unless the test deliberately imported or replaced the missing data components. See the MVC slice testing example for a controller-focused alternative.

6. Mock Only the External Boundary

Sometimes an integration test needs real database writes but should avoid an external notification call. The example’s LoggingOrderNotifier is a local stand-in for such a boundary. @MockitoBean(enforceOverride = true) replaces its existing bean in the full context, while OrderRepository remains real.

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Sql("/orders.sql")
class OrderNotifierIntegrationTest {
  @LocalServerPort int port;
  @Autowired OrderRepository orders;
  @MockitoBean(enforceOverride = true) OrderNotifier notifier;

  @Test
  void keepsRealPersistenceAndMocksNotification() {
    var response = RestClient.create("http://localhost:" + port)
        .post().uri("/orders")
        .body(new OrderController.NewOrder("Marker"))
        .retrieve().toEntity(CustomerOrder.class);

    Long id = response.getBody().getId();
    assertThat(orders.findById(id)).isPresent();
    verify(notifier).orderCreated(id);
  }
}

The POST reaches a running server and saves Marker in H2. The verification shows that the controller called the replacement notifier with the saved ID. It does not prove an email, message queue or other real notification system works. enforceOverride=true makes context setup fail if no notifier bean exists to replace.

7. Decide Whether H2 Is Enough

H2 is quick and useful for testing JPA mapping, repository calls and controller behavior in this example. Its SQL dialect and database behavior can differ from the production database. If a query uses vendor-specific SQL, migrations or constraints, run an additional test against the same database engine used in production, often through Testcontainers.

cd spring-boot-integration-test
mvn test

The project runs five tests: three through a real HTTP server, one through full-context MockMvc, and one through a real server with a mocked notifier. A successful run proves these paths against the versions and H2 database in the project. It is not a substitute for testing the deployed environment.

8. Frequently Asked Questions

8.1. Does @SpringBootTest Always Start a Server?

No. Its default web environment is MOCK, which loads a mock web environment without a listening server. Use RANDOM_PORT when a real HTTP client and server exchange is part of the test.

8.2. Why Did the Test Not Roll Back a POST Made Over HTTP?

The client test method and server request use separate transactions in a RANDOM_PORT test. A rollback on the test method does not undo a write the server committed. Reset data before each test or use another explicit cleanup strategy.

8.3. Should Every Collaborator Be Mocked in an Integration Test?

No. Mock only a boundary the test intentionally excludes, such as a notification provider. Keep the repository and database real if persistence is the behavior being tested. If all collaborators are mocks, a smaller slice or unit test will usually express the question more clearly.

9. Conclusion

Use @SpringBootTest when the test must cover real application wiring and persistence. Choose RANDOM_PORT for a real HTTP boundary or @AutoConfigureMockMvc for an in-memory MVC request. H2 makes these tests easy to run, while a production-database test is still needed for vendor-specific behavior.

Happy Learning!

Complete Spring Boot integration test example 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.