Lesson 39 of 83 intermediate

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

Unit testing a use case is like fact-checking a journalist's article by interviewing the source directly — you bypass the whole publication system (database, network, UI) and go straight to the logic. MockK is your interview script: you tell the 'source' (repository) exactly what to say, then check whether the journalist (use case) reported it correctly.

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

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. 1. mockk() creates a mock — a fake object that records what you call on it and returns whatever you tell it to.
  2. 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. 3. coEvery { mockRepo.getStudent('s1') } returns Student(...) stubs the suspend function — without the 'co' prefix MockK would throw an error for suspend functions.
  4. 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. 5. result.isSuccess and result.getOrThrow() test the Result wrapper — always check both the success flag AND the actual value.
  6. 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. 7. The IOException propagation test simulates a network failure — asserting that the use case correctly converts repository exceptions into Result.Failure without crashing.
  8. 8. viewModel.scoreFlow.test { } from Turbine opens a collector for the Flow — all emissions are queued and accessible via awaitItem().
  9. 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. 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?
Look at test names, what is being asserted vs what should be asserted, missing runTest wrapper, and the insufficient funds test assertions.
Show answer
Bug 1: The test method name 'testTransfer' is too generic and provides no information when it fails in CI. Rename it to 'transfer succeeds and returns success result when balance is sufficient' — this immediately tells you what broke without reading the test body. Bug 2: The happy path test only uses coVerify to check internal method calls — it never asserts the RETURN VALUE of useCase.transfer(). If transfer() returns a Result<Unit> or a TransferReceipt, the test should assert that Result.isSuccess is true. The current test would pass even if the use case returned a failure Result, as long as it called the repository methods. Bug 3: 'testInsufficientFunds' suffers from the same naming problem. Rename to 'transfer returns Result.Failure with InsufficientFundsException when source balance is less than amount'. Bug 4: assertThat(result).isNotNull() is an almost useless assertion — it only checks that transfer() returned something (not null), not that it returned the right thing. For an insufficient funds scenario, the test should assert: assertThat(result.isFailure).isTrue() AND assertThat(result.exceptionOrNull()).isInstanceOf(InsufficientFundsException::class.java). Currently a Result.Success would pass this assertion, meaning the test would pass even if the use case incorrectly approved the transfer. Bug 5: The testNegativeAmount test is missing the runTest { } wrapper — transfer() is a suspend function and cannot be called from a regular non-coroutine context. This test will likely fail to compile or throw an IllegalStateException at runtime. Wrap the body in runTest { }.

Explain like I'm 5

Imagine you are testing whether a recipe works. You do not go to the grocery store (the real database). Instead you use plastic toy ingredients (mocks) that always give you exactly what you tell them to. Then you follow the recipe (the use case), and check if the dish came out right (assert the result). runTest is like a magic kitchen where time moves super fast so you do not have to wait for the oven.

Fun fact

MockK was created because Mockito — the dominant Java mocking library — has poor support for Kotlin features: it cannot mock final classes by default, cannot mock suspend functions, and produces confusing errors with Kotlin's null-safety. MockK was designed from the ground up for Kotlin and handles all these cases natively. It is now the most widely used mocking library in Android Kotlin projects.

Hands-on challenge

Write a complete unit test class for a TransferFundsUseCase from a fintech app. The use case: takes sourceAccountId, destinationAccountId, and amount; validates amount > 0 and account IDs are not blank; fetches both accounts from AccountRepository; checks source balance >= amount; calls repo.transfer(). Test: happy path, insufficient balance, invalid amount (<=0), blank account IDs, repository throws NetworkException. Use MockK and runTest.

More resources

Open interactive version (quiz + challenge) ← Back to course: Android Interview Mastery