In the previous tutorial, you built a REST API with Ktor. Now let’s learn about testing. Good tests make your code reliable and give you confidence to refactor. In this tutorial, you will learn how to write tests in Kotlin using JUnit 5 and MockK.
In this tutorial, you will learn:
- JUnit 5 basics (
@Test,@BeforeEach,@Nested) - Assertions (
assertEquals,assertTrue,assertThrows) - Parameterized tests (
@ParameterizedTest,@ValueSource,@CsvSource) - MockK (mock, every, verify)
- Argument capture and verification order
- Testing coroutines with
runTest - Test organization and best practices
Setting Up
Add MockK and coroutines-test to your build.gradle.kts:
dependencies {
testImplementation(kotlin("test"))
testImplementation("io.mockk:mockk:1.13.16")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2")
}
tasks.test {
useJUnitPlatform()
}
JUnit 5 Basics
Your First Test
class CalculatorTest {
private lateinit var calculator: Calculator
@BeforeEach
fun setUp() {
calculator = Calculator()
}
@Test
fun `add returns sum of two numbers`() {
assertEquals(5, calculator.add(2, 3))
}
}
Key elements:
@Testmarks a test method@BeforeEachruns before each test (setup)- Backtick names like
\add returns sum`` make tests readable assertEquals(expected, actual)checks equality
Lifecycle Annotations
| Annotation | Runs |
|---|---|
@BeforeEach | Before each test |
@AfterEach | After each test |
@BeforeAll | Once before all tests (companion object) |
@AfterAll | Once after all tests (companion object) |
Assertions
JUnit 5 provides many assertion methods:
Equality
@Test
fun `add returns sum`() {
assertEquals(5, calculator.add(2, 3))
}
@Test
fun `subtract returns difference`() {
assertEquals(7, calculator.subtract(10, 3))
}
Boolean
@Test
fun `isEven returns true for even numbers`() {
assertTrue(calculator.isEven(4))
}
@Test
fun `isEven returns false for odd numbers`() {
assertFalse(calculator.isEven(5))
}
Null
@Test
fun `getUser returns null for non-existent user`() {
assertNull(service.getUser(999))
}
@Test
fun `getUser returns non-null for existing user`() {
assertNotNull(service.getUser(1))
}
Exceptions
@Test
fun `divide by zero throws ArithmeticException`() {
assertThrows<ArithmeticException> {
calculator.divide(10, 0)
}
}
@Test
fun `divide by zero has correct message`() {
val exception = assertThrows<ArithmeticException> {
calculator.divide(10, 0)
}
assertEquals("Division by zero", exception.message)
}
Parameterized Tests
Run the same test with different inputs:
@ValueSource
@ParameterizedTest
@ValueSource(ints = [0, 2, 4, 6, 8, 100])
fun `isEven returns true for even numbers`(n: Int) {
assertTrue(calculator.isEven(n))
}
@ParameterizedTest
@ValueSource(strings = ["alex@example.com", "user@domain.org"])
fun `isValidEmail accepts valid emails`(email: String) {
assertTrue(Validator.isValidEmail(email))
}
@CsvSource
@ParameterizedTest
@CsvSource(
"1, 1, 2",
"0, 0, 0",
"-1, 1, 0",
"100, 200, 300"
)
fun `add with various inputs`(a: Int, b: Int, expected: Int) {
assertEquals(expected, calculator.add(a, b))
}
Each line is a test case. Values are separated by commas.
@CsvSource for Strings
@ParameterizedTest
@CsvSource(
"hello world, hello-world",
"Kotlin Tutorial, kotlin-tutorial",
"Test 123, test-123"
)
fun `toSlug with various inputs`(input: String, expected: String) {
assertEquals(expected, StringUtils.toSlug(input))
}
MockK — Mocking in Kotlin
MockK is the most popular mocking library for Kotlin. It supports coroutines, extension functions, and Kotlin-specific features.
Why Mock?
When testing a class, you want to test only that class. You don’t want to test its dependencies (database, network, etc.). Mocking replaces real dependencies with fake ones you control.
Creating Mocks
interface UserRepository {
fun findById(id: Int): User?
fun findAll(): List<User>
fun save(user: User): User
fun delete(id: Int): Boolean
}
// Create a mock
val repository: UserRepository = mockk()
every / returns
Tell the mock what to return:
every { repository.findById(1) } returns User(1, "Alex", "alex@example.com")
every { repository.findById(999) } returns null
every { repository.findAll() } returns listOf(user1, user2)
verify
Check that a method was called:
// Verify it was called
verify { repository.findById(1) }
// Verify it was called exactly once
verify(exactly = 1) { repository.findById(1) }
// Verify it was never called
verify(exactly = 0) { repository.save(any()) }
Full Example
class UserServiceTest {
private val repository: UserRepository = mockk()
private val notifications: NotificationService = mockk()
private val service = UserService(repository, notifications)
@BeforeEach
fun setUp() {
clearMocks(repository, notifications)
}
@Test
fun `getUser returns user from repository`() {
val user = User(1, "Alex", "alex@example.com")
every { repository.findById(1) } returns user
val result = service.getUser(1)
assertEquals(user, result)
verify(exactly = 1) { repository.findById(1) }
}
@Test
fun `createUser saves user and sends email`() {
val saved = User(1, "Alex", "alex@example.com")
every { repository.save(any()) } returns saved
every { notifications.sendEmail(any(), any(), any()) } returns true
val result = service.createUser("Alex", "alex@example.com")
assertEquals(saved, result)
verify { repository.save(any()) }
verify { notifications.sendEmail("alex@example.com", "Welcome!", any()) }
}
}
any() Matcher
any() matches any argument:
every { repository.save(any()) } returns savedUser
verify { notifications.sendEmail(any(), any(), any()) }
match() Matcher
match() matches arguments based on a predicate:
verify { repository.save(match { !it.active }) }
Argument Capture
Capture the actual argument for inspection:
@Test
fun `createUser captures the saved user`() {
val slot = slot<User>()
every { repository.save(capture(slot)) } answers { slot.captured.copy(id = 1) }
every { notifications.sendEmail(any(), any(), any()) } returns true
service.createUser("Alex", "alex@example.com")
assertEquals("Alex", slot.captured.name)
assertEquals("alex@example.com", slot.captured.email)
}
Verify Order
Check that methods were called in the correct order:
@Test
fun `createUser saves before sending email`() {
every { repository.save(any()) } returns savedUser
every { notifications.sendEmail(any(), any(), any()) } returns true
service.createUser("Alex", "alex@example.com")
verifyOrder {
repository.save(any())
notifications.sendEmail(any(), any(), any())
}
}
Testing Coroutines
Use coEvery and coVerify for suspend functions. Use runTest to run tests with virtual time.
interface DataSource {
suspend fun fetchData(id: String): String
suspend fun saveData(id: String, data: String): Boolean
}
class DataService(private val source: DataSource) {
suspend fun getData(id: String): String = source.fetchData(id)
suspend fun processAndSave(id: String): Boolean {
val data = source.fetchData(id)
val processed = data.uppercase()
return source.saveData(id, processed)
}
}
Tests:
class DataServiceTest {
private val source: DataSource = mockk()
private val service = DataService(source)
@Test
fun `getData returns data from source`() = runTest {
coEvery { source.fetchData("123") } returns "hello"
val result = service.getData("123")
assertEquals("hello", result)
coVerify { source.fetchData("123") }
}
@Test
fun `processAndSave fetches, transforms, and saves`() = runTest {
coEvery { source.fetchData("123") } returns "hello"
coEvery { source.saveData("123", "HELLO") } returns true
val result = service.processAndSave("123")
assertTrue(result)
coVerify { source.fetchData("123") }
coVerify { source.saveData("123", "HELLO") }
}
}
MockK Coroutine Functions
| Regular | Coroutine |
|---|---|
every | coEvery |
verify | coVerify |
answers | coAnswers |
Nested Test Classes
Organize related tests with @Nested:
@DisplayName("User Management")
class UserManagementTest {
@Nested
@DisplayName("when creating users")
inner class CreateTests {
@Test
fun `creates user with valid data`() { ... }
@Test
fun `rejects blank name`() { ... }
@Test
fun `rejects invalid email`() { ... }
}
@Nested
@DisplayName("when querying users")
inner class QueryTests {
@Test
fun `finds user by id`() { ... }
@Test
fun `returns null for missing user`() { ... }
}
}
This creates a clear hierarchy in test reports.
What to Test
Test the Public API
Test the public methods of your class. Don’t test private implementation details.
// GOOD — test public behavior
@Test
fun `createUser returns saved user`() {
val result = service.createUser("Alex", "alex@example.com")
assertEquals("Alex", result.name)
}
// BAD — testing internal implementation
@Test
fun `createUser calls repository save before notification`() {
// Too coupled to implementation
}
Test Boundaries
Focus tests on:
- Happy path: Normal input, expected output
- Edge cases: Empty strings, zero, null, max values
- Error cases: Invalid input, missing data, exceptions
- Boundary values: First/last, min/max, off-by-one
// Happy path
@Test fun `isValidAge accepts 25`() = assertTrue(isValidAge(25))
// Boundaries
@Test fun `isValidAge accepts 0`() = assertTrue(isValidAge(0))
@Test fun `isValidAge accepts 150`() = assertTrue(isValidAge(150))
@Test fun `isValidAge rejects -1`() = assertFalse(isValidAge(-1))
@Test fun `isValidAge rejects 151`() = assertFalse(isValidAge(151))
Test Coverage Guidelines
| Type | What to Test | Priority |
|---|---|---|
| Business logic | Calculations, validations, transformations | High |
| Services | Method behavior with mocked dependencies | High |
| Data mapping | Serialization, conversion | Medium |
| Integration | API endpoints, database queries | Medium |
| UI | Only critical user flows | Low |
Best Practices
1. Test Names Should Describe Behavior
// BAD
@Test fun test1() { ... }
@Test fun `test add`() { ... }
// GOOD
@Test fun `add returns sum of two numbers`() { ... }
@Test fun `divide by zero throws ArithmeticException`() { ... }
2. One Assert Per Test (When Possible)
// BAD — testing two things
@Test fun `calculator works`() {
assertEquals(5, calculator.add(2, 3))
assertEquals(7, calculator.subtract(10, 3))
}
// GOOD — separate tests
@Test fun `add returns sum`() {
assertEquals(5, calculator.add(2, 3))
}
@Test fun `subtract returns difference`() {
assertEquals(7, calculator.subtract(10, 3))
}
3. Arrange-Act-Assert Pattern
@Test
fun `getUser returns user from repository`() {
// Arrange
val user = User(1, "Alex", "alex@example.com")
every { repository.findById(1) } returns user
// Act
val result = service.getUser(1)
// Assert
assertEquals(user, result)
verify(exactly = 1) { repository.findById(1) }
}
4. Test Edge Cases
// Happy path
@Test fun `add returns sum`() { ... }
// Edge cases
@Test fun `add with negative numbers`() { ... }
@Test fun `add with zero`() { ... }
@Test fun `add with max int`() { ... }
// Error cases
@Test fun `divide by zero throws`() { ... }
@Test fun `factorial of negative throws`() { ... }
5. Use clearMocks in @BeforeEach
@BeforeEach
fun setUp() {
clearMocks(repository, notifications)
}
This ensures each test starts fresh with no leftover mock behavior.
6. Test Error Paths
@Test
fun `deactivateUser returns false for non-existent user`() {
every { repository.findById(999) } returns null
val result = service.deactivateUser(999)
assertFalse(result)
verify(exactly = 0) { repository.save(any()) }
}
Always test what happens when things go wrong.
Summary
| Concept | Description |
|---|---|
@Test | Marks a test method |
@BeforeEach | Setup before each test |
assertEquals | Check equality |
assertTrue/False | Check boolean |
assertThrows | Check exception |
@ParameterizedTest | Run with multiple inputs |
@ValueSource | Provide single values |
@CsvSource | Provide multiple values per test |
mockk() | Create a mock object |
every { } returns | Define mock behavior |
verify { } | Check mock was called |
coEvery/coVerify | Mock suspend functions |
runTest | Run coroutine tests |
slot/capture | Capture arguments |
verifyOrder | Check call order |
@Nested | Group related tests |
Source Code
You can find the source code for this tutorial on GitHub: tutorial-25-testing
What’s Next?
Congratulations! You have completed the advanced section of the Kotlin Tutorial series. You now know coroutines, Flow, inline functions, DSLs, serialization, Ktor, and testing. You have the skills to build real Kotlin applications.