Spring Boot REST API Test Suite
Build a comprehensive test suite for a Spring Boot Task API that exercises every
level of the test pyramid: fast TaskService unit tests with MockK, focused
slice tests (@WebMvcTest, @DataJpaTest), and a full-stack @SpringBootTest
integration test running against a real PostgreSQL spun up by Testcontainers.
If you’ve written Jest unit tests plus a Supertest e2e suite in Node, or table
tests plus httptest in Go, this is the same instinct — just with the JVM’s
slice machinery doing the wiring for you.
What you’ll test
Section titled “What you’ll test”-
Unit tests —
TaskServicewith a mocked repository (MockK). No Spring context, no database — pure logic at memory speed. -
Controller tests —
TaskControllerwith@WebMvcTestand a MockK-backed service. Boots only the web layer to assert routing, status codes, and JSON. -
Repository tests —
TaskRepositorywith@DataJpaTestagainst a real Postgres container, so the generated queries are verified for real. -
Integration tests — the full API end-to-end with
@SpringBootTest+ Testcontainers: HTTP in, real database out.
Requirements
Section titled “Requirements”- Docker running locally (Testcontainers starts a Postgres container per run).
- JDK 21.
The toolset: JUnit 5 for structure and lifecycle, MockK for mocking,
springmockk to inject MockK mocks as Spring beans, Kotest assertions for
readable shouldBe matchers inside JUnit tests, and Testcontainers for the
real database.
The application under test
Section titled “The application under test”A standard four-layer Spring Boot app: controller → service → repository → entity. The slice you test at each level mirrors one of these layers.
Directoryspring-boot-testing/
- build.gradle.kts deps, test config, JaCoCo coverage
- settings.gradle.kts project name
Directorysrc/
Directorymain/kotlin/com/example/taskapi/
- TaskApiApplication.kt Spring Boot entry point
- model/Task.kt entity, request/response DTOs, mapper
- repository/TaskRepository.kt Spring Data JPA interface
- service/TaskService.kt business logic + validation
- controller/TaskController.kt REST endpoints
Directorytest/kotlin/com/example/taskapi/
Directoryunit/
- TaskServiceTest.kt MockK unit tests
Directoryintegration/
- TaskControllerTest.kt
@WebMvcTest+ MockK - TaskRepositoryTest.kt
@DataJpaTest+ Testcontainers - TaskApiIntegrationTest.kt
@SpringBootTest+ Testcontainers
- TaskControllerTest.kt
Directorytestutil/
- IntegrationTestBase.kt shared Testcontainers setup
- TestFixtures.kt test data factories
The service is the only layer with interesting logic — validation, trimming, and null-on-missing semantics — which is exactly what the unit tests target.
@Serviceclass TaskService(private val taskRepository: TaskRepository) {
fun findAll(): List<TaskResponse> = taskRepository.findAll().map { it.toResponse() }
fun findById(id: Long): TaskResponse? = taskRepository.findById(id).orElse(null)?.toResponse()
fun findByCompleted(completed: Boolean): List<TaskResponse> = taskRepository.findByCompleted(completed).map { it.toResponse() }
fun search(query: String): List<TaskResponse> = taskRepository.findByTitleContainingIgnoreCase(query).map { it.toResponse() }
fun create(request: CreateTaskRequest): TaskResponse { require(request.title.isNotBlank()) { "Title must not be blank" } require(request.title.length <= 200) { "Title must be 200 characters or fewer" }
val entity = TaskEntity( title = request.title.trim(), description = request.description.trim() ) return taskRepository.save(entity).toResponse() }
fun update(id: Long, request: UpdateTaskRequest): TaskResponse? { val existing = taskRepository.findById(id).orElse(null) ?: return null
val updated = existing.copy( title = request.title?.trim() ?: existing.title, description = request.description?.trim() ?: existing.description, completed = request.completed ?: existing.completed, updatedAt = Instant.now() ) return taskRepository.save(updated).toResponse() }
fun delete(id: Long): Boolean { if (!taskRepository.existsById(id)) return false taskRepository.deleteById(id) return true }}The controller is a thin HTTP shell — it maps service results to status codes and
catches the validation IllegalArgumentException to return a 400.
@RestController@RequestMapping("/api/tasks")class TaskController(private val taskService: TaskService) {
@GetMapping fun listTasks(@RequestParam completed: Boolean?): List<TaskResponse> = if (completed != null) taskService.findByCompleted(completed) else taskService.findAll()
@GetMapping("/{id}") fun getTask(@PathVariable id: Long): ResponseEntity<TaskResponse> { val task = taskService.findById(id) ?: return ResponseEntity.notFound().build() return ResponseEntity.ok(task) }
@GetMapping("/search") fun searchTasks(@RequestParam q: String): List<TaskResponse> = taskService.search(q)
@PostMapping fun createTask(@RequestBody request: CreateTaskRequest): ResponseEntity<Any> { return try { val task = taskService.create(request) ResponseEntity.status(HttpStatus.CREATED).body(task) } catch (e: IllegalArgumentException) { ResponseEntity.badRequest().body(mapOf("error" to e.message)) } }
@PutMapping("/{id}") fun updateTask( @PathVariable id: Long, @RequestBody request: UpdateTaskRequest ): ResponseEntity<TaskResponse> { val task = taskService.update(id, request) ?: return ResponseEntity.notFound().build() return ResponseEntity.ok(task) }
@DeleteMapping("/{id}") fun deleteTask(@PathVariable id: Long): ResponseEntity<Void> { return if (taskService.delete(id)) ResponseEntity.noContent().build() else ResponseEntity.notFound().build() }}The build wiring
Section titled “The build wiring”Two things in build.gradle.kts are worth calling out for the testing story.
First, Mockito is excluded from spring-boot-starter-test and replaced with
MockK — Spring’s default mocking library is Mockito, but it’s awkward in Kotlin
(final classes, no relaxed mocks), so this course swaps it out. Second,
springmockk provides the @MockkBean annotation that injects a MockK mock into
the Spring context exactly where Spring would otherwise use @MockBean.
dependencies { // Spring Boot implementation("org.springframework.boot:spring-boot-starter-web") implementation("org.springframework.boot:spring-boot-starter-data-jpa") implementation("org.springframework.boot:spring-boot-starter-validation") implementation("com.fasterxml.jackson.module:jackson-module-kotlin") implementation("org.jetbrains.kotlin:kotlin-reflect") runtimeOnly("org.postgresql:postgresql")
// Testing — swap Mockito for MockK testImplementation("org.springframework.boot:spring-boot-starter-test") { exclude(module = "mockito-core") exclude(module = "mockito-junit-jupiter") } testImplementation("io.mockk:mockk:1.13.13") testImplementation("com.ninja-squad:springmockk:4.0.2")
// Testcontainers testImplementation(platform("org.testcontainers:testcontainers-bom:1.20.4")) testImplementation("org.testcontainers:junit-jupiter") testImplementation("org.testcontainers:postgresql")
// Kotest assertions (usable inside JUnit 5 tests) testImplementation("io.kotest:kotest-assertions-core:5.9.1")}
tasks.test { useJUnitPlatform() finalizedBy(tasks.jacocoTestReport)}Shared test data: fixtures
Section titled “Shared test data: fixtures”Before the tests, a small factory object keeps test data DRY. Every builder has sensible defaults so a test names only the fields it cares about — the Kotlin equivalent of a factory function or a builder in your TS/Go test helpers.
object TestFixtures {
fun taskEntity( id: Long = 0, title: String = "Test Task", description: String = "A test task description", completed: Boolean = false, createdAt: Instant = Instant.parse("2024-01-15T10:00:00Z"), updatedAt: Instant = Instant.parse("2024-01-15T10:00:00Z") ) = TaskEntity(id, title, description, completed, createdAt, updatedAt)
fun taskResponse( id: Long = 1, title: String = "Test Task", description: String = "A test task description", completed: Boolean = false, createdAt: Instant = Instant.parse("2024-01-15T10:00:00Z"), updatedAt: Instant = Instant.parse("2024-01-15T10:00:00Z") ) = TaskResponse(id, title, description, completed, createdAt, updatedAt)
fun createTaskRequest( title: String = "Test Task", description: String = "A test task description" ) = CreateTaskRequest(title = title, description = description)
fun updateTaskRequest( title: String? = null, description: String? = null, completed: Boolean? = null ) = UpdateTaskRequest(title = title, description = description, completed = completed)}Level 1 — Unit tests with MockK
Section titled “Level 1 — Unit tests with MockK”The fastest and most numerous tests. No Spring, no database — just TaskService
with a mocked repository. The mock is created with mockk<TaskRepository>() and
the real service is constructed around it.
A few MockK idioms to learn here, mapped from what you know:
mockk<TaskRepository>()creates the mock (likejest.fn()or a gomock stub).every { taskRepository.findAll() } returns entitiesstubs a call — theevery { } returnsform is MockK’swhen/thenReturn.verify(exactly = 1) { ... }asserts a call happened exactly N times.slot<TaskEntity>()pluscapture(slot)captures the argument passed to a stubbed call, so you can assert on what the service actually saved.answers { firstArg() }andanswers { slot.captured.copy(id = 1) }compute a dynamic return value (here: echo back the saved entity with a generated id).just Runsstubs aUnit-returning call likedeleteById.
@Nested inner class groups related cases (like a describe block in Jest), and
clearAllMocks() in @BeforeEach resets state between tests.
class TaskServiceTest {
private val taskRepository = mockk<TaskRepository>() private val taskService = TaskService(taskRepository)
@BeforeEach fun setup() { clearAllMocks() }
@Nested inner class Create { @Test fun `creates task with valid data`() { val slot = slot<TaskEntity>() every { taskRepository.save(capture(slot)) } answers { slot.captured.copy(id = 1) }
val result = taskService.create(TestFixtures.createTaskRequest( title = "New Task", description = "Description" ))
result.title shouldBe "New Task" result.completed shouldBe false result.id shouldBe 1
verify(exactly = 1) { taskRepository.save(any()) } }
@Test fun `trims whitespace from title and description`() { val slot = slot<TaskEntity>() every { taskRepository.save(capture(slot)) } answers { slot.captured.copy(id = 1) }
taskService.create(TestFixtures.createTaskRequest( title = " Trimmed Title ", description = " Trimmed Description " ))
slot.captured.title shouldBe "Trimmed Title" slot.captured.description shouldBe "Trimmed Description" }
@Test fun `rejects blank title`() { assertThrows<IllegalArgumentException> { taskService.create(TestFixtures.createTaskRequest(title = "")) }
verify(exactly = 0) { taskRepository.save(any()) } } }}Notice the two negative-path patterns: rejects blank title asserts the
exception fires AND that save was never called (verify(exactly = 0)), and the
returns null cases (in the Update/FindById nested classes) stub the
repository to return Optional.empty() and assert the service surfaces a Kotlin
null. That Optional ↔ nullable boundary — findById(...).orElse(null) — is
the kind of seam that’s worth a unit test.
Level 2 — Controller slice with @WebMvcTest
Section titled “Level 2 — Controller slice with @WebMvcTest”@WebMvcTest(TaskController::class) boots only the web layer — the
controller, the JSON serializer, and MockMvc — but not the service or the
database. The service is supplied as a @MockkBean lateinit var taskService, a
MockK mock injected into the trimmed-down Spring context by springmockk.
MockMvc drives requests in-process (no real socket) and jsonPath(...) asserts
on the response body. This is where you verify routing, status codes, request
parsing, and the controller’s error handling — without touching business logic.
@WebMvcTest(TaskController::class)class TaskControllerTest {
@Autowired lateinit var mockMvc: MockMvc
@MockkBean lateinit var taskService: TaskService
@Test fun `GET task by id returns 404 when not found`() { every { taskService.findById(999L) } returns null
mockMvc.perform(get("/api/tasks/999")) .andExpect(status().isNotFound) }
@Test fun `POST creates task`() { every { taskService.create(any()) } returns TestFixtures.taskResponse( id = 1, title = "New Task", description = "Created via test" )
mockMvc.perform( post("/api/tasks") .contentType(MediaType.APPLICATION_JSON) .content("""{"title": "New Task", "description": "Created via test"}""") ) .andExpect(status().isCreated) .andExpect(jsonPath("$.title").value("New Task"))
verify(exactly = 1) { taskService.create(any()) } }
@Test fun `POST returns 400 for invalid input`() { every { taskService.create(any()) } throws IllegalArgumentException("Title must not be blank")
mockMvc.perform( post("/api/tasks") .contentType(MediaType.APPLICATION_JSON) .content("""{"title": "", "description": "no title"}""") ) .andExpect(status().isBadRequest) .andExpect(jsonPath("$.error").value("Title must not be blank")) }}Level 3 — Repository slice with @DataJpaTest + Testcontainers
Section titled “Level 3 — Repository slice with @DataJpaTest + Testcontainers”@DataJpaTest boots only the JPA layer (entities, repositories, an
EntityManager). By default it would swap in an in-memory H2 database — but H2
isn’t Postgres, and you want to test the real query generation. So two things
override that:
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)tells Spring not to replace the configured datasource with an embedded one.- The test extends
IntegrationTestBase, which starts a real Postgres container and points Spring at it (covered below).
This proves your derived queries — findByCompleted, findByTitleContainingIgnoreCase
— actually translate to working SQL against Postgres, not just H2.
@DataJpaTest@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)class TaskRepositoryTest : IntegrationTestBase() {
@Autowired lateinit var taskRepository: TaskRepository
@BeforeEach fun cleanup() { taskRepository.deleteAll() }
@Test fun `finds tasks by completed status`() { taskRepository.save(TaskEntity(title = "Done 1", description = "d", completed = true)) taskRepository.save(TaskEntity(title = "Done 2", description = "d", completed = true)) taskRepository.save(TaskEntity(title = "Pending", description = "d", completed = false))
val completed = taskRepository.findByCompleted(true) completed shouldHaveSize 2 completed.all { it.completed } shouldBe true
val pending = taskRepository.findByCompleted(false) pending shouldHaveSize 1 }
@Test fun `search is case insensitive`() { taskRepository.save(TaskEntity(title = "UPPERCASE Task", description = "d"))
val results = taskRepository.findByTitleContainingIgnoreCase("uppercase") results shouldHaveSize 1 results[0].title shouldBe "UPPERCASE Task" }}The Testcontainers base class
Section titled “The Testcontainers base class”The shared base class is the heart of the integration setup. A single
PostgreSQLContainer is started once (it’s a companion object @JvmStatic
field, so it’s shared across all tests in the class), and @DynamicPropertySource
injects the container’s randomized JDBC URL, username, and password into Spring’s
configuration at runtime.
abstract class IntegrationTestBase {
companion object { @JvmStatic val postgres = PostgreSQLContainer("postgres:16-alpine").apply { withDatabaseName("testdb") withUsername("test") withPassword("test") start() }
@JvmStatic @DynamicPropertySource fun configureProperties(registry: DynamicPropertyRegistry) { registry.add("spring.datasource.url") { postgres.jdbcUrl } registry.add("spring.datasource.username") { postgres.username } registry.add("spring.datasource.password") { postgres.password } registry.add("spring.jpa.hibernate.ddl-auto") { "create-drop" } } }}Level 4 — Full integration with @SpringBootTest
Section titled “Level 4 — Full integration with @SpringBootTest”The top of the pyramid: @SpringBootTest boots the entire application
context — every real bean, no mocks — and @AutoConfigureMockMvc gives you a
MockMvc to drive the real HTTP stack. Combined with the Testcontainers base
class, a request flows controller → service → repository → real Postgres and back.
The flagship test walks a complete CRUD lifecycle through HTTP, reading the
generated id back out of the create response with JsonPath.read and using it for
the read/update/delete steps.
@SpringBootTest@AutoConfigureMockMvcclass TaskApiIntegrationTest : IntegrationTestBase() {
@Autowired lateinit var mockMvc: MockMvc
@Test fun `full CRUD lifecycle against real PostgreSQL`() { // Create val createResult = mockMvc.perform( post("/api/tasks") .contentType(MediaType.APPLICATION_JSON) .content("""{"title": "Integration Test Task", "description": "Testing with real DB"}""") ) .andExpect(status().isCreated) .andExpect(jsonPath("$.title").value("Integration Test Task")) .andExpect(jsonPath("$.id").exists()) .andReturn()
val id = com.jayway.jsonpath.JsonPath.read<Int>( createResult.response.contentAsString, "$.id" )
// Read mockMvc.perform(get("/api/tasks/$id")) .andExpect(status().isOk) .andExpect(jsonPath("$.title").value("Integration Test Task"))
// Update mockMvc.perform( put("/api/tasks/$id") .contentType(MediaType.APPLICATION_JSON) .content("""{"title": "Updated Task", "completed": true}""") ) .andExpect(status().isOk) .andExpect(jsonPath("$.completed").value(true))
// Delete mockMvc.perform(delete("/api/tasks/$id")) .andExpect(status().isNoContent)
// Verify deleted mockMvc.perform(get("/api/tasks/$id")) .andExpect(status().isNotFound) }}Run the tests
Section titled “Run the tests”-
Make sure Docker is running (Testcontainers needs it for the repository and integration layers):
Terminal window docker info -
Run the whole suite:
Terminal window ./gradlew test -
Run just the fast unit tests:
Terminal window ./gradlew test --tests "com.example.taskapi.unit.*" -
Run just the integration tests:
Terminal window ./gradlew test --tests "com.example.taskapi.integration.*" -
The build is configured with JaCoCo, so a coverage report lands at
build/reports/jacoco/test/html/index.htmlafter any test run.