Testing Spring Boot — Unit, Integration & Testcontainers
A practical guide to testing Spring Boot 4 applications — unit tests, MockMvc, @DataJpaTest, Testcontainers for PostgreSQL, and @ServiceConnection.
Tests that hit H2 while production runs PostgreSQL give false confidence. Tests that mock everything test the mocks, not the code. This post covers what actually works — unit tests for logic, MockMvc for controllers, and Testcontainers for real database tests.
Dependencies
dependencies {
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.springframework.boot:spring-boot-testcontainers")
testImplementation("org.testcontainers:junit-jupiter")
testImplementation("org.testcontainers:postgresql")
testImplementation("org.springframework.security:spring-security-test")
}
Spring Boot’s test starter includes JUnit 5, AssertJ, Mockito, and MockMvc.
Unit tests — no Spring context
For pure business logic, don’t start Spring. Plain JUnit is faster and clearer.
package com.example.demo.service
import com.example.demo.domain.Product
import com.example.demo.domain.ProductStatus
import com.example.demo.dto.CreateProductRequest
import com.example.demo.repository.ProductRepository
import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.api.Assertions.assertThatThrownBy
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
import org.mockito.InjectMocks
import org.mockito.Mock
import org.mockito.junit.jupiter.MockitoExtension
import org.mockito.kotlin.any
import org.mockito.kotlin.verify
import org.mockito.kotlin.whenever
import java.math.BigDecimal
import java.util.Optional
import java.util.UUID
@ExtendWith(MockitoExtension::class)
class ProductServiceTest {
@Mock
lateinit var productRepository: ProductRepository
@InjectMocks
lateinit var productService: ProductService
@Test
fun `create saves product and returns response`() {
val request = CreateProductRequest(
name = "Widget",
price = BigDecimal("29.99"),
stockQuantity = 100
)
whenever(productRepository.save(any<Product>())).thenAnswer { invocation ->
val product = invocation.getArgument<Product>(0)
product.apply {
// Simulate JPA setting the ID
val idField = Product::class.java.superclass.getDeclaredField("id")
idField.isAccessible = true
idField.set(this, UUID.randomUUID())
}
}
val response = productService.create(request)
assertThat(response.name).isEqualTo("Widget")
assertThat(response.price).isEqualTo(BigDecimal("29.99"))
verify(productRepository).save(any<Product>())
}
@Test
fun `findById throws when product not found`() {
val id = UUID.randomUUID()
whenever(productRepository.findById(id)).thenReturn(Optional.empty())
assertThatThrownBy { productService.findById(id) }
.isInstanceOf(ProductNotFoundException::class.java)
.hasMessageContaining(id.toString())
}
}
Use @ExtendWith(MockitoExtension::class) instead of @SpringBootTest. No application context, no database — runs in milliseconds.
Controller tests with MockMvc
@WebMvcTest loads only the web layer — the controller, filters, and exception handlers. Everything else is mocked.
package com.example.demo.controller
import com.example.demo.dto.ProductResponse
import com.example.demo.service.ProductService
import com.example.demo.service.ProductNotFoundException
import org.junit.jupiter.api.Test
import org.mockito.kotlin.whenever
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
import org.springframework.boot.test.mock.bean.MockBean
import org.springframework.http.MediaType
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.get
import org.springframework.test.web.servlet.post
import java.math.BigDecimal
import java.time.Instant
import java.util.UUID
@WebMvcTest(ProductController::class)
class ProductControllerTest(
@Autowired private val mockMvc: MockMvc,
@MockBean private val productService: ProductService
) {
@Test
fun `GET products returns list`() {
val products = listOf(
ProductResponse(
id = UUID.randomUUID(),
name = "Widget",
description = null,
price = BigDecimal("29.99"),
status = "ACTIVE",
stockQuantity = 100,
createdAt = Instant.now(),
updatedAt = Instant.now()
)
)
whenever(productService.findAll(null, org.mockito.kotlin.any()))
.thenReturn(org.springframework.data.domain.PageImpl(products))
mockMvc.get("/api/v1/products")
.andExpect {
status { isOk() }
jsonPath("$.content[0].name") { value("Widget") }
jsonPath("$.content[0].price") { value(29.99) }
}
}
@Test
fun `POST products requires authentication`() {
mockMvc.post("/api/v1/products") {
contentType = MediaType.APPLICATION_JSON
content = """{"name": "Widget", "price": 29.99, "stockQuantity": 10}"""
}.andExpect {
status { isUnauthorized() }
}
}
@Test
fun `POST products with valid auth creates product`() {
val response = ProductResponse(
id = UUID.randomUUID(),
name = "Widget",
description = null,
price = BigDecimal("29.99"),
status = "ACTIVE",
stockQuantity = 10,
createdAt = Instant.now(),
updatedAt = Instant.now()
)
whenever(productService.create(org.mockito.kotlin.any())).thenReturn(response)
mockMvc.post("/api/v1/products") {
with(jwt())
contentType = MediaType.APPLICATION_JSON
content = """{"name": "Widget", "price": 29.99, "stockQuantity": 10}"""
}.andExpect {
status { isOk() }
jsonPath("$.name") { value("Widget") }
}
}
@Test
fun `GET product returns 404 when not found`() {
val id = UUID.randomUUID()
whenever(productService.findById(id)).thenThrow(ProductNotFoundException(id))
mockMvc.get("/api/v1/products/$id")
.andExpect {
status { isNotFound() }
}
}
}
Repository tests with @DataJpaTest
@DataJpaTest loads only JPA components — entities, repositories, and an embedded datasource. By default it uses an in-memory database, but we’ll override that with Testcontainers.
Base test class with Testcontainers
package com.example.demo
import org.springframework.boot.testcontainers.service.connection.ServiceConnection
import org.springframework.test.context.DynamicPropertyRegistry
import org.springframework.test.context.DynamicPropertySource
import org.testcontainers.containers.PostgreSQLContainer
import org.testcontainers.junit.jupiter.Container
import org.testcontainers.junit.jupiter.Testcontainers
@Testcontainers
abstract class PostgresTestBase {
companion object {
@Container
@ServiceConnection
val postgres = PostgreSQLContainer("postgres:16-alpine")
}
}
@ServiceConnection is a Spring Boot 3.1+ feature. It automatically configures spring.datasource.url, spring.datasource.username, and spring.datasource.password from the container — no manual @DynamicPropertySource needed.
Repository test
package com.example.demo.repository
import com.example.demo.PostgresTestBase
import com.example.demo.domain.Product
import com.example.demo.domain.ProductStatus
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
import org.springframework.data.domain.PageRequest
import java.math.BigDecimal
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
class ProductRepositoryTest : PostgresTestBase() {
@Autowired
lateinit var productRepository: ProductRepository
@BeforeEach
fun setUp() {
productRepository.deleteAll()
}
@Test
fun `findByStatus returns only matching products`() {
productRepository.save(Product("Active Widget", null, BigDecimal("10.00"), ProductStatus.ACTIVE, 5))
productRepository.save(Product("Inactive Widget", null, BigDecimal("20.00"), ProductStatus.INACTIVE, 0))
productRepository.save(Product("Another Active", null, BigDecimal("30.00"), ProductStatus.ACTIVE, 10))
val activeProducts = productRepository.findByStatus(ProductStatus.ACTIVE, PageRequest.of(0, 10))
assertThat(activeProducts.content).hasSize(2)
assertThat(activeProducts.content).allMatch { it.status == ProductStatus.ACTIVE }
}
@Test
fun `findByPriceRangeAndStatus filters correctly`() {
productRepository.save(Product("Cheap", null, BigDecimal("5.00"), ProductStatus.ACTIVE, 10))
productRepository.save(Product("Mid", null, BigDecimal("50.00"), ProductStatus.ACTIVE, 10))
productRepository.save(Product("Expensive", null, BigDecimal("500.00"), ProductStatus.ACTIVE, 10))
productRepository.save(Product("Inactive Mid", null, BigDecimal("50.00"), ProductStatus.INACTIVE, 0))
val results = productRepository.findByPriceRangeAndStatus(
BigDecimal("10.00"),
BigDecimal("100.00"),
ProductStatus.ACTIVE
)
assertThat(results).hasSize(1)
assertThat(results[0].name).isEqualTo("Mid")
}
@Test
fun `pagination works correctly`() {
repeat(25) { i ->
productRepository.save(Product("Product $i", null, BigDecimal("10.00"), ProductStatus.ACTIVE, i))
}
val firstPage = productRepository.findAll(PageRequest.of(0, 10))
val secondPage = productRepository.findAll(PageRequest.of(1, 10))
val thirdPage = productRepository.findAll(PageRequest.of(2, 10))
assertThat(firstPage.content).hasSize(10)
assertThat(secondPage.content).hasSize(10)
assertThat(thirdPage.content).hasSize(5)
assertThat(firstPage.totalElements).isEqualTo(25)
assertThat(firstPage.totalPages).isEqualTo(3)
}
}
@AutoConfigureTestDatabase(replace = NONE) tells Spring not to replace the datasource with an embedded one. Testcontainers provides the real PostgreSQL.
Full integration tests with @SpringBootTest
For testing the complete request flow — controller through service to database:
package com.example.demo
import com.example.demo.domain.Product
import com.example.demo.domain.ProductStatus
import com.example.demo.repository.ProductRepository
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.http.MediaType
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.get
import org.springframework.test.web.servlet.post
import java.math.BigDecimal
@SpringBootTest
@AutoConfigureMockMvc
class ProductIntegrationTest : PostgresTestBase() {
@Autowired
lateinit var mockMvc: MockMvc
@Autowired
lateinit var productRepository: ProductRepository
@BeforeEach
fun setUp() {
productRepository.deleteAll()
}
@Test
fun `create and retrieve product end-to-end`() {
// Create
val createResult = mockMvc.post("/api/v1/products") {
with(jwt())
contentType = MediaType.APPLICATION_JSON
content = """{"name": "Widget", "price": 29.99, "stockQuantity": 10}"""
}.andExpect {
status { isCreated() }
jsonPath("$.name") { value("Widget") }
jsonPath("$.id") { exists() }
}.andReturn()
// Verify it's in the database
val products = productRepository.findAll()
assertThat(products).hasSize(1)
assertThat(products[0].name).isEqualTo("Widget")
// Retrieve
mockMvc.get("/api/v1/products")
.andExpect {
status { isOk() }
jsonPath("$.content").isArray()
jsonPath("$.content[0].name") { value("Widget") }
}
}
@Test
fun `validation errors return 400`() {
mockMvc.post("/api/v1/products") {
with(jwt())
contentType = MediaType.APPLICATION_JSON
content = """{"name": "", "price": -1}"""
}.andExpect {
status { isBadRequest() }
}
}
}
@SpringBootTest loads the full application context. Combined with @AutoConfigureMockMvc, you get the entire stack without an actual HTTP server.
Testcontainers lifecycle
Singleton container pattern
The base class above starts one container per test class. For faster test suites, share a single container across all tests:
package com.example.demo
import org.springframework.boot.test.context.TestConfiguration
import org.springframework.boot.testcontainers.service.connection.ServiceConnection
import org.springframework.context.annotation.Bean
import org.testcontainers.containers.PostgreSQLContainer
@TestConfiguration(proxyBeanMethods = false)
class TestcontainersConfig {
@Bean
@ServiceConnection
fun postgres(): PostgreSQLContainer<Nothing> {
return PostgreSQLContainer<Nothing>("postgres:16-alpine")
.apply { withReuse(true) }
}
}
Import it in your test:
@SpringBootTest
@Import(TestcontainersConfig::class)
class MyIntegrationTest {
// tests...
}
With withReuse(true) and testcontainers.reuse.enable=true in ~/.testcontainers.properties, the container survives between test runs.
Using @TestConfiguration for local development
Spring Boot 4 supports a TestApplication entry point for running the app with test containers locally:
package com.example.demo
import org.springframework.boot.fromApplication
import org.springframework.boot.with
fun main(args: Array<String>) {
fromApplication<DemoApplication>()
.with(TestcontainersConfig::class)
.run(*args)
}
Run this instead of the main application during development — it starts PostgreSQL in a container automatically.
Test slices summary
| Annotation | What it loads | Use for |
|---|---|---|
| No annotation (plain JUnit) | Nothing | Pure business logic |
@WebMvcTest | Controllers, filters, advices | HTTP layer testing |
@DataJpaTest | JPA, repositories, Flyway | Database queries |
@SpringBootTest | Everything | Full integration tests |
Common mistakes
Using H2 for tests, PostgreSQL for production. SQL behavior differs between databases. Use Testcontainers with the same database as production.
Starting the full context for unit tests. If you’re testing a single service class, mock its dependencies with Mockito. Don’t load the entire application context.
Not cleaning up between tests. Use @BeforeEach to clear the database, or @Transactional on test classes (which rolls back after each test by default in @DataJpaTest).
Testing the framework. Don’t test that @NotBlank works — Spring already tests that. Test your validation rules, error response format, and business logic.
Match the test scope to what you’re verifying. Unit tests for logic, MockMvc for HTTP behavior, Testcontainers for data access, and integration tests for the full flow.