Skip to content
ExponaBLOG
JAVA, SPRING FRAMEWORK & SPRING BOOT · DAY 065 / 100

Integration tests with @SpringBootTest, explained simply

Prove the whole app works together.

Expona Admin7 min read

Integration tests with @SpringBootTest, explained simply

Most unit tests verify a single class in isolation, but they don't prove your whole application works together. When you wire up controllers, services, repositories, and databases, new bugs emerge that unit tests never catch. Integration tests with @SpringBootTest let you start a real Spring Boot context and exercise critical paths end-to-end, giving you confidence that the pieces fit together.

Why integration tests matter

Unit tests catch logic errors. Integration tests catch wiring errors, configuration mistakes, and the gaps between layers. A service might work perfectly in isolation, but fail when it actually calls your database through a real repository. A controller might handle requests fine, but your security configuration might block them. These real-world scenarios are exactly what integration tests expose.

The cost is that integration tests run slower and require more setup. That's why we keep them focused on critical paths rather than testing every possible permutation. A typical project has hundreds or thousands of unit tests and dozens of integration tests.

Start on a random port

When you run integration tests locally and in CI, you need your Spring Boot server to start without port conflicts. Using a random available port is the standard approach.

java
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
class OrderControllerIntegrationTest {
    
    @LocalServerPort
    private int port;
    
    @Autowired
    private TestRestTemplate restTemplate;
    
    @Test
    void testCreateOrder() {
        String url = "http://localhost:" + port + "/api/orders";
        CreateOrderRequest request = new CreateOrderRequest("WIDGET", 5);
        
        ResponseEntity<OrderResponse> response = restTemplate.postForEntity(
            url,
            request,
            OrderResponse.class
        );
        
        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED);
        assertThat(response.getBody().getId()).isNotNull();
    }
}

The @SpringBootTest annotation with RANDOM_PORT starts your embedded server on whatever port is available. The @LocalServerPort injection gives you that port number. Use TestRestTemplate to make actual HTTP calls to your running server, exactly as a client would.

This approach mimics production more closely than mocking out your servlet container. You're testing the real dispatcher servlet, real filter chains, and real request/response handling.

Use Testcontainers for databases and brokers

Your integration tests need real infrastructure. Mocking a database layer defeats the purpose. Testcontainers spins up Docker containers for PostgreSQL, Redis, Kafka, and dozens of other services, all managed automatically.

java
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
class OrderRepositoryIntegrationTest {
    
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15")
        .withDatabaseName("testdb")
        .withUsername("testuser")
        .withPassword("testpass");
    
    static {
        postgres.start();
    }
    
    @DynamicPropertySource
    static void properties(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", postgres::getJdbcUrl);
        registry.add("spring.datasource.username", postgres::getUsername);
        registry.add("spring.datasource.password", postgres::getPassword);
    }
    
    @Autowired
    private OrderRepository orderRepository;
    
    @Test
    void testSaveAndRetrieveOrder() {
        Order order = new Order();
        order.setProductName("WIDGET");
        order.setQuantity(5);
        order.setStatus("PENDING");
        
        Order saved = orderRepository.save(order);
        assertThat(saved.getId()).isNotNull();
        
        Optional<Order> retrieved = orderRepository.findById(saved.getId());
        assertThat(retrieved).isPresent();
        assertThat(retrieved.get().getProductName()).isEqualTo("WIDGET");
    }
}

The static container starts once per test class. The @DynamicPropertySource method tells Spring to use the real container's connection details instead of your application properties. Now your repository tests run against an actual database, with schema migrations and all.

For Kafka or message brokers, follow the same pattern. Testcontainers maintains containers for RabbitMQ, Redis, Elasticsearch, and many others. Your integration tests become true end-to-end tests, proving the data actually flows through your persistence layer.

Call real endpoints with TestRestTemplate or RestClient

Integration tests differ from unit tests because they make actual HTTP requests to your running server. TestRestTemplate is the traditional choice; it's a convenient wrapper around RestTemplate designed for testing.

java
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
class PaymentControllerIntegrationTest {
    
    @LocalServerPort
    private int port;
    
    @Autowired
    private TestRestTemplate restTemplate;
    
    private String baseUrl;
    
    @BeforeEach
    void setUp() {
        baseUrl = "http://localhost:" + port + "/api/payments";
    }
    
    @Test
    void testProcessPaymentSuccess() {
        PaymentRequest request = new PaymentRequest(100.00, "USD", "user123");
        
        ResponseEntity<PaymentResponse> response = restTemplate.postForEntity(
            baseUrl,
            request,
            PaymentResponse.class
        );
        
        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
        assertThat(response.getBody().isSuccess()).isTrue();
    }
    
    @Test
    void testProcessPaymentBadRequest() {
        PaymentRequest request = new PaymentRequest(-10.00, "USD", "user123");
        
        ResponseEntity<ErrorResponse> response = restTemplate.postForEntity(
            baseUrl,
            request,
            ErrorResponse.class
        );
        
        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
    }
    
    @Test
    void testGetPaymentHistory() {
        ResponseEntity<PaymentResponse[]> response = restTemplate.getForEntity(
            baseUrl + "/user123",
            PaymentResponse[].class
        );
        
        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
        assertThat(response.getBody()).isNotEmpty();
    }
}

Spring 6.1 introduced RestClient, a newer, more fluent API. If you're on Spring Boot 3.2 or later, prefer RestClient for better readability:

java
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
class PaymentControllerModernTest {
    
    @LocalServerPort
    private int port;
    
    @Autowired
    private RestClient restClient;
    
    private String baseUrl;
    
    @BeforeEach
    void setUp() {
        baseUrl = "http://localhost:" + port + "/api/payments";
    }
    
    @Test
    void testProcessPayment() {
        PaymentRequest request = new PaymentRequest(100.00, "USD", "user123");
        
        PaymentResponse response = restClient.post()
            .uri(baseUrl)
            .body(request)
            .retrieve()
            .body(PaymentResponse.class);
        
        assertThat(response.isSuccess()).isTrue();
    }
}

Both approaches let you test your controllers as a client would, exercising request validation, error handling, and response serialization. You're not mocking the servlet layer—you're testing it.

Keep them few and focused on critical paths

Integration tests are expensive. They take seconds to run instead of milliseconds. Spinning up databases and application contexts adds overhead. Don't write an integration test for every scenario.

Focus on critical paths: payment processing, order creation, authentication flows, and data consistency across layers. Write one integration test per major user journey. For edge cases and error handling, unit tests are faster and clearer.

java
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
class CheckoutIntegrationTest {
    
    @LocalServerPort
    private int port;
    
    @Autowired
    private TestRestTemplate restTemplate;
    
    @Autowired
    private OrderRepository orderRepository;
    
    @Autowired
    private PaymentService paymentService;
    
    // This is a critical path: user adds items, checks out, payment processed, order saved
    @Test
    void testCompleteCheckoutFlow() {
        // 1. Create cart
        CartRequest cartRequest = new CartRequest();
        cartRequest.addItem("WIDGET", 2);
        cartRequest.addItem("GADGET", 1);
        
        ResponseEntity<CartResponse> cartResponse = restTemplate.postForEntity(
            "http://localhost:" + port + "/api/cart",
            cartRequest,
            CartResponse.class
        );
        assertThat(cartResponse.getStatusCode()).isEqualTo(HttpStatus.CREATED);
        String cartId = cartResponse.getBody().getId();
        
        // 2. Checkout
        CheckoutRequest checkoutRequest = new CheckoutRequest(cartId, "card_token_123");
        ResponseEntity<OrderResponse> orderResponse = restTemplate.postForEntity(
            "http://localhost:" + port + "/api/checkout",
            checkoutRequest,
            OrderResponse.class
        );
        assertThat(orderResponse.getStatusCode()).isEqualTo(HttpStatus.OK);
        Long orderId = orderResponse.getBody().getOrderId();
        
        // 3. Verify order was persisted with correct state
        Order order = orderRepository.findById(orderId).orElseThrow();
        assertThat(order.getStatus()).isEqualTo("CONFIRMED");
        assertThat(order.getItems()).hasSize(2);
        assertThat(order.getTotalAmount()).isEqualTo(150.00);
    }
}

This single test validates that a customer can check out successfully. It exercises the controller, service layer, and repository. If this test passes, you know the critical path works. Write five or ten of these, not a hundred.

Common mistakes

  • Not cleaning up between tests. If one test inserts data and the next test assumes a clean state, you'll get flaky failures. Use @Transactional on your test class or clean up explicitly in @AfterEach.
  • Testing the framework instead of your code. Don't write an integration test to verify that Spring Data JPA works—it does. Test your custom queries and business logic.
  • Ignoring randomization. If your test assumes data inserted earlier will be present, or assumes a specific ordering, it will fail randomly. Use explicit assertions and explicit setup.
  • Mixing unit and integration tests in the same class. Separate your fast unit tests from your slow integration tests. Many teams put integration tests in src/test/integration and run them in a separate CI stage.
  • Forgetting to test error paths. Test 400s, 500s, and edge cases in integration tests too. A successful request tells you half the story.

Key takeaways

  • Use @SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) to start a real server on an available port.
  • Inject @LocalServerPort and TestRestTemplate (or RestClient in Spring 6.1+) to make actual HTTP calls.
  • Use Testcontainers to spin up real databases, caches, and brokers so your integration tests exercise the full stack.
  • Keep integration tests focused on critical user journeys, not every edge case. Use unit tests for that.
  • Clean up state between tests and avoid flaky assumptions about data ordering or presence.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles