Kotlin Testing Patterns
JUnit 5 + MockK
@ExtendWith(MockKExtension::class)
class UserServiceTest {
@MockK lateinit var userRepository: UserRepository
@MockK lateinit var emailService: EmailService
@InjectMockKs lateinit var userService: UserService
@Test
fun `findById returns user when exists`() {
val user = User(id = 1L, name = "Alice", email = "alice@example.com")
every { userRepository.findById(1L) } returns Optional.of(user)
val result = userService.findById(1L)
assertThat(result).isEqualTo(user.toDto())
verify(exactly = 1) { userRepository.findById(1L) }
}
@Test
fun `findById throws when not found`() {
every { userRepository.findById(any()) } returns Optional.empty()
assertThrows<ResourceNotFoundException> { userService.findById(99L) }
}
@Test
fun `create sends welcome email`() {
val request = CreateUserRequest("Bob", "bob@example.com")
every { userRepository.existsByEmail(any()) } returns false
every { userRepository.save(any()) } answers { firstArg() }
every { emailService.sendWelcome(any()) } just Runs
userService.create(request)
verify { emailService.sendWelcome(match { it.email == "bob@example.com" }) }
}
}
Kotest
class UserServiceSpec : BehaviorSpec({
val repository = mockk<UserRepository>()
val service = UserService(repository)
Given("a user exists") {
val user = User(1L, "Alice", "alice@example.com")
every { repository.findById(1L) } returns Optional.of(user)
When("findById is called") {
val result = service.findById(1L)
Then("returns the user DTO") {
result shouldBe user.toDto()
}
}
}
})
// Data-driven tests
class EmailValidationSpec : FunSpec({
forAll(
row("valid@email.com", true),
row("invalid-email", false),
row("", false),
row("missing@domain", false),
) { email, expected ->
test("$email is ${if (expected) "valid" else "invalid"}") {
email.isValidEmail() shouldBe expected
}
}
})
Coroutine Testing
class FlowViewModelTest {
@get:Rule val mainDispatcherRule = MainDispatcherRule()
@Test
fun `search results update after debounce`() = runTest {
val fakeRepo = FakeSearchRepository()
val vm = SearchViewModel(fakeRepo)
vm.onQueryChange("kotlin")
advanceTimeBy(400) // past 300ms debounce
assertEquals(fakeRepo.expectedResults, vm.results.value)
}
@Test
fun `stateflow collects values`() = runTest {
val flow = MutableStateFlow(0)
val collected = mutableListOf<Int>()
val job = launch { flow.collect { collected.add(it) } }
flow.value = 1
flow.value = 2
job.cancel()
assertThat(collected).containsExactly(0, 1, 2)
}
}
Testcontainers
@SpringBootTest
@Testcontainers
class UserRepositoryIntegrationTest {
companion object {
@Container @JvmStatic
val postgres = PostgreSQLContainer("postgres:16-alpine")
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test")
@DynamicPropertySource @JvmStatic
fun configureProperties(registry: DynamicPropertyRegistry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl)
registry.add("spring.datasource.username", postgres::getUsername)
registry.add("spring.datasource.password", postgres::getPassword)
}
}
@Autowired lateinit var repository: UserRepository
@Test
@Transactional
fun `save and find user`() {
val user = User(name = "Test", email = "test@example.com")
val saved = repository.save(user)
val found = repository.findById(saved.id!!)
assertThat(found).isPresent.get().extracting("email").isEqualTo("test@example.com")
}
}
Spring Test Slices
// Only loads web layer
@WebMvcTest(UserController::class)
class UserControllerTest {
@Autowired lateinit var mvc: MockMvc
@MockBean lateinit var userService: UserService
@Test
fun `GET user returns 200`() {
every { userService.findById(1L) } returns UserResponse(1L, "Alice")
mvc.get("/api/v1/users/1")
.andExpect { status { isOk() } }
.andExpect { jsonPath("$.name") { value("Alice") } }
}
}
// Only loads data layer
@DataJpaTest
class OrderRepositoryTest {
@Autowired lateinit var repository: OrderRepository
@Autowired lateinit var entityManager: TestEntityManager
// ...
}
AssertJ Custom Assertions
fun assertThatUser(user: User) = UserAssert(user)
class UserAssert(actual: User) : AbstractAssert<UserAssert, User>(actual, UserAssert::class.java) {
fun isActive() = apply { check("status") { actual.status == UserStatus.ACTIVE } }
fun hasEmail(email: String) = apply { check("email") { actual.email == email } }
}
// Usage
assertThatUser(user).isActive().hasEmail("alice@example.com")
Key Rules
- Use
mockk over Mockito — idiomatic Kotlin, no any() casting issues, supports object mocking
- Use
runTest for coroutine tests — it uses TestCoroutineScheduler and auto-advances virtual time
@Testcontainers with @Container static field — reuse containers across tests with companion object
- Spring slices (
@WebMvcTest, @DataJpaTest) are faster than full @SpringBootTest — use them for focused tests
- Prefer
every { } returns over whenever { } thenReturn — cleaner in Kotlin