Unit Tests, Mock HTTP & UI Tests in Kotlin Compose Desktop | Lesson 15
Video: Unit Tests, Mock HTTP & UI Tests in Kotlin Compose Desktop | Lesson 15 by Taught by Celeste AI - AI Coding Coach
Testing in Compose Desktop: Unit, Mock HTTP, and UI
JUnit + kotlin.test for pure logic. Ktor's
MockEnginefor HTTP.createComposeRule()plustestTagfor UI tests. Make the Weather App from Lesson 8 verifiable end-to-end.
Today's lesson takes the Weather App from Lesson 8 and adds three layers of tests:
- Unit tests — pure functions (
weatherDescription, deserialisation). - Integration tests with mocked HTTP —
fetchWeatherwithMockEngineswapped in. - UI tests — drive the Composable, assert what's on screen.
Compose Desktop's testing story is the same as Android's — androidx.compose.ui.test, createComposeRule(), semantics-tree assertions. If you've tested Compose code on Android, this is identical.
What we are building
Same Weather App as Lesson 8 — but with fetchWeather(client: HttpClient, city: City) taking the client as a parameter so tests can inject MockEngine. Plus tagged Composables (testTag("title")) so the UI tests can find them.
Test-friendly refactor: dependency injection by parameter
Lesson 8's Ktor client was a top-level val. For tests, we make it a parameter:
suspend fun fetchWeather(client: HttpClient, city: City): WeatherResponse {
return client.get("https://api.open-meteo.com/v1/forecast") { /* ... */ }.body()
}
@Composable
fun AppContent(client: HttpClient = defaultClient) { ... }
In production, defaultClient is the real Ktor client. In tests, we pass a HttpClient(MockEngine { ... }) instead. Same code path, different engine.
This is dependency injection in its simplest form — a function parameter with a default. No DI framework needed for two-level depth. (Lesson 16 brings Koin for deeper graphs.)
Unit tests: pure logic
class WeatherModelsTest {
private val json = Json { ignoreUnknownKeys = true }
@Test
fun `deserialize valid JSON into WeatherResponse`() {
val jsonString = """
{
"latitude": 35.6762,
"current": {
"time": "2025-01-15T12:00",
"temperature_2m": 8.5,
"weather_code": 0,
"wind_speed_10m": 12.3
}
}
""".trimIndent()
val response = json.decodeFromString<WeatherResponse>(jsonString)
assertEquals(8.5, response.current.temperature)
assertEquals(0, response.current.weatherCode)
}
@Test
fun `weatherDescription returns Clear sky for code 0`() {
assertEquals("Clear sky", weatherDescription(0))
}
@Test
fun `weatherDescription returns Unknown for unrecognized code`() {
assertEquals("Unknown", weatherDescription(999))
}
}
Backtick-quoted method names give you readable test reports. JUnit (org.junit.Test) and kotlin.test (kotlin.test.Test) both work — kotlin.test is multiplatform, JUnit is JVM-only.
assertEquals(expected, actual) — note the order. Switching them produces backwards messages in failures.
Mocking HTTP with Ktor's MockEngine
private fun createMockClient(handler: MockRequestHandler): HttpClient {
return HttpClient(MockEngine(handler)) {
install(ContentNegotiation) {
json(Json { ignoreUnknownKeys = true })
}
}
}
@Test
fun `fetchWeather with MockEngine returns deserialized data`() = runTest {
val client = createMockClient { request ->
respond(
content = sampleJson,
headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString()),
)
}
val result = fetchWeather(client, cities[0])
assertEquals(8.5, result.current.temperature)
}
HttpClient(MockEngine { request -> ... }) builds a Ktor client whose engine is your handler instead of real network I/O. Inside the handler:
respond(content, status, headers)— return a successful response.respondError(status)— return an error response.request.url/request.method/request.headers— inspect what was sent.
That last point is powerful — you can assert the request matches expectations, not just the response handling:
@Test
fun `request URL contains correct latitude and longitude`() = runTest {
var capturedUrl = ""
val client = createMockClient { request ->
capturedUrl = request.url.toString()
respond(content = sampleJson, /* ... */)
}
fetchWeather(client, cities[0])
assertTrue(capturedUrl.contains("latitude=${cities[0].latitude}"))
}
The mock captures the URL via a closed-over variable, then the assertion checks it after fetchWeather completes.
runTest for suspending tests
@Test
fun `fetchWeather returns data`() = runTest {
val result = fetchWeather(client, cities[0])
assertEquals(8.5, result.current.temperature)
}
runTest { } from kotlinx-coroutines-test is the test analogue of runBlocking. It uses a virtual scheduler — delay(1000) returns immediately, but coroutines that test "what happens after 1s" still see the right relative ordering.
For tests that don't involve real time, the difference is invisible. For timing-sensitive tests (debounce, retry-with-backoff), runTest's virtual time saves seconds per assertion.
UI tests: createComposeRule
class AppContentTest {
@get:Rule
val rule = createComposeRule()
@Test
fun `app title Weather App is displayed`() {
rule.setContent {
MaterialTheme(colorScheme = darkColorScheme()) {
AppContent(client = createMockClient())
}
}
rule.onNodeWithTag("title").assertTextEquals("Weather App")
}
}
createComposeRule() returns a ComposeContentTestRule. @get:Rule registers it with JUnit so it sets up a Compose host before each test and tears it down after.
rule.setContent { ... } mounts a Composable tree into the test host. From there, the rule has methods that traverse the semantics tree — Compose's accessibility-driven view of the UI.
testTag for findability
@Composable
fun AppContent(client: HttpClient) {
Text(
"Weather App",
modifier = Modifier.testTag("title"),
style = MaterialTheme.typography.headlineMedium,
)
// ...
}
Modifier.testTag("title") attaches a tag to a node. Tests look it up via:
onNodeWithTag("title")— find a single node.onAllNodesWithTag("title")— find all matching nodes (returns a collection).
For text content, onNodeWithText("Weather App") works without tags — but tags are more stable across copy changes and i18n.
Async UI testing: waitUntil
@Test
fun `WeatherCard shows temperature and wind speed`() {
rule.setContent {
MaterialTheme(colorScheme = darkColorScheme()) {
AppContent(client = createMockClient())
}
}
rule.waitUntil(5000) {
rule.onAllNodesWithTag("weather_card").fetchSemanticsNodes().isNotEmpty()
}
rule.onNodeWithTag("temperature").assertTextContains("8.5", substring = true)
rule.onNodeWithTag("wind_speed").assertTextContains("12.3", substring = true)
}
The Weather App fetches data asynchronously, so the weather_card doesn't appear until the suspend function resolves. rule.waitUntil(timeoutMs) { condition } blocks until the condition returns true (or the timeout fires).
fetchSemanticsNodes().isNotEmpty() is the canonical "is this node mounted yet" check. onNodeWithTag would assert immediately and fail if the node isn't there yet.
Asserting state changes via clicks
@Test
fun `clicking a city button updates selection`() {
rule.setContent { AppContent(client = createMockClient()) }
rule.onNodeWithTag("city_London").performClick()
rule.waitUntil(5000) {
rule.onAllNodesWithTag("weather_card").fetchSemanticsNodes().isNotEmpty()
}
rule.onNodeWithTag("weather_card").assertExists()
}
performClick() simulates a tap. After the click, the Compose runtime processes it on the next frame — the rule advances frames automatically as the test progresses.
Other gestures: performScrollTo(), performTextInput("text"), performKeyPress(Key.Enter), performTouchInput { swipeDown() }.
Error-state UI test
@Test
fun `error state shows error message`() {
val errorClient = HttpClient(MockEngine { request ->
respondError(HttpStatusCode.InternalServerError)
}) {
install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true }) }
}
rule.setContent { AppContent(client = errorClient) }
rule.waitUntil(5000) {
rule.onAllNodesWithTag("error").fetchSemanticsNodes().isNotEmpty()
}
rule.onNodeWithTag("error").assertTextContains("Failed to fetch weather", substring = true)
}
A different mock client returns a 500 error. The composable's error branch should render. The test asserts the error text is on screen — closing the loop on the error-handling code path that's normally hard to reach.
Common assertions
| Method | Purpose |
|---|---|
assertExists() |
Node is in the tree |
assertDoesNotExist() |
Node is not in the tree |
assertIsDisplayed() |
Node exists and is visible |
assertTextEquals("...") |
Text matches exactly |
assertTextContains("...", substring = true) |
Text contains substring |
assertIsEnabled() / assertIsNotEnabled() |
Click/input enabled state |
assertIsSelected() / assertIsNotSelected() |
For checkboxes, radios, tabs |
Testing pyramid
The three layers in this lesson form the classic pyramid:
- Lots of unit tests — fast, deterministic, focused.
WeatherModelsTest. - Some integration tests — boundaries (HTTP, DB).
WeatherApiTestwith MockEngine. - A few UI tests — end-to-end happy paths.
AppContentTest.
UI tests are slow (real Compose composition). Don't write one for every code path. Pick the critical user flows; cover edge cases at lower layers.
Common mistakes
Calling suspend functions from a non-runTest test. They won't compile (you need a coroutine scope). Wrap with runTest { } or runBlocking { }.
Forgetting await* in UI tests. A test that asserts immediately after setContent for an async-loaded node will flake. Always waitUntil for async UI.
Real network in tests. HttpClient(CIO) in tests means flaky tests that depend on the network. Always inject a mock.
Asserting against Color values directly. Compose's semantics tree exposes text, click handlers, dimensions — not colours. Test colours via screenshot tests, not assertions.
Reusing the rule across tests. Each @Test should call rule.setContent { ... } itself. Setting content once at the class level breaks isolation.
What's next
Lesson 16: Koin DI, repository pattern, and ViewModel. Stack the architectural pieces that take small Composables and grow them into testable, modular apps with proper separation of concerns.
Recap
Three test layers: unit (@Test + assertEquals), integration (MockEngine + runTest), UI (createComposeRule + onNodeWithTag + assertTextEquals). Inject the Ktor client as a parameter so tests can swap in a mock. testTag("name") for findability. waitUntil { ... } for async UI. runTest { } from kotlinx-coroutines-test for suspend functions.
Next lesson: Koin DI and ViewModel.