09 - Testing
Concept
1. Why
An app built on Sinew tests the same seams over and over:
- a repository against a fake backend;
- a view model with its dependencies faked;
- a paged list whose responses arrive in a chosen order;
- a session whose refresh fails.
Each app writing its own fakes for Sinew's interfaces means hundreds of slightly different fakes, some of them wrong (a fake secure store that never "loses" its key, a fake token source that can't fail).
sinew_testing ships one correct fake per Sinew interface, plus helpers for the hard cases. It's a test-only dependency: nothing in it may reach production code.
2. Shape
Fakes
| Fake | Fakes | Can be told to |
|---|---|---|
FakeSecureStore | SecureStore | hold values in memory; fail the next read or write; simulate a lost key (every read returns absent) |
FakeKeyValueStore | KeyValueStore | hold values in memory; fail the next write with "disk full" |
FakeFieldCipher | FieldCipher | encrypt reversibly without real crypto (a marked, readable form, so assertions can see what was stored); fail decryption on demand |
FakeBiometricVault | BiometricVault | report any availability; return Unlocked or a chosen Failed(reason) |
FakeTokenSource | TokenSource | return a scripted sequence of RefreshOutcomes; count refresh calls |
FakeNetworkChecker | NetworkChecker | switch online and offline, emitting changes |
RecordingCrashReporter | CrashReporter | record every report for assertions; clear() between tests |
FakeLocalizer | Localizer | resolve keys from a map, or return the key itself, so a test can assert which key was shown |
Every fake is plain code, with no mocking library. That keeps it usable in common Kotlin code, where JVM mocking libraries don't run.
Helpers
| Helper | For |
|---|---|
mockHttp { on(GET, "/api/orders") respond(200, fixture("orders_page.json")) } | a Sinew client (SinewHttp) backed by a mock engine that answers by method and path, records requests, and can delay or drop a response. Kotlin: Ktor's mock engine. Flutter: a Dio mock adapter. |
fixture(name) | reads a JSON file from the test resources, so response shapes live as files next to the tests |
ControlledFetch<T> | a Pager fetch function whose calls stay pending until the test completes them, in any order. That's how paging races are tested. |
installRecordingReporter() | installs a RecordingCrashReporter and resets to CrashReporter.none after the test |
effects.collectInto(list) / states.collectInto(list) | gathers a view model's states and effects for assertions (Kotlin: a Flow test helper; Dart: a stream listener) |
There are no envelope builders. Every app declares its own envelopes, so its tests use JSON fixtures in its own envelope shape, parsed by its real envelopes.
How an app tests with Sinew
| Seam | Test with | Assert |
|---|---|---|
| Repository | the real API client on mockHttp, real envelopes, JSON fixtures | the domain result, the exception type and code for error fixtures, the request sent (path, query, headers, body) |
| Use case | fake repositories (the app's own) | the result, and that a domain exception passes through with its code |
| View model | fakes, sending events | the sequence of states and effects |
| Paging | ControlledFetch | which response wins in a race, and the final merged list |
| Session expiry | FakeTokenSource scripted Rejected or Unavailable, with mockHttp returning 401 | one refresh, the right failure, and the session kept or ended |
// a view-model test (pseudocode)
fetch = ControlledFetch<Order>()
vm = OrderListViewModel(orders = FakeOrderRepository(fetch))
states = vm.state.collectInto(list)
vm.onEvent(Opened)
vm.onEvent(QueryChanged("shoe")) // after debounce: a second Initial load
fetch.complete(call = 1, page(orders: [a, b])) // the old query's response arrives late
fetch.complete(call = 0, page(orders: [c])) // the new one
assert states.last().orders.view == Done(PagingDomain([c], …)) // the stale page never landed
Rules for Sinew's own tests
| Rule | Why |
|---|---|
| Test first by default. A test written after the code is allowed only in the same change, from the requirement, and must be seen failing once (by breaking the code on purpose). Bug fixes are always test-first. | Tests that never failed prove nothing |
| At least 80% line coverage per package, measured on the JVM (Kotlin) and the VM (Dart) | A floor, not a target: every public behavior has a test |
| Platform implementations (secure storage, crypto, biometrics, network checker) are tested on their platform: device or simulator tests, browser tests for web | That's exactly where platforms differ |
| Every fake has its own tests, including its failure switches | A wrong fake makes every test that uses it wrong |
3. API
| Name | Signature (pseudocode) |
|---|---|
| fakes | FakeSecureStore(), .failNextRead(), .failNextWrite(), .loseKey() · FakeKeyValueStore(), .failNextWriteWithDiskFull() · FakeFieldCipher(), .failNextDecrypt() · FakeBiometricVault(availability), .nextUnlock(outcome) · FakeTokenSource(outcomes: List<RefreshOutcome>), .refreshCount · FakeNetworkChecker(online), .setOnline(Boolean) · RecordingCrashReporter(), .reports, .clear() · FakeLocalizer(strings = {}) |
mockHttp | mockHttp(config = SinewConfig(baseUrl = "https://test.local"), tokenSource = FakeTokenSource(), routes: MockRoutes.() -> Unit): client, with .requests |
MockRoutes | on(method, path) respond(status, body, headers = {}, delay = 0), on(method, path) drop() |
fixture | fixture(name): String |
ControlledFetch<T> | fetch(pageOrCursor, query) (pass to a Pager factory), .calls, .complete(call, result), .fail(call, exception) |
installRecordingReporter | installRecordingReporter(): RecordingCrashReporter, restored after the test |
collectInto | stream.collectInto(list) |
4. Build steps
- The fakes, each with its own tests, including every failure switch.
mockHttpon each platform, tested with the Network package's own scenarios: a 401 then a refresh, a dropped connection, a delayed response.ControlledFetch, tested by reproducing each paging race from the Paging note.installRecordingReporter, tested so that the reporter is restored even when the test fails.- Use
sinew_testingin the tests of every other Sinew package, as its first user.
Done when
- Every Sinew interface has a fake here, with tests.
- Every Review Focus scenario (refresh burst, paging races, broken envelopes, cancellation) can be written in a few lines with these helpers.
-
sinew_testingis never a non-test dependency of any Sinew package or sample app (checked in CI).
5. Edge cases
| Case | Decided behavior |
|---|---|
| An app uses a mocking library anyway | Allowed in its own JVM-only tests. Sinew's fakes don't depend on one. |
| Two tests in parallel install recording reporters | installRecordingReporter is for tests that run serially. Parallel suites share one reporter and clear() it. |
| A fixture file is missing | fixture fails the test with the missing file's path, never returns an empty string. |
A test forgets to complete a ControlledFetch call | The pending call is reported when the test ends, so a forgotten race doesn't pass silently. |
FakeFieldCipher used where real encryption must be proven | Not allowed: crypto tests use the real FieldCipher on the platform. The fake is for code that only stores and loads values. |
Implementation
1. Why
sinew-testing holds the fakes and helpers from the base note, written so they run in commonTest on every target. That rules out JVM-only mocking libraries, and makes coroutine test tools the default.
2. Shape
| Helper | Kotlin form |
|---|---|
| fakes | plain classes in commonMain of sinew-testing (it's a library consumed only by test source sets) |
mockHttp | SinewHttp.client(config, tokenSource, engine = MockEngine { request -> routes.respond(request) }), with recorded requests |
fixture(name) | reads src/commonTest/resources/<name> through a small expect file reader (Android and JVM resources, iOS bundle, wasm fetch of a test resource) |
ControlledFetch<T> | each call suspends on a CompletableDeferred until the test completes it; assertAllCompleted() runs in teardown |
installRecordingReporter() | a TestRule-like helper: withRecordingReporter { reporter -> … } restores CrashReporter.None in finally |
| flows | Turbine: vm.state.test { … }, vm.effects.test { … } |
@Test fun staleSearchResponseIsDropped() = runTest {
val fetch = ControlledFetch<Order>()
val vm = OrderListViewModel(FakeOrderRepository(fetch))
vm.state.test {
vm.onEvent(Opened); runCurrent()
vm.onEvent(QueryChanged("shoe")); advanceTimeBy(500); runCurrent()
fetch.complete(call = 1, Result.success(page(listOf(c))))
fetch.complete(call = 0, Result.success(page(listOf(a, b)))) // the old query's response, late
assertEquals(listOf(c), expectMostRecentItem().orders.view.dataOrNull?.items)
}
fetch.assertAllCompleted()
}
| Rule | Why |
|---|---|
runTest with StandardTestDispatcher, and Dispatchers.setMain for view-model tests | Virtual time for debounce, retry and polling, and deterministic ordering |
| No MockK or Mockito in Sinew's tests | They need JVM reflection and don't run on iOS or wasm. Apps may use them in their own JVM-only tests. |
| Kover measures the JVM test run | Coverage tooling runs on the JVM. Platform-only code is covered by its platform tests. |
3. API
As the base note, in Kotlin: FakeSecureStore, FakeKeyValueStore, FakeFieldCipher, FakeBiometricVault, FakeTokenSource, FakeNetworkChecker, RecordingCrashReporter, FakeLocalizer, mockHttp, fixture, ControlledFetch, withRecordingReporter.
4. Build steps
- The fakes, with their own tests in
sinew-testing'scommonTest. mockHttponMockEngine, tested with the network scenarios.ControlledFetch, tested by reproducing each paging race.- Every other module's tests depend on
sinew-testingthroughcommonTestonly.
Done when
-
sinew-testingappears only in test configurations across the repo (checked bycheckSinewGraph).
5. Edge cases
| Case | Decided behavior |
|---|---|
| A fixture read on wasm | Test resources are served by the browser test runner, and fixture fetches them. |
A test forgets assertAllCompleted() | ControlledFetch is created through controlledFetch(), which registers the check with the test's teardown. |
withRecordingReporter { }as a block instead of a JUnit rule, because JUnit rules don't exist incommonTest.