Husi Testing
Husi tests its Kotlin code from composeApp/src/commonTest/ against the desktop JVM target. There
is no Android instrumented test suite. Tests follow a strong inject seams + fake them style:
the existing suite uses mockk almost nowhere despite it being on the classpath — every reusable
test dependency is a hand-rolled Fake* registered through Koin or passed via constructor.
Before writing tests, answer two questions:
- Does the code under test touch Koin-resolved singletons (
Repository,HttpClientFactory,DataStore)? → extend one of the Koin-aware base classes below. - Does it use
viewModelScope/Dispatchers.IO/delay? → you need aStandardTestDispatcherandrunTest(dispatcher.scheduler) { ... advanceUntilIdle() }.
The answers determine the base class and the boilerplate. Don't roll your own.
Toolchain & where things live
| Item | Value |
|---|---|
| Test source set | composeApp/src/commonTest/kotlin/ (runs as desktopTest) |
| Desktop-only tests | composeApp/src/desktopTest/kotlin/ (rare; one file at present) |
| Test framework | kotlin.test (@Test, assertEquals, assertIs, …) on JUnit 5 |
| Coroutines | kotlinx-coroutines-test (runTest, StandardTestDispatcher) |
| Mocking | mockk exists but is avoided — prefer fakes (see below) |
| Run all tests | make test_gradle (= ./gradlew :composeApp:allTests) |
| Run one class | ./gradlew :composeApp:desktopTest --tests fr.husi.foo.BarTest |
| Test reports | composeApp/build/test-results/desktopTest/TEST-*.xml |
Test files mirror the package of the file under test:
fr.husi.ui.tools.SpeedTestScreenViewModel →
commonTest/kotlin/fr/husi/ui/tools/SpeedTestScreenViewModelTest.kt.
Function names use backticked sentences describing the behaviour, e.g.
`setServer with blank value sets urlError`. Match the existing style — see
MainViewModelTest.kt, ProfileEditorViewModelTest.kt, the fmt/ tests.
Base class cheat sheet
All three live in composeApp/src/commonTest/kotlin/fr/husi/test/.
| Base class | Sets up | Use when… |
|---|---|---|
| (nothing) | — | Pure logic — fmt/, ktx/, RuleItemTest. No coroutines, no Koin. |
MainDispatcherTest |
StandardTestDispatcher swapped into Dispatchers.Main. |
viewModelScope is involved but no Koin singletons are touched. |
HusiKoinTest |
+ initHusiKoin(FakeRepository()) / stopKoin(). |
Code resolves Repository (e.g. resolveRepository()), but no coroutine timing. |
HusiKoinMainDispatcherTest |
Both of the above. | Default for ViewModel tests. Resolves Koin singletons and uses viewModelScope. |
HusiHttpKoinTest |
+ HttpClientFactory overridden with FakeHttpClientFactory, DataStore.configurationStore.reset(). |
The code under test calls Libcore.newHttpClient() (directly or via the factory). |
The hooks preStartKoin / postStartKoin / preStopKoin / postStopKoin (suspend, all open) are the
extension points. HusiHttpKoinTest exposes its own postStartKoinWithHttp() because it already
finalises postStartKoin.
Why fakes, not mockk
The codebase uses Fake* classes that implement the production interfaces (FakeRepository,
FakeHttpClientFactory, FakeHTTPClient, FakeHTTPRequest, FakeHTTPResponse, FakeURL). They:
- compile-check against the real interface so renaming/refactoring stays sound,
- can be reused across many tests with no per-test setup boilerplate,
- read like a recorder — you assert on
lastClient?.lastRequest?.headers["Referer"]instead of decodingverify { … }blocks, - never require
mockkStaticagainst gobind classes, which is fragile on the desktop JVM.
If you find yourself reaching for mockk, first check whether a fake already exists, or whether
the right move is to extract an interface and add a fake to commonTest/kotlin/fr/husi/test/. Treat
each new fake as infrastructure for future tests, not just the one you're writing.
Refactoring for testability
When the code you want to test calls a global static (Libcore.xxx) or an internal Kotlin object
(DataStore), introduce a DI seam instead of working around it in the test:
- Interface in
commonMain(e.g.fr.husi.libcore.HttpClientFactory) wrapping the static. - Default object impl (e.g.
LibcoreHttpClientFactory) that just delegates. - Koin binding in
commonUiModule()(or a new module if domain-specific). - Constructor injection on the consumer, with the Koin singleton as the default.
- Fake in
commonTestimplementing the same interface; recording inputs, replaying canned outputs. Register it vialoadKoinModules(module { single<T> { fake } })in a base class.
Worked example: HttpClientFactory.kt + LibcoreHttpClientFactory + FakeHttpClientFactory +
HusiHttpKoinTest. DataStore itself is not abstracted — it's a Kotlin object backed by
DataStore-Preferences, and DataStore.configurationStore.reset() (in postStartKoin) is enough
for tests to start clean. Don't replicate that level of plumbing unless you need to.
Inject CoroutineDispatcher (default Dispatchers.IO) on ViewModels that use it explicitly —
otherwise advanceUntilIdle() can't drive the background work. Don't inject viewModelScope
itself; let StandardTestDispatcher + Dispatchers.setMain (handled by MainDispatcherTest)
control it.
Koin registration gotcha for ViewModels with default-valued params.
viewModelOf(::FooVM)uses reflection and tries to resolve every constructor parameter from Koin — it ignores Kotlin default values. If your ViewModel takesioDispatcher: CoroutineDispatcher = Dispatchers.IO(or any other type that isn't bound),viewModelOfblows up at runtime withNoDefinitionFoundException: No definition found for type 'kotlinx.coroutines.CoroutineDispatcher'. Use an explicit factory instead and only pass the things Koin actually has:viewModel { FooViewModel(httpClientFactory = get()) }Reserve
viewModelOf(...)for ViewModels whose constructor only takes Koin-bound types.
ViewModel test recipe
@OptIn(ExperimentalCoroutinesApi::class)
class FooViewModelTest : HusiKoinMainDispatcherTest() {
private fun newViewModel() = FooViewModel(
// Inject the things you faked. Defaults from constructor stay for production.
ioDispatcher = dispatcher,
)
@Test
fun `setX writes through to state`() = runTest(dispatcher.scheduler) {
val vm = newViewModel()
vm.setX("hello")
advanceUntilIdle()
assertEquals("hello", vm.uiState.value.x)
}
@Test
fun `doThing emits Snackbar event`() = runTest(dispatcher.scheduler) {
val vm = newViewModel()
val event = backgroundScope.async { vm.uiEvent.first() }
vm.doThing()
advanceUntilIdle()
assertIs<FooUiEvent.Snackbar>(event.await())
}
}
Key points:
- Use
runTest(dispatcher.scheduler)— not justrunTest {}. Without passing the scheduler, the test dispatcher andviewModelScopeend up driven by different clocks andadvanceUntilIdlebecomes a no-op. - Collect
SharedFlowevents withbackgroundScope.async { flow.first() }before triggering the emit._uiEventdefaults toreplay = 0; if you start collecting after the emit, you'll deadlock. - For
StateFlowyou can read.valuedirectly afteradvanceUntilIdle()— no collector needed. - Reset any global object state your test mutates (e.g.
DataStore.serviceState) in@AfterTest.HusiHttpKoinTestresetsconfigurationStore; it does not resetserviceStatebecause that's a@Volatile varoutside the preference store.
Testing HTTP / Libcore code
If the code calls Libcore.newHttpClient() directly, refactor it to use HttpClientFactory
first (see "Refactoring for testability"). Then in the test:
class BarUpdaterTest : HusiHttpKoinTest() {
@Test
fun `update issues a GET to the configured URL`() = runTest(dispatcher.scheduler) {
fakeHttp.nextDownloadBytes = 8 * 1024
// run code under test
// …
val request = assertNotNull(fakeHttp.lastClient?.lastRequest)
assertEquals("https://example.com/list", request.url)
assertEquals(fakeHttp.userAgent, request.userAgent)
}
@Test
fun `update surfaces error when execute throws`() = runTest(dispatcher.scheduler) {
fakeHttp.nextThrowable = IOException("boom")
// assert the code under test emits whatever error event it should
}
}
FakeHttpClientFactory supports:
nextDownloadBytes— total bytes the next response reports throughCopyCallback.setLength.nextChunkCount— how manyCopyCallback.update(n)calls drive the progress callback.nextThrowable— when set, the nextexecute()throws this instead of returning a response.lastClient.socks5/lastRequest.headers/lastRequest.contentZero/lastResponse.closedfor assertions on what the production code configured.
Source: composeApp/src/commonTest/kotlin/fr/husi/test/FakeHttpClientFactory.kt.
DataStore in tests
DataStore is a Kotlin object backed by a real persistent file via
DataStorePreferenceDataStore.
Its file path is resolved through resolveRepository().resolveDatabaseFile(...), which requires
Koin to be initialised — that's why ViewModel/updater tests must go through HusiKoinTest or
deeper, not MainDispatcherTest alone.
The store's own coroutines (file reads, the write actor) run on Repository.preferenceStoreDispatcher
— Dispatchers.IO in production, Dispatchers.Unconfined under FakeRepository, so suspending
PreferenceProxy.get() / set() resumes on the calling thread and stays inside the test
scheduler. Note the store is created once per process: whichever Repository is registered at the
first DataStore touch supplies that dispatcher for the whole test run.
Reset between tests:
override suspend fun postStartKoin() {
DataStore.configurationStore.reset()
}
HusiHttpKoinTest already does this. If you extend it, you don't have to reset again.
Never rely on a default value implicitly
reset() restores the defaults declared in DataStore.kt, and those defaults are product
decisions that get changed. A test that asserts on behaviour gated by a setting it never wrote is
asserting on today's default, not on the behaviour it names — the next chore: commit that flips
that default breaks the test somewhere far from the change.
Set every setting your assertion depends on, even when the default already matches what you want:
@Test
fun `buildConfig should migrate response-based proxy DNS rules to evaluate then respond`() = runBlocking {
disableFakeDns() // The rule shape under test only exists without fake DNS.
// …
}
Guidelines:
Give the premise a name when several tests share it.
ConfigBuilderTest.disableFakeDns()pinsenableFakeDnsandfakeDNSForAllin one documented place, instead of six scatteredset(false)lines that each have to be found again when a third fake-DNS switch appears.Pin what the assertion actually depends on, not the whole store.
ConfigBuilderreads ~34 settings; pinning all of them in every test buys nothing and hides which one matters.Verify the dependency instead of guessing it. Temporarily flip the candidate default in
postStartKoin()and run the class — the tests that fail are exactly the ones that were leaning on it:./gradlew :composeApp:desktopTest --tests fr.husi.fmt.ConfigBuilderTest 2>&1 | grep -E 'FAILED$'
For non-preference fields (e.g. DataStore.serviceState, which is @Volatile var serviceState),
restore them yourself in @AfterTest.
Common pitfalls
- Tests hang or
advanceUntilIdle()does nothing. You passedrunTest { ... }withoutdispatcher.scheduler. UserunTest(dispatcher.scheduler) { ... }. advanceUntilIdle()returns while the work is still running. It only drains the test scheduler; work that resumes on a real dispatcher is invisible to it, so assertions run mid-flight.DataStoreused to do exactly this —Repository.preferenceStoreDispatchernow keeps the preference store on the caller's thread underFakeRepository(see below). For any other hop onto a real dispatcher, either inject the dispatcher (see "Refactoring for testability") or have the function return itsJobandjoin()it.uiEvent.first()returns immediately with the wrong event (or never returns). You started collecting after the producer emitted, into a 0-replaySharedFlow. Wrap the collection inbackgroundScope.async { flow.first() }before triggering the producer.- A
snapshotFlow { … }collector in the ViewModel never fires. Writing to the Compose state it reads (aTextFieldState, amutableStateOf) lands in the global snapshot immediately, but the observers behindsnapshotFlowonly wake on an apply notification. In the app the recomposer sends one every frame; a unit test has no Compose runtime, so it must send its own:Snapshot.sendApplyNotifications()(fromandroidx.compose.runtime.snapshots) after the write and beforeadvanceUntilIdle(). SeecommonTest/kotlin/fr/husi/ui/dashboard/DashboardViewModelConnectionListTest.kt. DataStoreaccess throwsIllegalStateException: KoinApplicationException. You extendedMainDispatcherTestinstead ofHusiKoinMainDispatcherTest.DataStore.configurationStore's factory callsresolveRepository()which needs Koin.- A test "passes" but only because production code silently swallowed the exception. Inspect the
FakeHTTPClient/FakeHTTPRequestrecorders —lastClientbeingnullis usually the smoking gun. Treat unread recorders as a smell. - A test that never touched a setting starts failing after an unrelated
chore:commit. It was asserting on aDataStoredefault. Pin the setting in the test (see "Never rely on a default value implicitly") rather than rewriting the expectation to match the new default — the expectation is usually still right for the scenario the test names. - Tests pollute each other via
DataStore.serviceStateor othervarglobals. Reset them in@AfterTest. The Koin / configurationStore lifecycle resets between tests automatically; loose vars do not. - Reaching for
mockkStatic(Libcore::class). That's a refactor-shaped problem in disguise. Add an interface seam (see theHttpClientFactoryworked example) and put the fake incommonTest. - Forgetting to register a new
viewModelOf(::FooViewModel)incommonNavigationModuleafter switching the Composable tokoinViewModel<...>(). The Compose preview will still compile (constructor has defaults) but production will throwNoDefinitionFoundat runtime. viewModelOf(::FooViewModel)throwsNoDefinitionFoundfor a defaulted constructor param (e.g.CoroutineDispatcher).viewModelOfresolves all params from Koin and doesn't see Kotlin defaults. Switch toviewModel { FooViewModel(httpClientFactory = get()) }.
Reference implementations
- ViewModel + flows + Koin:
commonTest/kotlin/fr/husi/ui/MainViewModelTest.kt - ViewModel without Koin (pure state):
commonTest/kotlin/fr/husi/ui/AssetsScreenViewModelTest.kt - StateFlow
isDirtycollector pattern:commonTest/kotlin/fr/husi/ui/profile/ProfileEditorViewModelTest.kt - Pure logic, no base class:
commonTest/kotlin/fr/husi/fmt/V2RayFmtTest.kt,commonTest/kotlin/fr/husi/ktx/MapsKtTest.kt - HTTP-touching code with fakes:
commonTest/kotlin/fr/husi/ui/tools/SpeedTestScreenViewModelTest.kt - Background scheduling:
commonTest/kotlin/fr/husi/bg/SubscriptionAutoUpdateTest.kt,RouteAssetAutoUpdateTest.kt
Source layout reminders
- Production seam:
composeApp/src/commonMain/kotlin/fr/husi/libcore/HttpClientFactory.kt - Fakes:
composeApp/src/commonTest/kotlin/fr/husi/test/FakeHttpClientFactory.kt - Base classes:
commonTest/kotlin/fr/husi/test/MainDispatcherTest.ktcommonTest/kotlin/fr/husi/test/HusiKoinTest.ktcommonTest/kotlin/fr/husi/test/HusiKoinMainDispatcherTest.ktcommonTest/kotlin/fr/husi/test/HusiHttpKoinTest.kt
- Koin module registering production singletons:
composeApp/src/commonMain/kotlin/fr/husi/di/Koin.kt - ViewModel registry:
composeApp/src/commonMain/kotlin/fr/husi/di/Navigation.kt(viewModelOf(::FooViewModel)insidescope<MainScreenScope> { ... })