Skip to content

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.

  1. Unit testsTaskService with a mocked repository (MockK). No Spring context, no database — pure logic at memory speed.

  2. Controller testsTaskController with @WebMvcTest and a MockK-backed service. Boots only the web layer to assert routing, status codes, and JSON.

  3. Repository testsTaskRepository with @DataJpaTest against a real Postgres container, so the generated queries are verified for real.

  4. Integration tests — the full API end-to-end with @SpringBootTest + Testcontainers: HTTP in, real database out.

  • 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.

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
        • 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.

src/main/kotlin/com/example/taskapi/service/TaskService.kt
@Service
class 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.

src/main/kotlin/com/example/taskapi/controller/TaskController.kt
@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()
}
}

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.

build.gradle.kts
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)
}

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.

src/test/kotlin/com/example/taskapi/testutil/TestFixtures.kt
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)
}

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 (like jest.fn() or a gomock stub).
  • every { taskRepository.findAll() } returns entities stubs a call — the every { } returns form is MockK’s when/thenReturn.
  • verify(exactly = 1) { ... } asserts a call happened exactly N times.
  • slot<TaskEntity>() plus capture(slot) captures the argument passed to a stubbed call, so you can assert on what the service actually saved.
  • answers { firstArg() } and answers { slot.captured.copy(id = 1) } compute a dynamic return value (here: echo back the saved entity with a generated id).
  • just Runs stubs a Unit-returning call like deleteById.

@Nested inner class groups related cases (like a describe block in Jest), and clearAllMocks() in @BeforeEach resets state between tests.

src/test/kotlin/com/example/taskapi/unit/TaskServiceTest.kt
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.

src/test/kotlin/com/example/taskapi/integration/TaskControllerTest.kt
@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:

  1. @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) tells Spring not to replace the configured datasource with an embedded one.
  2. 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.

src/test/kotlin/com/example/taskapi/integration/TaskRepositoryTest.kt
@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 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.

src/test/kotlin/com/example/taskapi/testutil/IntegrationTestBase.kt
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.

src/test/kotlin/com/example/taskapi/integration/TaskApiIntegrationTest.kt
@SpringBootTest
@AutoConfigureMockMvc
class 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)
}
}
  1. Make sure Docker is running (Testcontainers needs it for the repository and integration layers):

    Terminal window
    docker info
  2. Run the whole suite:

    Terminal window
    ./gradlew test
  3. Run just the fast unit tests:

    Terminal window
    ./gradlew test --tests "com.example.taskapi.unit.*"
  4. Run just the integration tests:

    Terminal window
    ./gradlew test --tests "com.example.taskapi.integration.*"
  5. The build is configured with JaCoCo, so a coverage report lands at build/reports/jacoco/test/html/index.html after any test run.