Skip to main content

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​

FakeFakesCan be told to
FakeSecureStoreSecureStorehold values in memory; fail the next read or write; simulate a lost key (every read returns absent)
FakeKeyValueStoreKeyValueStorehold values in memory; fail the next write with "disk full"
FakeFieldCipherFieldCipherencrypt reversibly without real crypto (a marked, readable form, so assertions can see what was stored); fail decryption on demand
FakeBiometricVaultBiometricVaultreport any availability; return Unlocked or a chosen Failed(reason)
FakeTokenSourceTokenSourcereturn a scripted sequence of RefreshOutcomes; count refresh calls
FakeNetworkCheckerNetworkCheckerswitch online and offline, emitting changes
RecordingCrashReporterCrashReporterrecord every report for assertions; clear() between tests
FakeLocalizerLocalizerresolve 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​

HelperFor
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​

SeamTest withAssert
Repositorythe real API client on mockHttp, real envelopes, JSON fixturesthe domain result, the exception type and code for error fixtures, the request sent (path, query, headers, body)
Use casefake repositories (the app's own)the result, and that a domain exception passes through with its code
View modelfakes, sending eventsthe sequence of states and effects
PagingControlledFetchwhich response wins in a race, and the final merged list
Session expiryFakeTokenSource scripted Rejected or Unavailable, with mockHttp returning 401one 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​

RuleWhy
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 webThat's exactly where platforms differ
Every fake has its own tests, including its failure switchesA wrong fake makes every test that uses it wrong

3. API​

NameSignature (pseudocode)
fakesFakeSecureStore(), .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 = {})
mockHttpmockHttp(config = SinewConfig(baseUrl = "https://test.local"), tokenSource = FakeTokenSource(), routes: MockRoutes.() -> Unit): client, with .requests
MockRouteson(method, path) respond(status, body, headers = {}, delay = 0), on(method, path) drop()
fixturefixture(name): String
ControlledFetch<T>fetch(pageOrCursor, query) (pass to a Pager factory), .calls, .complete(call, result), .fail(call, exception)
installRecordingReporterinstallRecordingReporter(): RecordingCrashReporter, restored after the test
collectIntostream.collectInto(list)

4. Build steps​

  1. The fakes, each with its own tests, including every failure switch.
  2. mockHttp on each platform, tested with the Network package's own scenarios: a 401 then a refresh, a dropped connection, a delayed response.
  3. ControlledFetch, tested by reproducing each paging race from the Paging note.
  4. installRecordingReporter, tested so that the reporter is restored even when the test fails.
  5. Use sinew_testing in 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_testing is never a non-test dependency of any Sinew package or sample app (checked in CI).

5. Edge cases​

CaseDecided behavior
An app uses a mocking library anywayAllowed in its own JVM-only tests. Sinew's fakes don't depend on one.
Two tests in parallel install recording reportersinstallRecordingReporter is for tests that run serially. Parallel suites share one reporter and clear() it.
A fixture file is missingfixture fails the test with the missing file's path, never returns an empty string.
A test forgets to complete a ControlledFetch callThe pending call is reported when the test ends, so a forgotten race doesn't pass silently.
FakeFieldCipher used where real encryption must be provenNot 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​

HelperKotlin form
fakesplain classes in commonMain of sinew-testing (it's a library consumed only by test source sets)
mockHttpSinewHttp.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
flowsTurbine: 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()
}
RuleWhy
runTest with StandardTestDispatcher, and Dispatchers.setMain for view-model testsVirtual time for debounce, retry and polling, and deterministic ordering
No MockK or Mockito in Sinew's testsThey 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 runCoverage 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​

  1. The fakes, with their own tests in sinew-testing's commonTest.
  2. mockHttp on MockEngine, tested with the network scenarios.
  3. ControlledFetch, tested by reproducing each paging race.
  4. Every other module's tests depend on sinew-testing through commonTest only.

Done when

  • sinew-testing appears only in test configurations across the repo (checked by checkSinewGraph).

5. Edge cases​

CaseDecided behavior
A fixture read on wasmTest 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.
Decided by default (revisit during implementation)
  • withRecordingReporter { } as a block instead of a JUnit rule, because JUnit rules don't exist in commonTest.