Skip to content

Kotlin Testing — Unit and Integration Testing Guide

DodaTech Updated 2026-06-28 7 min read

In this tutorial, you will learn about Kotlin Testing. We cover key concepts, practical examples, and best practices to help you master this topic.

Kotlin testing uses kotlin.test assertions, JUnit 5 test runners, MockK for mocking dependencies, kotlinx-coroutines-test for suspend function testing, and framework-specific test tools for Android and Ktor applications.

What You'll Learn

  • Write unit tests with kotlin.test and JUnit 5
  • Mock dependencies with MockK
  • Test coroutines and flows
  • Test ViewModels and repositories
  • Write Android UI tests with Compose
  • Test Ktor applications with test host
  • Measure code coverage

Why It Matters

Testing ensures your code works correctly and continues to work as it evolves. Kotlin's testing ecosystem integrates seamlessly with JUnit 5, provides idiomatic mocking with MockK, and supports coroutine testing with structured concurrency. Well-tested code reduces bugs, enables Refactoring, and documents expected behavior.

Real-World Use

DodaTech requires unit tests for all ViewModel logic, integration tests for Room DAOs, and UI tests for critical user flows. The CI pipeline runs tests on every Pull Request. Coroutine tests use StandardTestDispatcher to control time in test environments.

Learning Path

flowchart LR
  A[Ktor] --> B[Testing\nYou are here]
  B --> C[Dependency Injection]
  style B fill:#f90,color:#fff

Adding Test Dependencies

// build.gradle.kts
dependencies {
    testImplementation(kotlin("test"))
    testImplementation("org.junit.jupiter:junit-jupiter:5.10.3")
    testImplementation("io.mockk:mockk:1.13.12")
    testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1")
    
    // Android-specific
    testImplementation("androidx.arch.core:core-testing:2.2.0")
    testImplementation("app.cash.turbine:turbine:1.1.0")
    
    // Ktor-specific
    testImplementation("io.ktor:ktor-server-test-host:2.3.12")
}

Basic Unit Test

import org.junit.jupiter.api.Test
import kotlin.test.*

class CalculatorTest {
    private val calculator = Calculator()
    
    @Test
    fun testAddition() {
        assertEquals(5, calculator.add(2, 3))
    }
    
    @Test
    fun testSubtraction() {
        assertEquals(1, calculator.subtract(3, 2))
    }
    
    @Test
    fun testDivisionByZero() {
        assertFailsWith<IllegalArgumentException> {
            calculator.divide(10, 0)
        }
    }
    
    @Test
    fun testMultipleAssertions() {
        assertAll(
            { assertEquals(4, calculator.add(2, 2)) },
            { assertEquals(0, calculator.subtract(2, 2)) },
            { assertTrue(calculator.isPositive(5)) },
            { assertFalse(calculator.isPositive(-1)) }
        )
    }
}

class Calculator {
    fun add(a: Int, b: Int) = a + b
    fun subtract(a: Int, b: Int) = a - b
    fun divide(a: Int, b: Int): Int {
        require(b != 0) { "Division by zero" }
        return a / b
    }
    fun isPositive(n: Int) = n > 0
}

Output: Tests run via ./gradlew test or the IDE test runner. All tests pass or fail with clear error messages.

Mocking with MockK

MockK is the idiomatic mocking library for Kotlin.

import io.mockk.*
import org.junit.jupiter.api.Test
import kotlin.test.*

interface UserRepository {
    fun getUser(id: Long): User?
    fun saveUser(user: User)
    fun searchUsers(query: String): List<User>
}

class UserServiceTest {
    @Test
    fun testGetUser() {
        // Create mock
        val mockRepo = mockk<UserRepository>()
        
        // Define behavior
        every { mockRepo.getUser(1) } returns User(1, "Alice", "alice@test.com")
        every { mockRepo.getUser(any()) } returns null
        
        val service = UserService(mockRepo)
        
        // Test
        val user = service.getUserDisplayName(1)
        assertEquals("Alice", user)
        
        val unknown = service.getUserDisplayName(999)
        assertEquals("Unknown", unknown)
        
        // Verify interaction
        verify(exactly = 2) { mockRepo.getUser(any()) }
    }
    
    @Test
    fun testSaveUserWithVerification() {
        val mockRepo = mockk<UserRepository>(relaxed = true)
        val service = UserService(mockRepo)
        
        service.registerUser("Bob", "bob@test.com")
        
        // Verify with matchers
        verify {
            mockRepo.saveUser(match {
                it.name == "Bob" && it.email == "bob@test.com"
            })
        }
    }
    
    @Test
    fun testCapturingArguments() {
        val mockRepo = mockk<UserRepository>(relaxed = true)
        val slot = slot<User>()
        
        every { mockRepo.saveUser(capture(slot)) } answers { }
        
        val service = UserService(mockRepo)
        service.registerUser("Charlie", "charlie@test.com")
        
        assertEquals("Charlie", slot.captured.name)
    }
}

data class User(val id: Long, val name: String, val email: String)

class UserService(private val repo: UserRepository) {
    fun getUserDisplayName(id: Long): String {
        return repo.getUser(id)?.name ?: "Unknown"
    }
    
    fun registerUser(name: String, email: String) {
        repo.saveUser(User(0, name, email))
    }
}

Output: MockK provides clear error messages when expected calls do not happen or happen with unexpected arguments.

Coroutine Testing

Test suspend functions with kotlinx-coroutines-test.

import kotlinx.coroutines.*
import kotlinx.coroutines.test.*
import org.junit.jupiter.api.Test
import kotlin.test.*

class CoroutineViewModelTest {
    private val testDispatcher = StandardTestDispatcher()
    
    @Test
    fun testStateFlowUpdates() = runTest {
        val viewModel = MyViewModel()
        
        // Launch coroutine in test scope
        backgroundScope.launch {
            viewModel.loadData()
        }
        
        // Advance time to let coroutines complete
        testScheduler.advanceUntilIdle()
        
        // Assert on final state
        assertEquals("Data loaded", viewModel.state.value)
    }
    
    @Test
    fun testTimeout() = runTest {
        // This test completes instantly due to virtual time
        val result = withTimeout(1000) {
            delay(500)
            "Completed"
        }
        assertEquals("Completed", result)
    }
}

class MyViewModel {
    private val _state = MutableStateFlow("Loading")
    val state: StateFlow<String> = _state.asStateFlow()
    
    suspend fun loadData() {
        delay(200)
        _state.value = "Data loaded"
    }
}

Output: Coroutine tests complete instantly regardless of delays because test dispatchers use virtual time.

Flow Testing

Use Turbine for flow testing.

class FlowTest {
    @Test
    fun testStateFlow() = runTest {
        val flow = MutableStateFlow(0)
        
        flow.test {
            assertEquals(0, awaitItem())
            flow.value = 1
            assertEquals(1, awaitItem())
            flow.value = 2
            assertEquals(2, awaitItem())
        }
    }
    
    @Test
    fun testFlowOperators() = runTest {
        val flow = (1..5).asFlow()
            .map { it * 2 }
            .filter { it > 5 }
        
        flow.test {
            assertEquals(6, awaitItem())
            assertEquals(8, awaitItem())
            assertEquals(10, awaitItem())
            awaitComplete()
        }
    }
}

ViewModel Testing

class UserViewModelTest {
    private val testDispatcher = StandardTestDispatcher()
    
    @Before
    fun setup() {
        Dispatchers.setMain(testDispatcher)
    }
    
    @After
    fun teardown() {
        Dispatchers.resetMain()
    }
    
    @Test
    fun testLoadUsersSuccess() = runTest {
        val mockRepo = mockk<UserRepository>()
        every { mockRepo.getUsers() } returns listOf(User(1, "Alice", "a@test.com"))
        
        val viewModel = UserViewModel(mockRepo)
        viewModel.loadUsers()
        
        testScheduler.advanceUntilIdle()
        
        val state = viewModel.users.value
        assertTrue(state is ApiResult.Success)
        assertEquals(1, (state as ApiResult.Success).data.size)
    }
    
    @Test
    fun testLoadUsersError() = runTest {
        val mockRepo = mockk<UserRepository>()
        every { mockRepo.getUsers() } throws RuntimeException("Network error")
        
        val viewModel = UserViewModel(mockRepo)
        viewModel.loadUsers()
        
        testScheduler.advanceUntilIdle()
        
        val state = viewModel.users.value
        assertTrue(state is ApiResult.Error)
    }
}

Android Compose UI Testing

import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createComposeRule

class ComposeUITest {
    @get:Rule
    val composeTestRule = createComposeRule()
    
    @Test
    fun testCounterIncrement() {
        composeTestRule.setContent {
            Counter()
        }
        
        // Initial state
        composeTestRule
            .onNodeWithText("Count: 0")
            .assertExists()
        
        // Click increment
        composeTestRule
            .onNodeWithText("Increment")
            .performClick()
        
        // State updated
        composeTestRule
            .onNodeWithText("Count: 1")
            .assertExists()
    }
}

Ktor Test Host

class KtorApiTest {
    @Test
    fun testCreateUser() = testApplication {
        application {
            module()
        }
        
        val response = client.post("/api/users") {
            contentType(ContentType.Application.Json)
            setBody("""{"name":"Test","email":"test@test.com"}""")
        }
        
        assertEquals(HttpStatusCode.Created, response.status)
        
        val body = response.bodyAsText()
        assertTrue(body.contains("Test"))
    }
}

Code Coverage

Use JaCoCo or Kover for code coverage.

// build.gradle.kts
plugins {
    id("org.jetbrains.kotlinx.kover") version "0.8.1"
}

tasks.koverHtmlReport {
    // Generates HTML report in build/reports/kover/html
}

Common Mistakes

  1. Not using test dispatchers: Real dispatchers in tests cause flaky timing-dependent tests. Always inject TestDispatcher or use Dispatchers.setMain().

  2. Testing implementation details instead of behavior: Test what the code does, not how it does it. Focus on public API and observable state.

  3. Not cleaning up mocks: MockK verifies all calls when the mock is garbage collected. Use clearMocks() in @After or AutoCloseable mocks.

  4. Using Thread.sleep() in tests: sleep() makes tests slow and flaky. Use runTest with advanceTimeBy() or advanceUntilIdle().

  5. Not testing error cases: Happy-path tests are not enough. Test network errors, database failures, invalid input, and edge cases.

  6. Ignoring test readability: Use descriptive test names, AAA pattern (Arrange-Act-Assert), and meaningful assertions.

Practice Questions

  1. What is the purpose of MockK's relaxed mock?

Answer: A relaxed mock returns default values for unstubbed methods, preventing null pointer exceptions when you do not need to stub every method.

  1. How do you test a suspend function?

Answer: Use runTest from kotlinx-coroutines-test. It creates a test scope with virtual time where delays complete instantly.

  1. What is the difference between verify and verifySequence in MockK?

Answer: verify checks that calls happened at least once in any order. verifySequence checks that calls happened in a specific order.

  1. How do you test a Flow?

Answer: Use Turbine's test extension or toList() inside runTest. Turbine provides awaitItem(), awaitComplete(), and awaitError().

  1. Challenge: Write a parameterized test for a validation function that tests multiple input combinations. Use JUnit 5's @CsvSource or @MethodSource.

Answer:

class EmailValidationTest {
    private val validator = Validator()
    
    @ParameterizedTest
    @CsvSource(
        "user@example.com, true",
        "invalid-email, false",
        "user@, false",
        "@domain.com, false",
        "user@.com, false",
        "user.name+tag@example.co.uk, true"
    )
    fun testEmailValidation(email: String, expected: Boolean) {
        assertEquals(expected, validator.isValidEmail(email))
    }
}

Mini Project

Add comprehensive tests to a todo list ViewModel. Requirements:

  • Unit tests for all ViewModel functions (add, complete, delete, filter)
  • MockK for Repository dependency
  • Coroutine test dispatcher
  • Flow testing for state updates
  • Error case tests (network failure, empty list, invalid input)
  • Parameterized tests for validation
  • Code coverage report with Kover

This project consolidates all testing patterns in a practical scenario.

FAQ

What is the difference between JUnit 5 and kotlin.test?

kotlin.test provides Kotlin-idiomatic assertion functions. JUnit 5 provides the test runner and lifecycle annotations. Use both together.

How do I test Android Context-dependent code?

Use Robolectric for JVM tests that simulate Android environment, or inject the Context as a dependency and mock it.

What is the best way to test ViewModels?

Create the ViewModel with mocked dependencies, call its methods, and observe StateFlow/LiveData for expected state changes.

How do I test file or database operations?

Use temporary directories (createTempDir()) for file tests, and in-memory databases for Room tests (.inMemoryDatabaseBuilder()).

Should I test private methods?

No. Test through public APIs. If a private method is complex enough to need direct testing, extract it to a separate class.

What's Next

After mastering testing, learn dependency injection with Hilt or Koin. You can also explore coroutines for deeper understanding of async patterns.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro