Unit Testing Kotlin: Classes, Use Cases & Repositories
Write fast, reliable JVM unit tests for your Kotlin business logic with JUnit5 and MockK
Open interactive version (quiz + challenge)Real-world analogy
What is it?
Unit testing Kotlin means writing JVM-based tests for your business logic classes — use cases, repositories (via mocks), domain models, and utility classes — without any Android dependencies. JUnit5 provides the test runner and lifecycle hooks. MockK provides idiomatic Kotlin mocking for suspend functions and coroutines. kotlinx-coroutines-test's runTest handles suspend function testing. Turbine simplifies Flow testing. Together these tools let you verify your app's core logic in milliseconds.
Real-world relevance
In a school management app's SaaS backend-connected Android client, the CalculateGradeReportUseCase had complex logic: fetch raw scores from the repository, calculate weighted averages per subject, apply school-defined pass marks, generate a grade letter, and check if the student qualifies for honors. Unit tests for this use case ran in 8ms each, covered 14 scenarios (including missing scores, zero-weight subjects, and exact boundary grades), and caught 3 bugs during development — including one where honor roll qualification logic had an off-by-one error on the GPA threshold.
Key points
- JUnit5 setup for Android — Add junit-jupiter to testImplementation in build.gradle. Use @Test, @BeforeEach, @AfterEach, @Nested for organizing tests. JUnit5 requires the android-junit5 Gradle plugin for Android projects. Backtick function names (fun `returns error when network fails`()) create self-documenting test names.
- MockK basics — mockk() — val repo = mockk() creates a mock. every { repo.getStudent('1') } returns Student(...) stubs a return value. coEvery { repo.getStudent('1') } returns Student(...) is for suspend functions. MockK is Kotlin-native — it handles suspend functions, object mocks, and companion object mocking natively.
- Verifying calls with verify and coVerify — verify { repo.saveStudent(any()) } asserts that saveStudent was called at least once. verify(exactly = 1) { ... } asserts exactly one call. verify(exactly = 0) { ... } asserts the method was NOT called. coVerify is the suspend-function equivalent. Over-use of verify leads to brittle tests — prefer asserting output over verifying internal calls.
- Argument captors with slot() — val slot = slot(); every { repo.save(capture(slot)) } returns Unit. After calling the use case, slot.captured gives you the exact argument that was passed. Useful when you need to assert on the exact data passed to a dependency — e.g., checking that a payment amount was correctly converted before saving.
- Testing pure Kotlin classes — Pure functions with no dependencies are the easiest to test — instantiate the class, call the method, assert the result. No mocks needed. Calculator, Validator, Formatter classes fall into this category. These should have the most thorough test coverage since they are cheapest to test.
- Testing use cases with mock repositories — A use case orchestrates business logic using injected repositories. In tests: mock the repository (coEvery), call the use case, assert the returned Result or emitted value. The use case should be tested for: happy path, repository throws exception, invalid input, edge cases (empty list, null optional).
- Testing suspend functions with runTest — kotlinx-coroutines-test provides runTest { } — a coroutine builder for tests. It runs the test in a TestCoroutineScope, advances virtual time, and properly handles exceptions from suspend functions. Replace every coroutineScope.launch { } in your test with runTest { }.
- Turbine for Flow testing — Turbine (by Cash App) makes testing Flow emissions simple. val item = flow.test { val item = awaitItem(); cancel() }. Use awaitItem() for each expected emission, awaitComplete() for finite flows, awaitError() for error flows. Without Turbine, testing Flows requires complex channel-based gymnastics.
- Testing error paths — just as important — Every use case should handle errors from the repository. Test that when the repo throws an IOException, the use case returns Result.Error (or the appropriate error state). Test that error messages are correctly propagated. Error path tests catch the bugs that crash your app in production.
- Asserting with Truth or kotlin.test — Google Truth: assertThat(result).isEqualTo(expected) — readable, extensible. Kotlin's built-in kotlin.test: assertEquals(expected, actual), assertNotNull(value). Avoid raw JUnit4 assertEquals where Truth reads more clearly. For collections: assertThat(list).containsExactly(...).inOrder().
- Test naming conventions — Method name format: 'given [context] when [action] then [expectation]' or 'action returns expected for valid input'. Backtick names in Kotlin allow natural language: fun `getStudent throws NotFoundException when student does not exist`(). Good names make test failures self-explanatory without reading the test body.
- Parameterized tests — @ParameterizedTest with @MethodSource or @CsvSource runs the same test logic with multiple input sets. Great for testing boundary conditions: test grade boundaries (59=FAIL, 60=PASS, 100=PASS) in one test. Reduces test count while increasing coverage.
Code example
// build.gradle.kts (app module)
dependencies {
testImplementation("org.junit.jupiter:junit-jupiter:5.10.0")
testImplementation("io.mockk:mockk:1.13.8")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3")
testImplementation("app.cash.turbine:turbine:1.0.0")
testImplementation("com.google.truth:truth:1.1.5")
}
// --- Domain classes under test ---
data class Student(val id: String, val name: String, val gradeLevel: Int)
data class GradeReport(val studentId: String, val average: Double, val passed: Boolean)
interface StudentRepository {
suspend fun getStudent(id: String): Student
suspend fun getScores(studentId: String): List<Double>
}
class GenerateGradeReportUseCase(private val repo: StudentRepository) {
suspend fun execute(studentId: String): Result<GradeReport> = runCatching {
val student = repo.getStudent(studentId)
val scores = repo.getScores(studentId)
if (scores.isEmpty()) throw IllegalStateException("No scores found for ${student.name}")
val average = scores.average()
GradeReport(studentId = student.id, average = average, passed = average >= 60.0)
}
}
// --- Unit Tests ---
class GenerateGradeReportUseCaseTest {
private val mockRepo = mockk<StudentRepository>()
private val useCase = GenerateGradeReportUseCase(mockRepo)
@BeforeEach
fun setUp() {
// Common stub — individual tests can override
coEvery { mockRepo.getStudent("s1") } returns Student("s1", "Alice", gradeLevel = 10)
}
@Test
fun `execute returns passing report for scores above 60`() = runTest {
coEvery { mockRepo.getScores("s1") } returns listOf(70.0, 80.0, 90.0)
val result = useCase.execute("s1")
assertThat(result.isSuccess).isTrue()
val report = result.getOrThrow()
assertThat(report.average).isEqualTo(80.0)
assertThat(report.passed).isTrue()
}
@Test
fun `execute returns failing report for average below 60`() = runTest {
coEvery { mockRepo.getScores("s1") } returns listOf(40.0, 50.0, 55.0)
val result = useCase.execute("s1")
assertThat(result.getOrThrow().passed).isFalse()
}
@Test
fun `execute returns failure when scores list is empty`() = runTest {
coEvery { mockRepo.getScores("s1") } returns emptyList()
val result = useCase.execute("s1")
assertThat(result.isFailure).isTrue()
assertThat(result.exceptionOrNull()).isInstanceOf(IllegalStateException::class.java)
}
@Test
fun `execute propagates repository exception as failure`() = runTest {
coEvery { mockRepo.getStudent("s1") } throws IOException("Network error")
val result = useCase.execute("s1")
assertThat(result.isFailure).isTrue()
}
}
// --- Turbine for Flow testing ---
class StudentScoreStreamTest {
private val fakeRepo = FakeStudentRepository()
private val viewModel = StudentScoreViewModel(fakeRepo)
@Test
fun `score flow emits updated value after new score added`() = runTest {
viewModel.scoreFlow.test {
assertThat(awaitItem()).isEmpty() // initial empty state
fakeRepo.emitScore(Score("s1", 85.0))
assertThat(awaitItem()).hasSize(1) // after emission
cancel()
}
}
}Line-by-line walkthrough
- 1. mockk() creates a mock — a fake object that records what you call on it and returns whatever you tell it to.
- 2. @BeforeEach fun setUp() runs before every single test method — use it to set up common stubs that most tests share, keeping individual tests short.
- 3. coEvery { mockRepo.getStudent('s1') } returns Student(...) stubs the suspend function — without the 'co' prefix MockK would throw an error for suspend functions.
- 4. runTest { } is the coroutine test builder — it creates a TestScope with virtual time control and properly propagates exceptions from suspend functions to the test framework.
- 5. result.isSuccess and result.getOrThrow() test the Result wrapper — always check both the success flag AND the actual value.
- 6. The empty scores test asserts result.isFailure is true AND checks the exception type — verifying that the right kind of failure occurred, not just any failure.
- 7. The IOException propagation test simulates a network failure — asserting that the use case correctly converts repository exceptions into Result.Failure without crashing.
- 8. viewModel.scoreFlow.test { } from Turbine opens a collector for the Flow — all emissions are queued and accessible via awaitItem().
- 9. awaitItem() suspends until the next emission arrives — if the Flow does not emit within the timeout (default 1s), the test fails with a clear timeout message.
- 10. cancel() at the end of a Turbine test { } block is required for infinite flows — it signals that you are done collecting and prevents the test from hanging.
Spot the bug
class TransferFundsUseCaseTest {
private val repo = mockk<AccountRepository>()
private val useCase = TransferFundsUseCase(repo)
// Bug 1
@Test
fun testTransfer() = runTest {
coEvery { repo.getAccount("acc1") } returns Account("acc1", balance = 500.0)
coEvery { repo.getAccount("acc2") } returns Account("acc2", balance = 100.0)
coEvery { repo.transfer(any(), any(), any()) } returns Unit
useCase.transfer("acc1", "acc2", amount = 200.0)
// Bug 2
coVerify { repo.getAccount("acc1") }
coVerify { repo.getAccount("acc2") }
coVerify { repo.transfer("acc1", "acc2", 200.0) }
}
// Bug 3
@Test
fun testInsufficientFunds() = runTest {
coEvery { repo.getAccount("acc1") } returns Account("acc1", balance = 50.0)
coEvery { repo.getAccount("acc2") } returns Account("acc2", balance = 0.0)
val result = useCase.transfer("acc1", "acc2", amount = 200.0)
// Bug 4
assertThat(result).isNotNull()
}
// Bug 5
@Test
fun testNegativeAmount() {
val result = useCase.transfer("acc1", "acc2", amount = -50.0)
assertThat(result.isFailure).isTrue()
}
}Need a hint?
Show answer
Explain like I'm 5
Fun fact
Hands-on challenge
More resources
- MockK Documentation (MockK)
- kotlinx-coroutines-test (Kotlin)
- Turbine by Cash App (Cash App)
- JUnit 5 User Guide (JUnit)
- Android Unit Testing Guide (Android Developers)