PCSalt
YouTube GitHub
Back to Spring Boot
Spring Boot · 2 min read

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

AnnotationWhat it loadsUse for
No annotation (plain JUnit)NothingPure business logic
@WebMvcTestControllers, filters, advicesHTTP layer testing
@DataJpaTestJPA, repositories, FlywayDatabase queries
@SpringBootTestEverythingFull 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.