Getting started¶
This page walks through writing a single test against a running Compose Desktop window. It assumes you've already followed Installation.
The shape of a Spectre test¶
A typical Spectre test does four things, in order:
- Launch the UI under test. This is your responsibility —
application { Window { ... } }for a standalone Compose Desktop app, or a custom harness for IDE-hosted Compose. - Wait until the UI has settled. Spectre's queries don't auto-wait, so there must be a point where the window is visible and the first frame is composed before you query it.
- Find nodes via the semantics tree and drive them with mouse, keyboard, and typing.
- Assert on the resulting state by reading the semantics tree again.
Tag your UI¶
Spectre finds nodes by Compose semantics. The most reliable selector is Modifier.testTag:
@Composable
fun CounterScreen() {
var count by remember { mutableStateOf(0) }
Column(modifier = Modifier.padding(24.dp)) {
Text(
text = "Count: $count",
modifier = Modifier.testTag("CounterValue"),
)
Button(
onClick = { count++ },
modifier = Modifier.testTag("Increment"),
) {
Text("Increment")
}
}
}
If you can't change the UI to add tags, you can still find nodes by visible text, content description, or role — see Finding nodes.
Write the test¶
import dev.sebastiano.spectre.testing.ComposeAutomatorExtension
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.RegisterExtension
class CounterTest {
@JvmField
@RegisterExtension
val automatorExt = ComposeAutomatorExtension()
@Test
fun `clicking increment bumps the counter`(): Unit = runBlocking {
launchCounterApp() // your harness — opens the Compose window
val automator = automatorExt.automator
automator.waitForNode(tag = "CounterValue")
automator.waitForVisualIdle()
val initial = automator.findOneByTestTag("CounterValue")
check(initial?.text == "Count: 0")
val increment = automator.findOneByTestTag("Increment")
?: error("Could not find Increment button")
automator.click(increment)
automator.waitForVisualIdle()
val updated = automator.findOneByTestTag("CounterValue")
check(updated?.text == "Count: 1")
}
}
import dev.sebastiano.spectre.testing.ComposeAutomatorRule
import kotlinx.coroutines.runBlocking
import org.junit.Rule
import org.junit.Test
class CounterTest {
@get:Rule
val automatorRule = ComposeAutomatorRule()
@Test
fun clickingIncrementBumpsTheCounter(): Unit = runBlocking {
launchCounterApp()
val automator = automatorRule.automator
automator.waitForNode(tag = "CounterValue")
automator.waitForVisualIdle()
val initial = automator.findOneByTestTag("CounterValue")
check(initial?.text == "Count: 0")
val increment = automator.findOneByTestTag("Increment")
?: error("Could not find Increment button")
automator.click(increment)
automator.waitForVisualIdle()
val updated = automator.findOneByTestTag("CounterValue")
check(updated?.text == "Count: 1")
}
}
What just happened¶
ComposeAutomatorExtensionandComposeAutomatorRuleeach build a freshComposeAutomatorfor each test. The default factory isComposeAutomator.inProcess(), which usesRobotDriverfor real OS-level input.waitForNode(tag = "CounterValue")polls the semantics tree until the node exists. It's how you bridge the gap between "the test started" and "the UI is on screen".waitForVisualIdle()waits until the on-screen pixels stop changing for a few frames in a row — useful between an interaction and a read-back.findOneByTestTag(...)does a single semantics-tree read — no waiting. If the result isn't what you expect, your UI probably wasn't idle yet.automator.click(node)issuspend— it resolves the node's centre on screen, dispatches a real mouse click viajava.awt.Robot, and only hops toDispatchers.IOwhen the call originates on the AWT event dispatch thread.
All interaction methods (click, doubleClick, swipe, typeText, pasteText, …) and all wait
helpers (waitForNode, waitForIdle, waitForVisualIdle) are suspend, so the test
body runs inside runBlocking { … }. JUnit test methods don't run on the AWT event
dispatch thread, so no extra withContext is needed here. If you ever call wait helpers
from a coroutine on Dispatchers.Main (Swing EDT), wrap them in
withContext(Dispatchers.Default) — they reject EDT callers at runtime. See
Synchronization.
Force JUnit expression-body tests to return Unit
In JUnit 5.14 and newer, @Test methods whose JVM return type is not void are
rejected during discovery. Kotlin expression-body tests infer their return type from
the last expression inside runBlocking { ... }; assertions such as assertNotNull
return the asserted value, not Unit. Write fun mySpec(): Unit = runBlocking { ... }
so the JVM signature stays void regardless of the last assertion.
Use runBlocking, not runTest
runTest from kotlinx-coroutines-test controls time via a virtual scheduler and
skips delay() calls, advancing the clock instantly. Spectre uses delay internally
for timing-sensitive operations — longClick hold durations, swipe step pacing, and
clipboard-settle polling in pasteText — so running under runTest silently collapses
those pauses to zero. The result is that longClick doesn't actually hold, swipe
jumps to the end position instantly, and clipboard operations may race.
Stick with runBlocking for Spectre tests.
Where to go next¶
- The automator for the mental model — surfaces, the semantics tree, and the deliberate lack of auto-wait.
- Finding nodes if
testTagisn't enough. - Synchronization for the full wait-helper toolkit.
- Troubleshooting if your test isn't finding what you expect.