The UI layer described in ADR-0025 now has a stable, feature-first package layout under ui/.
Test coverage across that layer is currently low and inconsistent:
*ViewModel classes have no unit tests (ReferenceViewModel, SomedayMaybeViewModel, a full-coverage EditItemViewModel test is absent beyond the single error-path file).*Screen composable.ui/components/ and feature-scoped components in ui/features/*/components/ are entirely untested.NavigationTest.kt covers route constant naming but does not assert that every registered destination actually renders.PrimaryJourneySmokeTest, EditAndReclassifySmokeTest, SomedayPromotionSmokeTest) are the only end-to-end coverage in the project.The project already carries Turbine (1.2.1), MockK (1.14.9), Robolectric (4.16.1), the Compose BOM (2026.01.01), and Espresso Core (3.7.0) in libs.versions.toml, so the necessary infrastructure is in place. What is missing is a documented, enforceable policy that specifies what to test, at which layer, with which tools, and to what coverage threshold.
We will enforce a three-tier testing pyramid for all UI code, with each tier having a defined scope, tooling choice, co-location rule, and minimum coverage expectation. No feature may be considered complete unless all three applicable tiers have been addressed.
Scope: Every *ViewModel class. Tests cover state transitions, business-logic branching inside the ViewModel, error paths, and any derived StateFlow / SharedFlow emission.
Tooling:
kotlinx-coroutines-test with UnconfinedTestDispatcher as the default scheduler so StateFlow emissions resolve synchronously.Turbine for all Flow/StateFlow/SharedFlow assertions — prefer turbineScope { } over collectList() hacks.MockK for mocking infrastructure dependencies (repositories, use cases). Use coEvery / coVerify for suspend functions.Fake* implementations (e.g. FakeInboxRepository) over MockK stubs for repositories that are exercised by many test cases — this matches the existing FakeLlmService pattern already in the codebase.Canonical test class template:
@OptIn(ExperimentalCoroutinesApi::class)
class InboxViewModelTest {
private val testDispatcher = UnconfinedTestDispatcher()
@get:Rule
val mainDispatcherRule = MainDispatcherRule(testDispatcher) // custom TestRule
private val fakeRepository = FakeInboxRepository()
private lateinit var viewModel: InboxViewModel
@Before fun setUp() {
viewModel = InboxViewModel(fakeRepository)
}
@Test fun `initial state is Loading`() = runTest {
// assert first emission
}
@Test fun `inbox items emitted after load`() = runTest {
viewModel.uiState.test {
fakeRepository.emit(listOf(anInboxItem()))
val state = awaitItem()
assertThat(state.items).hasSize(1)
}
}
}
Co-location rule: Every ui/features/<feature>/*ViewModel.kt must have a corresponding ui/features/<feature>/*ViewModelTest.kt under app/src/test/.
Coverage threshold: ≥ 80 % line coverage per ViewModel class, measured by Kover on the CI pipeline (see ADR-0022).
Scope:
*Screen composable — at minimum, one test per distinct UI state the screen can render (Loading, Content, Empty, Error where applicable).ui/components/ — at minimum a smoke test that the component renders without crashing across its primary variants.ui/features/*/components/ — tested when they contain non-trivial conditional rendering or interaction logic.Tooling:
compose-ui-test-junit4 (androidx.compose.ui:ui-test-junit4) with createComposeRule().@RunWith(RobolectricTestRunner::class).ComposeContentTestRule.onNodeWithTag() / onNodeWithText() / onNodeWithContentDescription() for assertions. testTag modifiers must be added to stateful or interactive elements that are otherwise difficult to target.LocusFlowTheme {} wrapper must be applied inside each setContent {} call so tokens and typography resolve correctly.Canonical screen test pattern:
@RunWith(RobolectricTestRunner::class)
class InboxScreenTest {
@get:Rule val composeTestRule = createComposeRule()
@Test fun `loading state shows progress indicator`() {
composeTestRule.setContent {
LocusFlowTheme {
InboxScreen(
uiState = InboxUiState.Loading,
onAddItem = {},
onItemClick = {},
)
}
}
composeTestRule.onNodeWithTag("LoadingIndicator").assertIsDisplayed()
}
@Test fun `content state renders item list`() {
val items = listOf(anInboxItem(title = "Buy groceries"))
composeTestRule.setContent {
LocusFlowTheme {
InboxScreen(
uiState = InboxUiState.Content(items),
onAddItem = {},
onItemClick = {},
)
}
}
composeTestRule.onNodeWithText("Buy groceries").assertIsDisplayed()
}
}
Canonical shared component test pattern:
@RunWith(RobolectricTestRunner::class)
class PrimaryButtonTest {
@get:Rule val composeTestRule = createComposeRule()
@Test fun `renders label and invokes onClick`() {
var clicked = false
composeTestRule.setContent {
LocusFlowTheme { PrimaryButton(label = "Save", onClick = { clicked = true }) }
}
composeTestRule.onNodeWithText("Save").performClick()
assertThat(clicked).isTrue()
}
@Test fun `disabled state blocks click`() {
var clicked = false
composeTestRule.setContent {
LocusFlowTheme { PrimaryButton(label = "Save", enabled = false, onClick = { clicked = true }) }
}
composeTestRule.onNodeWithText("Save").performClick()
assertThat(clicked).isFalse()
}
}
Co-location rules:
app/src/test/.../ui/features/<feature>/*ScreenTest.ktapp/src/test/.../ui/components/*Test.ktapp/src/test/.../ui/features/<feature>/components/*Test.ktCoverage threshold: Every distinct sealed-interface state branch of a *State must have at least one Compose UI test exercising it. No numeric line-coverage gate is applied here; branch completeness is verified by code review.
The existing smoke tests in app/src/androidTest/.../userjourney/ cover end-to-end critical paths. This tier is retained as-is; the strategy for it is documented in ADR-0022 (CI pipeline) and ADR-0020 (LLM testing). No new user-journey test should be added unless:
ui/navigation/NavigationTest.kt is expanded to assert:
NavigationRoutes has a corresponding composable(...) registration reachable via locusFlowNavGraph.navController.navigate(route) results in the expected screen composable being displayed, verified with a stable testTag on each screen's root node.These tests use createComposeRule() + a real NavHostController backed by a TestNavHostController and run under Robolectric (Tier 2 tooling).
A shared app/src/test/.../testutil/ package provides:
| File | Contents |
|---|---|
Fixtures.kt | Top-level builder functions: anInboxItem(), aProcessedItem(), aDailyReflection(), etc. All parameters have sensible defaults so tests only specify what they care about. |
Fake*.kt | Hand-written fake implementations of each *Repository interface (FakeInboxRepository, FakeProcessedItemRepository, etc.), emitting MutableStateFlow values that tests can control. |
MainDispatcherRule.kt | TestWatcher that installs UnconfinedTestDispatcher as Dispatchers.Main for the duration of each test. |
The existing FakeLlmService in domain/llm/ is the reference implementation. All new fakes follow the same pattern.
data/ tests; this ADR does not modify them.SummarizeDailyReflectionUseCaseTest pattern; out of scope here.Semantics correctness is implicitly covered by content-description assertions in Tier 2 tests, which is sufficient for now.Kover is configured in app/build.gradle.kts with the following rules:
koverReport {
filters { excludes { packages("*.di", "*.data.local.entity") } }
verify {
rule("ViewModel coverage") {
bound {
minValue = 80
metric = MetricType.LINE
aggregation = AggregationType.COVERED_PERCENTAGE
filter { includes { classes("*ViewModel") } }
}
}
}
}
The verifyKoverReportDebug Gradle task is added to the test stage of the CI pipeline (ADR-0022). A pull request that drops ViewModel line coverage below 80 % fails the pipeline.
delay()-based Flow assertions prevalent in the existing tests.MutableStateFlow that any test can set up without knowing the call signature — the same design the FakeLlmService already uses.test/ so that when a developer opens a feature folder they immediately see both the production code and its tests. This is consistent with ADR-0025's feature-first principle.MainDispatcherRule, Fixtures.kt, and all Fake* implementations should be created in a single bootstrapping task before new feature tests are written, to prevent duplication.Vision, Settings, Projects) are promoted to full features (per ADR-0025), their ViewModel and Screen tests must be added in the same pull request as the implementation.FakeLlmService pattern is already established in the codebase as the preferred approach for infrastructure fakes; this ADR extends that convention uniformly.testTag convention for screen root nodes should be "<FeatureName>Screen" (e.g. "InboxScreen", "ProcessingScreen") to allow stable targeting from both Tier 2 tests and navigation tests.captureRoboImage() directly on Compose nodes.