How testing is set up across the project and what to test where. The project favors fast JVM unit tests (Robolectric where Android APIs are needed) over instrumented tests — there are ~155 unit tests and only a handful of instrumented ones. For the Compose-UI-specific patterns, see the companion Compose UI Testing Guide.
graph TD
Unit["Unit tests (src/test, JVM + Robolectric)<br/>ViewModels, coordinators, mappers, DAOs"]
Fakes["Fakes of injected interfaces<br/>(controllers, coordinators, providers)"]
Utils[":libs:test-utils<br/>TestDispatchers, MainCoroutineRule"]
Turbine["Turbine — Flow/StateFlow assertions"]
Compose["Compose UI tests<br/>(see compose-ui-testing.md)"]
Unit --> Fakes
Unit --> Utils
Unit --> Turbine
Compose --> Fakes
| Test what | How | Lives in |
|---|---|---|
| ViewModel logic (reducer + event side effects) | Plain JUnit; inject fakes + TestDispatchers; assert on stateFlow with Turbine |
the feature module's src/test |
| Coordinator sync/cache logic | Fake the wrapped Controller + persistence; drive session/lifecycle callbacks | the shared module's src/test |
| DAO / Room queries & migrations | Robolectric + in-memory Room, runTest |
shared/persistence/*/src/test |
| Mappers / pure logic (currency, crypto, encoding) | Plain JUnit, no Android | the relevant :libs:* module |
| Compose UI | Compose test rule + fakes | see Compose UI Testing Guide |
flipcash.android.library (and therefore every module —
01) configures unit tests automatically
(AndroidLibraryConventionPlugin.kt):
testOptions { unitTests.isReturnDefaultValues = true; unitTests.isIncludeAndroidResources = true }kotlin-test-juniton the test classpath- A
robolectric.propertiespinning the Robolectric SDK to 36 :libs:test-utilsavailable to tests (except for itself)
So a module can write Robolectric-backed unit tests with zero extra setup.
Two helpers make coroutine code deterministic:
TestDispatchers — a DispatcherProvider whose IO/Main/Default all map to
a single StandardTestDispatcher, so injected dispatchers are controllable in tests:
class TestDispatchers(scheduler: TestCoroutineScheduler) : DispatcherProvider {
val dispatcher = StandardTestDispatcher(scheduler)
override val IO = dispatcher
override val Main = dispatcher
override val Default = dispatcher
}MainCoroutineRule — a JUnit TestWatcher that Dispatchers.setMain(...)/
resetMain() around each test so viewModelScope uses the test dispatcher.
Because production code injects DispatcherProvider rather than referencing
Dispatchers.* (08), passing TestDispatchers
makes a ViewModel or coordinator fully deterministic.
The codebase is built on interfaces (controllers, coordinators, providers exposed
behind interfaces and delivered via Hilt / Local*). Tests substitute hand-written
fakes for those interfaces rather than using a mocking framework. This is the
dominant pattern — the Compose UI Testing Guide
documents it in detail and it applies equally to ViewModel/coordinator unit tests.
State is exposed as StateFlow/Flow, so tests assert on emissions with
Turbine (app.cash.turbine.test) inside runTest:
@Test
fun `loads on appear`() = runTest {
val vm = WidgetViewModel(fakeCoordinator, TestDispatchers(testScheduler))
vm.stateFlow.test {
assertEquals(false, awaitItem().loading) // initial
vm.dispatchEvent(Widget.Event.OnAppear)
// advance/await subsequent states...
}
}DAO tests run on Robolectric against an in-memory database and use runTest
(e.g. ContactDaoTest,
apps/flipcash/shared/persistence/db/src/test/.../dao/ContactDaoTest.kt):
@RunWith(RobolectricTestRunner::class)
class ContactDaoTest {
@Before fun setup() { /* Room.inMemoryDatabaseBuilder(...) */ }
@Test fun `upsertSyncState and getSyncState roundtrip`() = runTest { /* ... */ }
}./gradlew test # all modules
./gradlew :apps:flipcash:features:cash:test # one module (fast loop)
./gradlew :apps:flipcash:shared:router:test
bundle exec fastlane android flipcash_tests # what CI runsSee 10 — Build & run for the wider command set.
Session delegates (02 — Delegate composition)
emit cross-delegate events via Channel (exposed as Flow). Tests assert on these events using
Turbine:
@Test
fun `emits BillReady on successful grab`() = runTest {
val delegate = CodeScanDelegate(
stateHolder = SessionStateHolder(),
billController = mockk(relaxed = true),
// ... other mocked deps, DispatcherProvider with UnconfinedTestDispatcher
)
// set up mock captures for onGrabbed callback ...
delegate.events.test {
// trigger the callback
onGrabbedSlot.captured.invoke(token, amount, null)
val event = awaitItem()
assertIs<CodeScanDelegate.Event.BillReady>(event)
}
}For suspend lambda callbacks that MockK can't capture directly (a known Kotlin 2.x
compatibility issue), use the answers { args[N] as suspend ... } pattern instead
of capture(slot).
- Inject, don't reach. A ViewModel/coordinator that takes its dependencies
(incl.
DispatcherProvider) as constructor params is trivially testable; one that reaches for singletons orDispatchers.*is not. - Test the reducer and the effects. Assert state transitions for events, and
assert the side effects emitted on
eventFlow. - Prefer unit + Robolectric over instrumented. Reserve
connectedAndroidTestfor things that genuinely need a device. - Fakes over mocks. Write a small fake implementing the interface; it's clearer and survives refactors better than mock setups.