Kotlin Testing — Unit and Integration Testing Guide
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
Not using test dispatchers: Real dispatchers in tests cause flaky timing-dependent tests. Always inject TestDispatcher or use
Dispatchers.setMain().Testing implementation details instead of behavior: Test what the code does, not how it does it. Focus on public API and observable state.
Not cleaning up mocks: MockK verifies all calls when the mock is garbage collected. Use
clearMocks()in @After orAutoCloseablemocks.Using Thread.sleep() in tests: sleep() makes tests slow and flaky. Use
runTestwithadvanceTimeBy()oradvanceUntilIdle().Not testing error cases: Happy-path tests are not enough. Test network errors, database failures, invalid input, and edge cases.
Ignoring test readability: Use descriptive test names, AAA pattern (Arrange-Act-Assert), and meaningful assertions.
Practice Questions
- 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.
- 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.
- What is the difference between
verifyandverifySequencein MockK?
Answer: verify checks that calls happened at least once in any order. verifySequence checks that calls happened in a specific order.
- How do you test a Flow?
Answer: Use Turbine's test extension or toList() inside runTest. Turbine provides awaitItem(), awaitComplete(), and awaitError().
- 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'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