cx.m42.superizer:testing:0.1.0, in the test source set. Three layers, in the order you will want
them:
runAppTest — your app on fakes, composed, no host.AppContractTest — the TCKclass HelloContractTest : AppContractTest(HelloApp())
That inherits ten tests:
| Test | What it proves |
|---|---|
theDefaultConfigDecodes |
an empty payload opens the app |
aBrokenPayloadIsAFailureAndNotASilentDefault |
a malformed config either decodes honestly or fails with a reason |
everyFixtureStillReadsWithTheCurrentSpec |
every payload in configFixtures still decodes |
setupRegistersOnlyWhatTheManifestDeclares |
no deep link outside the manifest |
theManifestSurvivesARoundTripThroughJson |
id, requires and deepLinks come back equal |
launchingIsCheapAndTouchesNoNetworkBeforeTheFirstFrame |
onLaunch ≤ 200 ms of virtual time, runtime.network.calls empty |
aSnapshotSurvivesARoundTrip |
saveState → restore → saveState is stable, into a fresh instance |
everyStateFixtureCanStillBeRestored |
restore does not throw on an older snapshot |
closingIsAllowedByDefaultAndDisposeIsIdempotent |
dispose() twice is safe (the handler and shutdown can meet) |
theScreenComposesAndHasTheRootTagTheContainerPutsOnIt |
Content() composes at all |
Three hooks:
class ConverterContractTest : AppContractTest(ConverterApp()) {
// Older payloads this app must still read. Raw strings, not files: a resource loader that
// behaves the same in desktopTest, Robolectric and Karma does not exist.
override val configFixtures = listOf(
"""{"base":"USD"}""",
"""{"schemaVersion":1,"base":"EUR","favorites":["USD"]}""",
)
// The same for saveState snapshots an update may be handed.
override val stateFixtures = listOf("""{"amount":"100"}""")
// A config that exercises more than the default would.
override val sampleConfig = AppConfig(buildJsonObject { put("base", JsonPrimitive("EUR")) })
}
Add a fixture string every time you change the config or snapshot shape. That is the entire migration test suite, and it costs one line.
runAppTest — the app, composed, on fakespublic fun <C : Any> runAppTest(
app: SuperizerApp<C>,
config: AppConfig = AppConfig.Empty,
restore: JsonObject? = null,
prepare: (FakeAppRuntime) -> Unit = {},
block: AppTestScope<C>.() -> Unit,
)
It decodes the config, calls launch, runs onLaunch, applies restore if given, provides
LocalAppRuntime, wraps Content() in a <appId>:root box, and hands you:
class AppTestScope<C : Any>(
val app: SuperizerApp<C>,
val instance: AppInstance,
val runtime: FakeAppRuntime, // the fakes, with their innards visible
val ui: ComposeUiTest, // the escape hatch for anything below
) {
fun node(tag: String): SemanticsNodeInteraction
fun click(tag: String)
fun type(tag: String, text: String)
fun assertShown(tag: String)
fun assertAbsent(tag: String)
fun idle()
}
@Test
fun aRateArrivesAndTheResultUpdates() = runAppTest(
ConverterApp(),
prepare = { it.network.respond(ConverterConfig.DEFAULT_RATES_URL, """{"rates":{"EUR":0.9}}""") },
) {
type("currency-converter:amount", "100")
click("currency-converter:convert")
assertShown("currency-converter:result")
assertEquals(1, runtime.network.calls.size)
}
@Test
fun theHalfTypedSumComesBack() = runAppTest(
CalculatorApp(),
restore = buildJsonObject { put("entry", JsonPrimitive("12+")) },
) {
assertShown("calculator:display")
}
The test scope runs on an unconfined dispatcher, so work an app starts in runtime.scope has
already happened by the time the test looks.
Find nodes by tag, never by text — a suite that asserts on Russian strings fails the day somebody adds a third table.
Every one of them is twenty lines, and that is a property of the contract: the service interfaces take strings and JSON, so a fake cannot become an implementation in disguise.
| Fake | What it exposes to a test |
|---|---|
InMemoryStorage(initial) |
entries: MutableMap<String, String> |
FakeNetwork |
respond(url, body, status), respond(url) { req -> … }, fail(url, msg), calls: MutableList<NetworkRequest>, online: MutableStateFlow<Boolean>. A URL with no stub throws — a test that hits an unnamed endpoint is a test about to reach the real network. |
RecordingLogger |
lines: MutableList<String> |
RecordingAnalytics |
events, screens |
RecordingNavigation |
opened, closed, settingsOpened, urls, and unknown: MutableSet<AppId> to make openApp fail |
FakeLocale(tag) |
langTag: MutableStateFlow<String> |
FakeClock(millis) |
advance(millis, seconds, minutes, hours) |
RecordingHaptics |
ticks: Int |
FakePush |
deliver(topic, data, link, receivedAt), subscriptions, permissionGranted |
FakeAuth(session) |
session: MutableStateFlow<AuthSession?> |
FakeApps(summaries) |
plus appSummary(id, metadata, …) to build one |
InMemorySnapshotStore |
snapshot: SessionSnapshot? |
FakeAppRuntime(appId, scope, hostName, debug, declaredServices) |
all of the above as an InstanceRuntime; provide(key, impl), emittedEvents, hostLifecycle |
FakeHostRuntimeFactory(hostInfo) |
for a handler under test; created: Map<AppId, FakeAppRuntime> |
FakeAppRuntime offers exactly the service keys the manifest declares. An app that calls
require(key) on something it forgot to declare fails in its first test rather than as a null in
production.
For the things that are about the product rather than an app: what a fresh install shows, whether the drawer works, whether a promo code unlocks the right thing.
@OptIn(ExperimentalTestApi::class)
class ShellTest {
private fun testSuperizer(): Superizer {
PrefsStorage.useDirectory(Files.createTempDirectory("myhost-test").toFile())
return buildMyHost(CoroutineScope(SupervisorJob() + Dispatchers.Unconfined), debug = true)
}
@Test fun aFreshInstallHasHelloOnHome() = runComposeUiTest {
setContent { MyHostApp(testSuperizer()) }
waitForIdle()
onNodeWithTag("home:tile-hello").assertIsDisplayed()
onNodeWithTag("home:tile-test-app").assertDoesNotExist() // hidden
}
@Test fun aPromoCodeUnlocksTheBenchApp() = runComposeUiTest {
setContent { MyHostApp(testSuperizer()) }
onNodeWithContentDescription("Open menu").performClick()
onNodeWithContentDescription("Activate").performClick()
onNodeWithTag("activate:promo").performTextInput(TestApp.PROMO_CODE)
onNodeWithTag("activate:apply").performClick()
waitForIdle()
onNodeWithTag("test-app:root").assertIsDisplayed()
}
}
Give every test its own preferences directory — PrefsStorage.useDirectory(tmp) — or Home,
unlocks and the session snapshot leak between them and the order of the suite starts to matter.
Substitute anything that would reach outside the process:
buildMyHost(scope, debug = true) {
network(FakeNetwork().apply { respond("https://api…", "{}") })
clock(FakeClock())
lifecycle(MutableStateFlow(HostLifecycle.Foreground))
}
(Give your buildMyHost a trailing extra: SuperizerBuilder.() -> Unit = {} parameter, as Unitool
does, and a test can add or override anything without a second host function.)
The desktop target composes the same commonMain tree every other target does, so screenshots need
no emulator, browser or window server:
runDesktopComposeUiTest(width = 412, height = 915) {
setContent { CompositionLocalProvider(LocalDensity provides Density(2.625f)) { MyHostApp(host) } }
onRoot().captureToImage().toAwtImage().let { ImageIO.write(it, "PNG", file) }
}
Run one class only: ./gradlew :app:desktopTest --tests '*Screenshots*'. A test can be green while
the screen is blank — screenshots are meant to be looked at.
wasmJsTest runs on Karma and needs a browser binary; set CHROME_BIN. Where there is none, the
task is disabled rather than failing with a download error — scripts/verify.sh reports the skip
by name.
A canvas has no DOM to query, so Playwright cannot find nodes. The fixture publishes a read-only
bridge as window.__superizer — and only when opened with ?test=1:
window.__superizer.appCount() // the build started at all
window.__superizer.currentApp() // a route reached the handler
window.__superizer.events() // the lifecycle ran
window.__superizer.destination() // "Home" | "App"
Copy that pattern into your own host if you want the same three classes of wasm regression caught.
./scripts/verify.sh
checkDependencyRules and checkLegacyAbi (stale API dumps fail — run updateLegacyAbi and
commit the diff);desktopTest --continue across every module;wasmJsTest when a Chromium is available;publishToMavenLocal and then a build of samples/consumer, which resolves the library by
coordinates from a separate build — the only step that can prove a publication.publish.yml runs ./gradlew verify publish in one invocation, so an artifact nobody can build
against is never uploaded: Gradle does not reach the publish tasks if a test task fails.
One report at build/verify/report.md, non-zero exit unless every line passes, skips always named.