Testing
Testing Kotlin code — the JUnit and Kotest frameworks, runTest for suspending code, MockK against final classes and extension functions, fakes versus mocks, Turbine for Flow emissions, Compose snapshot tests, instrumented versus local JVM tests, KMP commonTest, and property-based testing.
11 questions
JuniorTheoryVery commonHow do local JVM tests in src/test differ from instrumented tests in src/androidTest?
How do local JVM tests in src/test differ from instrumented tests in src/androidTest?
src/test runs on your JVM: fast, device-free, but the Android framework is stubbed, so its calls throw unless mocked or run under Robolectric. src/androidTest runs on a real device — slow, yet the only place UI and Context are real.
Common mistakes
- ✗Calling a framework API from
src/testand being surprised by a not-mocked stub error instead of real behaviour - ✗Putting the whole suite into
src/androidTestfor realism and paying an emulator boot for every pure-logic test - ✗Assuming Robolectric on the JVM is identical to a real device rather than a simulation of the framework
Follow-up questions
- →What does Robolectric change about a
src/testrun, and where does its realism end? - →Which behaviour of Room would you refuse to trust outside
src/androidTest?
JuniorTheoryVery commonWhy is a suspending test driven by runTest rather than by runBlocking?
Why is a suspending test driven by runTest rather than by runBlocking?
runTest drives the test body on a virtual clock that auto-advances: a delay(10_000) completes at once, so the test finishes in milliseconds. runBlocking uses the real clock and would genuinely block the thread for ten seconds.
Common mistakes
- ✗Wrapping a suspending test in
runBlockingand then shortening the productiondelayjust to keep the suite fast - ✗Believing
runTestskips time by running the coroutines in parallel rather than by advancing a virtual clock - ✗Expecting a real-clock
Thread.sleepinside the test body to be skipped the waydelayis
Follow-up questions
- →When would you turn auto-advance off and step the virtual clock by hand instead?
- →What does
runTestdo at the end of the body if a coroutine you launched is still alive?
JuniorTheoryCommonFor a repository under test, how does a fake differ from a mock?
For a repository under test, how does a fake differ from a mock?
A fake is a lightweight working implementation — an in-memory repository — and you assert on its final state. A mock is a generated stand-in you configure and assert interactions on. Fakes survive refactors; mocks prove a call happened.
Common mistakes
- ✗Stubbing every repository method with a mock and then asserting on the returned data, which only re-tests the stub
- ✗Thinking a fake must be generated by a library rather than hand-written as a small in-memory implementation
- ✗Verifying interactions on a repository double where asserting the resulting state would be the stable check
Follow-up questions
- →What breaks in a mock-heavy test suite when you rename a repository method?
- →When is asserting an interaction the only honest check you can write?
JuniorTheoryCommonWhat changed from JUnit 4 to JUnit 5, and where does the Kotlin-native framework Kotest fit?
What changed from JUnit 4 to JUnit 5, and where does the Kotlin-native framework Kotest fit?
JUnit 5 is a rewrite: @Test moved packages, @BeforeEach/@AfterEach replace @Before/@After, extensions replace runners and rules, parameterized tests are built in. Kotest is a Kotlin-native framework with Given-When-Then specs.
Common mistakes
- ✗Mixing JUnit 4's
@Testimport with JUnit 5's@BeforeEachin one class, so setup silently never runs - ✗Expecting a JUnit 4
@Ruleto still fire on the JUnit 5 engine instead of porting it to an extension - ✗Treating
Kotestas just an assertion library and missing that it brings its own spec styles
Follow-up questions
- →Which JUnit 5 extension point replaces a JUnit 4
@Rule, and what does it hook into? - →What does a Given-When-Then spec style in
Kotestgive you over a flat list of@Testmethods?
MiddleDebuggingCommonA ViewModel test fails with Module with the Main dispatcher had failed to initialize
A ViewModel test fails with Module with the Main dispatcher had failed to initialize
viewModelScope runs on Dispatchers.Main, backed by the Android main looper, which does not exist on a local JVM. Install a test dispatcher: Dispatchers.setMain(StandardTestDispatcher()) in @Before and resetMain() in @After.
Common mistakes
- ✗Blaming the fake repository or
runTestfor the crash instead of the missing main looper behindDispatchers.Main - ✗Calling
Dispatchers.setMainbut neverresetMain, so the dispatcher leaks into the next test class - ✗Moving a pure-logic ViewModel test to
src/androidTestjust to get a realDispatchers.Main
Follow-up questions
- →What does
StandardTestDispatcherdo differently fromUnconfinedTestDispatcherhere? - →Why is
Dispatchers.resetMain()in@Afternot optional when the suite has many classes?
MiddleTheoryCommonWhy does Kotlin have its own mocking library MockK next to the Java one Mockito?
Why does Kotlin have its own mocking library MockK next to the Java one Mockito?
Kotlin classes are final by default, and Mockito cannot mock them without its inline mock maker. MockK is Kotlin-native: it mocks final classes, extension functions and top-level functions, and adds coEvery/coVerify for suspend.
Common mistakes
- ✗Assuming
Mockitomocks a Kotlin class without opting into the inline mock maker, then blaming Kotlin for the failure - ✗Marking production classes
openpurely so the test can mock them, instead of picking a Kotlin-aware library - ✗Stubbing a
suspendfunction witheveryinstead ofcoEveryand getting a coroutine-context error
Follow-up questions
- →How does the mocking library
MockKintercept a top-level or extension function, which has no instance to proxy? - →What does
coEverydo thateverycannot, when the stubbed function issuspend?
MiddleCodeCommonAssert a Flow's emission sequence with the Flow-testing library Turbine
Assert a Flow's emission sequence with the Flow-testing library Turbine
Collect the flow in Turbine's test { } block and pull items in order: flow.test { assertEquals(UiState.Loading, awaitItem()); assertEquals(UiState.Done, awaitItem()); awaitComplete() }. awaitComplete() proves nothing extra arrived.
Common mistakes
- ✗Asserting only the items and never closing the block, so an extra emission after
Donepasses unnoticed - ✗Reaching for
first()ortoList()on an infinite flow, which hangs instead of failing the test - ✗Treating
awaitItem()as a peek at the latest value rather than as pulling the next emission in order
Follow-up questions
- →How would the test change if the flow never completes, as a
StateFlowdoes? - →What does the Flow-testing library
Turbinereport when the flow emits one more item than you awaited?
MiddleTheoryOccasionalWhat does Compose snapshot (screenshot) testing catch, and what does it cost you?
What does Compose snapshot (screenshot) testing catch, and what does it cost you?
It renders a composable and compares its image with a stored golden; any pixel diff fails the test, catching visual regressions assertions miss. Goldens must be regenerated on each intended change and depend on device, font and density.
Common mistakes
- ✗Regenerating the goldens on every red test, which turns the suite into a rubber stamp that approves regressions
- ✗Running the goldens on a different device, font scale or density than the one they were captured on
- ✗Expecting semantics or click assertions to catch a purely visual regression such as a colour or spacing change
Follow-up questions
- →How do you keep goldens stable across machines when only the CI image is trusted?
- →Which visual changes deserve a golden, and which are better asserted on semantics?
MiddleTheoryOccasionalWhat does a test in the Kotlin Multiplatform commonTest source set actually run on?
What does a test in the Kotlin Multiplatform commonTest source set actually run on?
commonTest uses the kotlin.test API, so one suite is compiled and run for every declared target — and on JVM, JUnit runs it underneath. What cannot be expressed commonly goes to a platform source set like jvmTest or iosTest.
Common mistakes
- ✗Importing a JVM-only assertion library into
commonTest, which then fails to compile for the native targets - ✗Believing a green
commonTestproves the behaviour on iOS when only the JVM target was actually run - ✗Pushing platform-specific behaviour into
commonTestinstead of the platform source set that owns it
Follow-up questions
- →How would you assert a behaviour that only exists on iOS while keeping the shared suite common?
- →Which
kotlin.testassertion maps onto a JUnit assertion, and where does that mapping stop?
MiddleTheoryOccasionalWhat does property-based testing with the Kotlin framework Kotest add over example-based tests?
What does property-based testing with the Kotlin framework Kotest add over example-based tests?
You state an invariant — decode(encode(x)) returns x — and checkAll checks it on hundreds of generated inputs, not a few examples. It shrinks a failure to the smallest breaking counterexample. Example tests still pin business cases.
Common mistakes
- ✗Asserting a concrete expected value inside
checkAllinstead of an invariant that must hold for every input - ✗Reading a passing property as a proof of correctness rather than as evidence over the inputs that were generated
- ✗Dropping the example-based tests once a property exists, losing the specific business cases they pinned
Follow-up questions
- →Why does shrinking a counterexample matter more than the raw failing input the generator found?
- →Which invariants of your own code would you write as a property, and which as an example?
SeniorCodeOccasionalTest a ViewModel whose uiState: StateFlow is built by combine-ing several repository Flows
Test a ViewModel whose uiState: StateFlow is built by combine-ing several repository Flows
Swap Dispatchers.Main for StandardTestDispatcher() via setMain/resetMain, drive it with runTest, and feed the combine from fake repositories. Collect uiState with Turbine before emitting, then assert each awaitItem().
Common mistakes
- ✗Reading
uiState.valueafter the fakes emit and seeing only the last state, because aStateFlowconflates - ✗Leaving the real
Dispatchers.Mainin place, soviewModelScopecannot even start on a local JVM - ✗Expecting
combineto emit before every input flow has produced its first value
Follow-up questions
- →Why does a conflating
StateFlowhide states from a test that only readsuiState.valueat the end? - →How would the assertions change if one of the combined repository flows never emits at all?