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:

  • @Test marks a test method
  • @BeforeEach runs before each test (setup)
  • Backtick names like \add returns sum`` make tests readable
  • assertEquals(expected, actual) checks equality

Lifecycle Annotations

AnnotationRuns
@BeforeEachBefore each test
@AfterEachAfter each test
@BeforeAllOnce before all tests (companion object)
@AfterAllOnce 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

RegularCoroutine
everycoEvery
verifycoVerify
answerscoAnswers

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

TypeWhat to TestPriority
Business logicCalculations, validations, transformationsHigh
ServicesMethod behavior with mocked dependenciesHigh
Data mappingSerialization, conversionMedium
IntegrationAPI endpoints, database queriesMedium
UIOnly critical user flowsLow

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

ConceptDescription
@TestMarks a test method
@BeforeEachSetup before each test
assertEqualsCheck equality
assertTrue/FalseCheck boolean
assertThrowsCheck exception
@ParameterizedTestRun with multiple inputs
@ValueSourceProvide single values
@CsvSourceProvide multiple values per test
mockk()Create a mock object
every { } returnsDefine mock behavior
verify { }Check mock was called
coEvery/coVerifyMock suspend functions
runTestRun coroutine tests
slot/captureCapture arguments
verifyOrderCheck call order
@NestedGroup 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.