Spring Boot

SpringBootTest

@SpringBootTest starts your real application context, so it can catch wiring, security, transaction, and SQL problems that slice tests and unit tests cannot. It is also the slowest kind of Spring test, so how you structure it matters. This entry covers the web environment modes, shared Testcontainers setup, mocking external services, test properties and profiles, and context caching, including the mistakes that make suites flaky or slow: @WithMockUser silently ignored over real HTTP, a test application.properties that hides the main one, and a new Postgres container per test class.

Web Environment Modes

@SpringBootTest has four modes. MOCK (the default) builds the full context with a mock servlet environment and no real server, so you drive it with MockMvc. RANDOM_PORT starts a real embedded server on a free port, and you call it over HTTP. DEFINED_PORT uses the port from your configuration, which risks conflicts and is rarely worth it. NONE loads the context with no web environment at all, which suits batch jobs, message consumers, and service-layer tests. Choosing between MOCK and RANDOM_PORT is mostly a security question. MockMvc runs in the same thread as the test, so @WithMockUser and @Transactional rollback both work. A real HTTP call runs on a server thread, so neither works: @WithMockUser is silently ignored (you get 401 and wonder why) and a @Transactional test will not roll back what the server committed. Over real HTTP you must authenticate for real, for example by obtaining a token from your login endpoint. Use RANDOM_PORT when you need to test things MockMvc skips: the real servlet container, filters registered at the container level, serialization over the wire, or HTTP clients. Otherwise MockMvc is faster and simpler. Do not combine @AutoConfigureMockMvc with RANDOM_PORT unless you really use both.
Java
// MOCK (default): in-process, @WithMockUser works
@SpringBootTest
@AutoConfigureMockMvc
class UserMockMvcTest {

    @Autowired MockMvc mockMvc;

    @Test
    @WithMockUser(roles = "ADMIN")
    void adminCanList() throws Exception {
        mockMvc.perform(get("/api/users")).andExpect(status().isOk());
    }

    // If CSRF protection is on, state-changing requests also need: .with(csrf())
}

// RANDOM_PORT: real server, so authenticate for real. @WithMockUser would be ignored here.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class UserHttpTest {

    @Autowired TestRestTemplate rest;

    @Test
    void adminCanList() {
        String token = login("admin@example.com", "password");     // your helper: POST /api/auth/login
        HttpHeaders headers = new HttpHeaders();
        headers.setBearerAuth(token);

        ResponseEntity<UserResponse[]> response = rest.exchange(
            "/api/users", HttpMethod.GET, new HttpEntity<>(headers), UserResponse[].class);

        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
    }
}

// NONE: no web layer. Good for services, consumers, schedulers.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
class PricingServiceTest { ... }

// Notes:
//  - TestRestTemplate and WebTestClient both work against RANDOM_PORT. WebTestClient needs
//    spring-webflux on the test classpath even for MVC apps.
//  - On Spring Boot 4 the test auto-configuration annotations and TestRestTemplate moved into
//    separate test modules; if imports stop resolving after an upgrade, check the migration guide.

Real Database with Testcontainers

Integration tests are most valuable against the same database engine you run in production. H2 in "PostgreSQL mode" passes tests that fail in production (and the reverse), especially with native queries, JSON columns, and Flyway migrations written for one dialect. Testcontainers gives you the real thing. The pattern that scales is one shared container for the whole test run. The version in many tutorials, where each test class declares its own @Container, starts a fresh database per class, which is slow, and it also defeats Spring's context cache because each class ends up with different datasource properties. On Spring Boot 3.1 and later, declare the container as a bean annotated with @ServiceConnection. Boot wires the datasource to it for you, so the @DynamicPropertySource boilerplate disappears, and the container's lifecycle follows the cached Spring context, so every test class that shares the context shares the database. Put @SpringBootTest and your common setup in an abstract base class. Because the database now persists across tests, you need to isolate them. With MockMvc, mark tests @Transactional and they roll back automatically. Over real HTTP, rollback is impossible, so clean the tables in @BeforeEach (delete child tables before parents).
Java
@TestConfiguration(proxyBeanMethods = false)
public class TestcontainersConfig {

    @Bean
    @ServiceConnection
    PostgreSQLContainer<?> postgres() {
        return new PostgreSQLContainer<>("postgres:16-alpine");   // .withReuse(true) speeds up local reruns
    }
}

@SpringBootTest
@AutoConfigureMockMvc
@Import(TestcontainersConfig.class)
@ActiveProfiles("test")
public abstract class AbstractIntegrationTest {
    @Autowired protected MockMvc mockMvc;
    @Autowired protected ObjectMapper objectMapper;
}

@Transactional                                   // MockMvc runs in the test thread, so this rolls back
class UserIntegrationTest extends AbstractIntegrationTest {

    @Autowired UserRepository userRepository;

    @Test
    @WithMockUser(roles = "ADMIN")
    void createThenFetch() throws Exception {
        var request = new CreateUserRequest("Alice", "alice@example.com", "password123");

        String location = mockMvc.perform(post("/api/users")
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(request)))
            .andExpect(status().isCreated())
            .andReturn().getResponse().getHeader("Location");

        mockMvc.perform(get(location))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.name").value("Alice"))
            .andExpect(jsonPath("$.email").value("alice@example.com"));

        assertThat(userRepository.findByEmail("alice@example.com")).isPresent();
    }

    @Test
    @WithMockUser(roles = "ADMIN")
    void updatePersistsChanges() throws Exception {
        UserEntity saved = userRepository.saveAndFlush(
            UserEntity.builder().name("Bob").email("bob@example.com").build());

        mockMvc.perform(put("/api/users/" + saved.getId())
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(new UpdateUserRequest("Robert", null))))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.name").value("Robert"));
    }

    @Test
    @WithMockUser(roles = "USER")
    void nonAdminCannotDelete() throws Exception {
        mockMvc.perform(delete("/api/users/1")).andExpect(status().isForbidden());
    }
}

Faking External Services

A full context starts every client bean, including ones that call other services. You have two ways to keep them out of your test, and they test different things. @MockitoBean replaces a bean with a Mockito mock. It is quick, but you are replacing the client itself, so nothing exercises its URL building, JSON mapping, timeouts, retries, or error decoding. That is where integration bugs live. @MockBean does the same job but was deprecated in Spring Boot 3.4 in favour of Spring Framework's @MockitoBean, and it is gone in Boot 4, so prefer @MockitoBean in new code. For anything with real HTTP behaviour, run a fake HTTP server instead. WireMock lets you stub responses (including 500s and slow replies) and have your real Feign or RestClient code talk to it, so serialization and your resilience config are actually tested. One cost to know about: every distinct set of @MockitoBean declarations produces a different context, so scattering mocks across classes defeats the context cache. Put the common ones in the base class.
Java
// Option 1: replace the client bean. Fast, but skips the HTTP layer.
class OrderIntegrationTest extends AbstractIntegrationTest {

    @MockitoBean UserClient userClient;
    @MockitoBean InventoryClient inventoryClient;
    @MockitoBean PaymentClient paymentClient;

    @BeforeEach
    void stubs() {
        when(userClient.findById(1L)).thenReturn(new UserResponse(1L, "Alice", "alice@example.com"));
        when(inventoryClient.checkStock(10L)).thenReturn(new InventoryResponse(10L, true, 5));
        when(paymentClient.charge(any())).thenReturn(new PaymentResponse(PaymentStatus.SUCCESS));
    }

    @Test
    @WithMockUser(username = "1", roles = "USER")
    void placeOrder_persistsAndCallsDependencies() throws Exception {
        mockMvc.perform(post("/api/orders")
                .contentType(MediaType.APPLICATION_JSON)
                .content("{\"userId\":1,\"productId\":10,\"quantity\":2}"))
            .andExpect(status().isCreated())
            .andExpect(jsonPath("$.status").value("CONFIRMED"));

        verify(paymentClient).charge(any());
    }

    @Test
    @WithMockUser(username = "1", roles = "USER")
    void placeOrder_outOfStock_returns409_andPaymentIsNeverCalled() throws Exception {
        when(inventoryClient.checkStock(10L)).thenReturn(new InventoryResponse(10L, false, 0));

        mockMvc.perform(post("/api/orders")
                .contentType(MediaType.APPLICATION_JSON)
                .content("{\"userId\":1,\"productId\":10,\"quantity\":2}"))
            .andExpect(status().isConflict());

        verifyNoInteractions(paymentClient);            // the important assertion
    }
}

// Option 2: fake the remote service over real HTTP with WireMock. Tests serialization,
// error handling and your retry/circuit-breaker settings.
class InventoryHttpTest extends AbstractIntegrationTest {

    @RegisterExtension
    static WireMockExtension inventory = WireMockExtension.newInstance()
        .options(wireMockConfig().dynamicPort()).build();

    @DynamicPropertySource
    static void clientUrls(DynamicPropertyRegistry registry) {
        registry.add("clients.inventory.url", inventory::baseUrl);     // use your own property name
    }

    @Test
    @WithMockUser(roles = "USER")
    void inventoryServerError_returns503() throws Exception {
        inventory.stubFor(get(urlEqualTo("/stock/10")).willReturn(serverError()));

        mockMvc.perform(post("/api/orders")
                .contentType(MediaType.APPLICATION_JSON)
                .content("{\"userId\":1,\"productId\":10,\"quantity\":1}"))
            .andExpect(status().isServiceUnavailable());
    }
}

Test Properties and Profiles

Keep test configuration in src/test/resources/application-test.yml and activate it with @ActiveProfiles("test"). Spring merges it over your main application.yml, so you only list what differs. Avoid creating src/test/resources/application.properties (or application.yml). Test resources come first on the classpath, and a file with the same name replaces the main one instead of merging with it, which makes properties vanish and gives you confusing errors. Putting spring.profiles.active=test in such a file is the usual way people trip over this. A few corrections to common advice. There is no built-in property that disables @Scheduled tasks. Guard your scheduling configuration with a conditional property of your own (for example @ConditionalOnProperty on the @EnableScheduling class), then switch it off in test. Properties like a BCrypt strength are your own settings, not Spring's, so they only work if your code reads them. And do not keep an H2 URL "just in case" next to Testcontainers; the container's connection details will override it and leave a misleading config behind. Finally, every different profile or property combination creates a different application context, so use @TestPropertySource sparingly and keep overrides in the shared profile when you can.
yaml
# src/test/resources/application-test.yml
spring:
  jpa:
    show-sql: false              # turn on temporarily while debugging
    hibernate:
      ddl-auto: validate         # let Flyway own the schema, same as production
  flyway:
    enabled: true

logging:
  level:
    org.hibernate.SQL: DEBUG     # optional while debugging

# Your own switches, honoured only if your code reads them:
app:
  scheduling:
    enabled: false
  security:
    bcrypt-strength: 4           # faster password hashing in tests (default cost is 10)

// The guard that makes app.scheduling.enabled actually work
@Configuration
@EnableScheduling
@ConditionalOnProperty(name = "app.scheduling.enabled", havingValue = "true", matchIfMissing = true)
class SchedulingConfig {}

// One-off override for a single class (creates a separate context, so use sparingly)
@TestPropertySource(properties = "app.feature.new-checkout=true")
class CheckoutFeatureFlagTest extends AbstractIntegrationTest { ... }

Keeping the Suite Fast

Spring caches an application context and reuses it for every test class with the same configuration. Starting a context takes seconds, so the number of distinct contexts largely determines your suite time. Anything that changes the configuration creates a new one: a different profile, a different @TestPropertySource, a different set of @MockitoBean declarations, a different @Import, or @DirtiesContext, which throws the cache entry away. Practical rules: put the shared setup in one abstract base class so most tests reuse a single context; prefer cleaning data over @DirtiesContext; and push as much as you can down to cheaper slice tests (@WebMvcTest for controllers and validation, @DataJpaTest for repositories) so full-context tests cover only what slices cannot, such as security, wiring, and end-to-end flows. If you suspect cache misses, set logging.level.org.springframework.test.context.cache=DEBUG and watch the hit and miss lines.
yaml
# See whether contexts are being reused
logging:
  level:
    org.springframework.test.context.cache: DEBUG

// Typical split in a healthy suite
//   @WebMvcTest(UserController.class)    controller, validation, JSON, security rules  (fast)
//   @DataJpaTest + Testcontainers        repositories and custom queries               (fast-ish)
//   @SpringBootTest + shared base class  a few end-to-end flows per feature            (slow, keep few)

// Things that each create a NEW context:
//   different @ActiveProfiles           different @TestPropertySource
//   different @MockitoBean set          different @Import / @TestConfiguration
//   @DirtiesContext                     different webEnvironment