
Testing Android Code
- 69 installs
- 9.2k repo stars
- Updated August 4, 2026
- bitwarden/android
testing-android-code is a Claude Code skill that gives tactical testing patterns for Bitwarden Android ViewModels, Compose screens, repositories, and data sources.
About
This skill provides tactical testing guidance for Bitwarden-specific Android patterns. It documents base test classes, ViewModel and Compose UI testing, repository and service testing with MockWebServer, test data builders, Result-type testing, and common gotchas. A developer uses it when writing or reviewing tests for Android code in Bitwarden. It relies on JUnit 5, MockK, and Turbine.
- Tactical testing patterns for Bitwarden Android ViewModels, Compose, repositories, and services
- Documents base classes (BaseViewModelTest, BitwardenComposeTest, BaseServiceTest) and 35+ mock builders
- Covers StateFlow vs EventFlow, MockK, Turbine, and coroutine test gotchas
Testing Android Code by the numbers
- 69 all-time installs (skills.sh)
- Ranked #1,099 of 2,153 Testing & QA skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
testing-android-code capabilities & compatibility
- Capabilities
- unit testing · compose testing · repository testing · code review
- Use cases
- testing · debugging
What testing-android-code says it does
This skill provides tactical testing guidance for Bitwarden-specific patterns.
Always extend `BaseViewModelTest` for ViewModel tests.
npx skills add https://github.com/bitwarden/android --skill testing-android-codeAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 69 |
|---|---|
| repo stars | ★ 9.2k |
| Last updated | August 4, 2026 |
| Repository | bitwarden/android ↗ |
What it does
Write or fix Bitwarden Android tests using the project's base classes, mock builders, and StateFlow/EventFlow patterns.
Who is it for?
Writing ViewModel, Compose, repository, and service tests in the Bitwarden Android codebase.
Skip if: Non-Bitwarden test suites or non-Android testing.
When should I use this skill?
When writing or reviewing tests for ViewModels, repositories, Compose screens, or data sources, or debugging a failing Bitwarden test.
What you get
Correct, idiomatic Bitwarden Android tests for ViewModels, Compose, repositories, and services.
- ViewModel test patterns
- Compose UI test patterns
- Repository/service test patterns
By the numbers
- 35+ mock data builders
- 7 pattern sections (A-G)
- base classes: BaseViewModelTest, BitwardenComposeTest, BaseServiceTest
Files
Testing Android Code - Bitwarden Testing Patterns
This skill provides tactical testing guidance for Bitwarden-specific patterns. For comprehensive architecture and testing philosophy, consult docs/ARCHITECTURE.md.
Test Framework Configuration
Required Dependencies:
- JUnit 5 (jupiter), MockK, Turbine (app.cash.turbine)
- kotlinx.coroutines.test, Robolectric, Compose Test
Critical Note: Tests run with en-US locale for consistency. Don't assume other locales.
---
A. ViewModel Testing Patterns
Base Class: BaseViewModelTest
Always extend `BaseViewModelTest` for ViewModel tests.
Location: ui/src/testFixtures/kotlin/com/bitwarden/ui/platform/base/BaseViewModelTest.kt
Benefits:
- Automatically registers
MainDispatcherExtensionforUnconfinedTestDispatcher - Provides
stateEventFlow()helper for simultaneous StateFlow/EventFlow testing
Pattern:
class ExampleViewModelTest : BaseViewModelTest() {
private val mockRepository: ExampleRepository = mockk()
private val savedStateHandle = SavedStateHandle(mapOf(KEY_STATE to INITIAL_STATE))
@Test
fun `ButtonClick should fetch data and update state`() = runTest {
coEvery { mockRepository.fetchData(any()) } returns Result.success("data")
val viewModel = ExampleViewModel(savedStateHandle, mockRepository)
viewModel.stateFlow.test {
assertEquals(INITIAL_STATE, awaitItem())
viewModel.trySendAction(ExampleAction.ButtonClick)
assertEquals(INITIAL_STATE.copy(data = "data"), awaitItem())
}
coVerify { mockRepository.fetchData(any()) }
}
}For complete examples: See references/test-base-classes.md
StateFlow vs EventFlow (Critical Distinction)
| Flow Type | Replay | First Action | Pattern |
|---|---|---|---|
| StateFlow | Yes (1) | awaitItem() gets current state | Expect initial → trigger → expect new |
| EventFlow | No | expectNoEvents() first | expectNoEvents → trigger → expect event |
For detailed patterns: See references/flow-testing-patterns.md
---
B. Compose UI Testing Patterns
Base Class: BitwardenComposeTest
Always extend `BitwardenComposeTest` for Compose screen tests.
Location: app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/base/BitwardenComposeTest.kt
Benefits:
- Pre-configures all Bitwarden managers (FeatureFlags, AuthTab, Biometrics, etc.)
- Wraps content in
BitwardenThemeandLocalManagerProvider - Provides fixed Clock for deterministic time-based tests
Pattern:
class ExampleScreenTest : BitwardenComposeTest() {
private var haveCalledNavigateBack = false
private val mutableEventFlow = bufferedMutableSharedFlow<ExampleEvent>()
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
private val viewModel = mockk<ExampleViewModel>(relaxed = true) {
every { eventFlow } returns mutableEventFlow
every { stateFlow } returns mutableStateFlow
}
@Before
fun setup() {
setContent {
ExampleScreen(
onNavigateBack = { haveCalledNavigateBack = true },
viewModel = viewModel,
)
}
}
@Test
fun `on back click should send BackClick action`() {
composeTestRule.onNodeWithContentDescription("Back").performClick()
verify { viewModel.trySendAction(ExampleAction.BackClick) }
}
}Note: Use bufferedMutableSharedFlow for event testing in Compose tests. Default replay is 0; pass replay = 1 if needed.
For complete base class details: See references/test-base-classes.md
---
C. Repository and Service Testing
Service Testing with MockWebServer
Base Class: BaseServiceTest (network/src/testFixtures/)
class ExampleServiceTest : BaseServiceTest() {
private val api: ExampleApi = retrofit.create()
private val service = ExampleServiceImpl(api)
@Test
fun `getConfig should return success when API succeeds`() = runTest {
server.enqueue(MockResponse().setBody(EXPECTED_JSON))
val result = service.getConfig()
assertEquals(EXPECTED_RESPONSE.asSuccess(), result)
}
}Repository Testing Pattern
class ExampleRepositoryTest {
private val fixedClock: Clock = Clock.fixed(
Instant.parse("2023-10-27T12:00:00Z"),
ZoneOffset.UTC,
)
private val dispatcherManager = FakeDispatcherManager()
private val mockDiskSource: ExampleDiskSource = mockk()
private val mockService: ExampleService = mockk()
private val repository = ExampleRepositoryImpl(
clock = fixedClock,
exampleDiskSource = mockDiskSource,
exampleService = mockService,
dispatcherManager = dispatcherManager,
)
@Test
fun `fetchData should return success when service succeeds`() = runTest {
coEvery { mockService.getData(any()) } returns expectedData.asSuccess()
val result = repository.fetchData(userId)
assertTrue(result.isSuccess)
}
}Key patterns: Use FakeDispatcherManager, fixed Clock, and .asSuccess() helpers.
---
D. Test Data Builders
Builder Pattern with Number Parameter
Location: network/src/testFixtures/kotlin/com/bitwarden/network/model/
fun createMockCipher(
number: Int,
id: String = "mockId-$number",
name: String? = "mockName-$number",
// ... more parameters with defaults
): SyncResponseJson.Cipher
// Usage:
val cipher1 = createMockCipher(number = 1) // mockId-1, mockName-1
val cipher2 = createMockCipher(number = 2) // mockId-2, mockName-2
val custom = createMockCipher(number = 3, name = "Custom")Available Builders (35+):
- Cipher:
createMockCipher(),createMockLogin(),createMockCard(),createMockIdentity(),createMockSecureNote(),createMockSshKey(),createMockField(),createMockUri(),createMockFido2Credential(),createMockPasswordHistory(),createMockCipherPermissions() - Sync:
createMockSyncResponse(),createMockFolder(),createMockCollection(),createMockPolicy(),createMockDomains() - Send:
createMockSend(),createMockFile(),createMockText(),createMockSendJsonRequest() - Profile:
createMockProfile(),createMockOrganization(),createMockProvider(),createMockPermissions() - Attachments:
createMockAttachment(),createMockAttachmentJsonRequest(),createMockAttachmentResponse()
See network/src/testFixtures/kotlin/com/bitwarden/network/model/ for full list.
---
E. Result Type Testing
Locations:
.asSuccess(),.asFailure():core/src/main/kotlin/com/bitwarden/core/data/util/ResultExtensions.ktassertCoroutineThrows:core/src/testFixtures/kotlin/com/bitwarden/core/data/util/TestHelpers.kt
// Create results
"data".asSuccess() // Result.success("data")
throwable.asFailure() // Result.failure<T>(throwable)
// Assertions
assertTrue(result.isSuccess)
assertEquals(expectedValue, result.getOrNull())---
F. Test Utilities and Helpers
Fake Implementations
| Fake | Location | Purpose |
|---|---|---|
FakeDispatcherManager | core/src/testFixtures/ | Deterministic coroutine execution |
FakeConfigDiskSource | data/src/testFixtures/ | In-memory config storage |
FakeSharedPreferences | data/src/testFixtures/ | Memory-backed SharedPreferences |
Exception Testing (CRITICAL)
// CORRECT - Call directly, NOT inside runTest
@Test
fun `test exception`() {
assertCoroutineThrows<IllegalStateException> {
repository.throwingFunction()
}
}Why: runTest catches exceptions and rethrows them, breaking the assertion pattern.
---
G. Critical Gotchas
Common testing mistakes in Bitwarden. For complete details and examples: See references/critical-gotchas.md
Core Patterns:
- assertCoroutineThrows + runTest - Never wrap in
runTest; call directly - Static mock cleanup - Always
unmockkStatic()in@After - StateFlow vs EventFlow - StateFlow:
awaitItem()first; EventFlow:expectNoEvents()first - FakeDispatcherManager - Always use instead of real
DispatcherManagerImpl - Coroutine test wrapper - Use
runTest { }for all Flow/coroutine tests
Assertion Patterns:
- Complete state assertions - Assert entire state objects, not individual fields
- JUnit over Kotlin - Use
assertTrue(), not Kotlin'sassert() - Use Result extensions - Use
asSuccess()andasFailure()for Result type assertions
Test Design:
- Fake vs Mock strategy - Use Fakes for happy paths, Mocks for error paths
- DI over static mocking - Extract interfaces (like UuidManager) instead of mockkStatic
- Null stream testing - Test null returns from ContentResolver operations
- bufferedMutableSharedFlow - Use with
.onSubscription { emit(state) }in Fakes - Test factory methods - Accept domain state types, not SavedStateHandle
---
H. Test File Organization
Directory Structure
module/src/test/kotlin/com/bitwarden/.../
├── ui/*ScreenTest.kt, *ViewModelTest.kt
├── data/repository/*RepositoryTest.kt
└── network/service/*ServiceTest.kt
module/src/testFixtures/kotlin/com/bitwarden/.../
├── util/TestHelpers.kt
├── base/Base*Test.kt
└── model/*Util.ktTest Constants Placement
Declare test constants as top-level private const val at the bottom of the file, after the class closing brace. Do NOT use companion object for test constants.
Test Naming
- Classes:
*Test.kt,*ScreenTest.kt,*ViewModelTest.kt - Functions: `
given state when action should result`
---
Summary
Key Bitwarden-specific testing patterns:
1. BaseViewModelTest - Automatic dispatcher setup with stateEventFlow() helper 2. BitwardenComposeTest - Pre-configured with all managers and theme 3. BaseServiceTest - MockWebServer setup for network testing 4. Turbine Flow Testing - StateFlow (replay) vs EventFlow (no replay) 5. Test Data Builders - Consistent number: Int parameter pattern 6. Fake Implementations - FakeDispatcherManager, FakeConfigDiskSource 7. Result Type Testing - .asSuccess(), .asFailure()
Always consult: docs/ARCHITECTURE.md and existing test files for reference implementations.
---
Reference Documentation
For detailed information, see:
references/test-base-classes.md- Detailed base class documentation and usage patternsreferences/flow-testing-patterns.md- Complete Turbine patterns for StateFlow/EventFlowreferences/critical-gotchas.md- Full anti-pattern reference and debugging tips
Complete Examples:
examples/viewmodel-test-example.md- Full ViewModel test with StateFlow/EventFlowexamples/compose-screen-test-example.md- Full Compose screen testexamples/repository-test-example.md- Full repository test with mocks and fakes
/**
- Complete Compose Screen Test Example
*
- Key patterns demonstrated:
- - Extending BitwardenComposeTest
- - Mocking ViewModel with flows
- - Testing UI interactions
- - Testing navigation callbacks
- - Using bufferedMutableSharedFlow for events
- - Testing dialogs with isDialog() and hasAnyAncestor()
*/ package com.bitwarden.example.feature
import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.filterToOne import androidx.compose.ui.test.hasAnyAncestor import androidx.compose.ui.test.isDialog import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow import com.bitwarden.ui.util.assertNoDialogExists import com.bitwarden.ui.util.isProgressBar import com.x8bit.bitwarden.ui.platform.base.BitwardenComposeTest import io.mockk.every import io.mockk.mockk import io.mockk.verify import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update import junit.framework.TestCase.assertTrue import org.junit.Before import org.junit.Test
class ExampleScreenTest : BitwardenComposeTest() {
// Track navigation callbacks private var haveCalledNavigateBack = false private var haveCalledNavigateToNext = false
// Use bufferedMutableSharedFlow for events (default replay = 0) private val mutableEventFlow = bufferedMutableSharedFlow<ExampleEvent>() private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
// Mock ViewModel with relaxed = true private val viewModel = mockk<ExampleViewModel>(relaxed = true) { every { eventFlow } returns mutableEventFlow every { stateFlow } returns mutableStateFlow }
@Before fun setup() { haveCalledNavigateBack = false haveCalledNavigateToNext = false
setContent { ExampleScreen( onNavigateBack = { haveCalledNavigateBack = true }, onNavigateToNext = { haveCalledNavigateToNext = true }, viewModel = viewModel, ) } }
/**
- Test: Back button sends action to ViewModel
*/ @Test fun on back click should send BackClick action() { composeTestRule .onNodeWithContentDescription("Back") .performClick()
verify { viewModel.trySendAction(ExampleAction.BackClick) } }
/**
- Test: Submit button sends action to ViewModel
*/ @Test fun on submit click should send SubmitClick action() { composeTestRule .onNodeWithText("Submit") .performClick()
verify { viewModel.trySendAction(ExampleAction.SubmitClick) } }
/**
- Test: Loading state shows progress indicator
*/ @Test fun loading state should display progress indicator() { mutableStateFlow.update { it.copy(isLoading = true) }
composeTestRule .onNode(isProgressBar) .assertIsDisplayed() }
/**
- Test: Data state shows content
*/ @Test fun data state should display content() { mutableStateFlow.update { it.copy(data = "Test Data") }
composeTestRule .onNodeWithText("Test Data") .assertIsDisplayed() }
/**
- Test: Error state shows error message
*/ @Test fun error state should display error message() { mutableStateFlow.update { it.copy(errorMessage = "Something went wrong") }
composeTestRule .onNodeWithText("Something went wrong") .assertIsDisplayed() }
/**
- Test: NavigateBack event triggers navigation callback
*/ @Test fun NavigateBack event should call onNavigateBack() { mutableEventFlow.tryEmit(ExampleEvent.NavigateBack)
assertTrue(haveCalledNavigateBack) }
/**
- Test: NavigateToNext event triggers navigation callback
*/ @Test fun NavigateToNext event should call onNavigateToNext() { mutableEventFlow.tryEmit(ExampleEvent.NavigateToNext)
assertTrue(haveCalledNavigateToNext) }
/**
- Test: Item in list can be clicked
*/ @Test fun on item click should send ItemClick action() { val itemId = "item-123" mutableStateFlow.update { it.copy(items = listOf(ExampleItem(id = itemId, name = "Test Item"))) }
composeTestRule .onNodeWithText("Test Item") .performClick()
verify { viewModel.trySendAction(ExampleAction.ItemClick(itemId)) } }
// ==================== DIALOG TESTS ====================
/**
- Test: No dialog exists when dialogState is null
*/ @Test fun no dialog should exist when dialogState is null() { mutableStateFlow.update { it.copy(dialogState = null) }
composeTestRule.assertNoDialogExists() }
/**
- Test: Loading dialog displays when state updates
- PATTERN: Use isDialog() to check dialog exists
*/ @Test fun loading dialog should display when dialogState is Loading() { mutableStateFlow.update { it.copy(dialogState = ExampleState.DialogState.Loading("Please wait...")) }
composeTestRule .onNode(isDialog()) .assertIsDisplayed()
// Verify loading text within dialog using hasAnyAncestor(isDialog()) composeTestRule .onAllNodesWithText("Please wait...") .filterToOne(hasAnyAncestor(isDialog())) .assertIsDisplayed() }
/**
- Test: Error dialog displays title and message
- PATTERN: Use filterToOne(hasAnyAncestor(isDialog())) to find text within dialogs
*/ @Test fun error dialog should display title and message() { mutableStateFlow.update { it.copy( dialogState = ExampleState.DialogState.Error( title = "An error has occurred", message = "Something went wrong. Please try again.", ), ) }
// Verify dialog exists composeTestRule .onNode(isDialog()) .assertIsDisplayed()
// Verify title within dialog composeTestRule .onAllNodesWithText("An error has occurred") .filterToOne(hasAnyAncestor(isDialog())) .assertIsDisplayed()
// Verify message within dialog composeTestRule .onAllNodesWithText("Something went wrong. Please try again.") .filterToOne(hasAnyAncestor(isDialog())) .assertIsDisplayed() }
/**
- Test: Dialog button click sends action
- PATTERN: Find button with hasAnyAncestor(isDialog()) then performClick()
*/ @Test fun error dialog dismiss button should send DismissDialog action() { mutableStateFlow.update { it.copy( dialogState = ExampleState.DialogState.Error( title = "Error", message = "An error occurred", ), ) }
// Click dismiss button within dialog composeTestRule .onAllNodesWithText("Ok") .filterToOne(hasAnyAncestor(isDialog())) .performClick()
verify { viewModel.trySendAction(ExampleAction.DismissDialog) } }
/**
- Test: Confirmation dialog with multiple buttons
- PATTERN: Test both confirm and cancel actions
*/ @Test fun confirmation dialog confirm button should send ConfirmAction() { mutableStateFlow.update { it.copy( dialogState = ExampleState.DialogState.Confirmation( title = "Confirm Action", message = "Are you sure you want to proceed?", ), ) }
// Click confirm button composeTestRule .onAllNodesWithText("Confirm") .filterToOne(hasAnyAncestor(isDialog())) .performClick()
verify { viewModel.trySendAction(ExampleAction.ConfirmAction) } }
@Test fun confirmation dialog cancel button should send DismissDialog action() { mutableStateFlow.update { it.copy( dialogState = ExampleState.DialogState.Confirmation( title = "Confirm Action", message = "Are you sure?", ), ) }
// Click cancel button composeTestRule .onAllNodesWithText("Cancel") .filterToOne(hasAnyAncestor(isDialog())) .performClick()
verify { viewModel.trySendAction(ExampleAction.DismissDialog) } } }
private val DEFAULT_STATE = ExampleState( isLoading = false, data = null, errorMessage = null, items = emptyList(), dialogState = null, )
// Example types (normally in separate files) data class ExampleState( val isLoading: Boolean = false, val data: String? = null, val errorMessage: String? = null, val items: List<ExampleItem> = emptyList(), val dialogState: DialogState? = null, ) { /**
- PATTERN: Nested sealed class for dialog states.
- Common dialog types: Loading, Error, Confirmation
*/ sealed class DialogState { data class Loading(val message: String) : DialogState() data class Error(val title: String, val message: String) : DialogState() data class Confirmation(val title: String, val message: String) : DialogState() } }
data class ExampleItem(val id: String, val name: String)
sealed class ExampleAction { data object BackClick : ExampleAction() data object SubmitClick : ExampleAction() data class ItemClick(val itemId: String) : ExampleAction() data object DismissDialog : ExampleAction() data object ConfirmAction : ExampleAction() }
sealed class ExampleEvent { data object NavigateBack : ExampleEvent() data object NavigateToNext : ExampleEvent() }
/**
- Complete Repository Test Example
*
- Key patterns demonstrated:
- - Fake for disk sources, Mock for network services
- - Using FakeDispatcherManager for deterministic coroutines
- - Using fixed Clock for deterministic time
- - Testing Result types with .asSuccess() / .asFailure()
- - Asserting actual objects (not isSuccess/isFailure) for better diagnostics
- - Testing Flow emissions with Turbine
*/ package com.bitwarden.example.data.repository
import app.cash.turbine.test import com.bitwarden.core.data.manager.dispatcher.FakeDispatcherManager import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow import com.bitwarden.core.data.util.asFailure import com.bitwarden.core.data.util.asSuccess import io.mockk.coEvery import io.mockk.coVerify import io.mockk.mockk import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.onSubscription import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import java.time.Clock import java.time.Instant import java.time.ZoneOffset
class ExampleRepositoryTest {
// Fixed clock for deterministic time-based tests private val fixedClock: Clock = Clock.fixed( Instant.parse("2023-10-27T12:00:00Z"), ZoneOffset.UTC, )
// Use FakeDispatcherManager for deterministic coroutine execution private val dispatcherManager = FakeDispatcherManager()
// Mock service (network layer is always mocked) private val mockService: ExampleService = mockk()
/**
- PATTERN: Use Fake for disk source in happy path tests.
- This is the Bitwarden convention for repository testing.
*/ private val fakeDiskSource = FakeExampleDiskSource()
private lateinit var repository: ExampleRepositoryImpl
@BeforeEach fun setup() { repository = ExampleRepositoryImpl( clock = fixedClock, service = mockService, diskSource = fakeDiskSource, dispatcherManager = dispatcherManager, ) }
// ==================== HAPPY PATH TESTS (use Fake) ====================
/**
- Test: Successful fetch returns data and saves to disk
*/ @Test fun fetchData should return success and save to disk when service succeeds() = runTest { val expectedData = ExampleData(id = "1", name = "Test", updatedAt = fixedClock.instant()) coEvery { mockService.getData() } returns expectedData.asSuccess()
val result = repository.fetchData()
assertEquals(expectedData, result.getOrThrow()) // Fake automatically stores the data - verify it's there assertEquals(expectedData, fakeDiskSource.storedData) }
/**
- Test: Service failure returns failure without saving
*/ @Test fun fetchData should return failure when service fails() = runTest { val exception = Exception("Network error") coEvery { mockService.getData() } returns exception.asFailure()
val result = repository.fetchData()
assertEquals(exception, result.exceptionOrNull()) // Fake was not updated assertNull(fakeDiskSource.storedData) }
/**
- Test: Repository flow emits when disk source updates
*/ @Test fun dataFlow should emit when disk source updates() = runTest { val data1 = ExampleData(id = "1", name = "First", updatedAt = fixedClock.instant()) val data2 = ExampleData(id = "2", name = "Second", updatedAt = fixedClock.instant())
repository.dataFlow.test { // Initial null value from Fake assertNull(awaitItem())
// Update via Fake property setter (triggers emission) fakeDiskSource.storedData = data1 assertEquals(data1, awaitItem())
// Another update fakeDiskSource.storedData = data2 assertEquals(data2, awaitItem()) } }
/**
- Test: Refresh fetches and saves new data
*/ @Test fun refresh should fetch new data and update disk source() = runTest { val newData = ExampleData(id = "new", name = "Fresh", updatedAt = fixedClock.instant()) coEvery { mockService.getData() } returns newData.asSuccess()
val result = repository.refresh()
assertEquals(Unit, result.getOrThrow()) coVerify { mockService.getData() } assertEquals(newData, fakeDiskSource.storedData) }
/**
- Test: Delete clears data from disk
*/ @Test fun deleteData should clear disk source() = runTest { // Pre-populate the fake fakeDiskSource.storedData = ExampleData(id = "1", name = "Test", updatedAt = fixedClock.instant())
repository.deleteData()
assertNull(fakeDiskSource.storedData) }
/**
- Test: Cached data returns from disk when available
*/ @Test fun getCachedData should return disk data without network call() = runTest { val cachedData = ExampleData( id = "cached", name = "Cached", updatedAt = fixedClock.instant(), ) fakeDiskSource.storedData = cachedData
val result = repository.getCachedData()
assertEquals(cachedData, result) coVerify(exactly = 0) { mockService.getData() } }
// ==================== ERROR PATH TESTS ====================
/**
- PATTERN: For error paths, reconfigure the class-level mock per-test.
- Use coEvery to change mock behavior for each specific test case.
*/ @Test fun fetchData should return failure when service returns error() = runTest { val exception = Exception("Server unavailable") coEvery { mockService.getData() } returns exception.asFailure()
val result = repository.fetchData()
assertEquals(exception, result.exceptionOrNull()) // Fake state unchanged on failure assertNull(fakeDiskSource.storedData) }
@Test fun refresh should return failure and preserve cached data when service fails() = runTest { // Pre-populate cache via Fake val cachedData = ExampleData(id = "cached", name = "Old", updatedAt = fixedClock.instant()) fakeDiskSource.storedData = cachedData
// Reconfigure mock to return failure coEvery { mockService.getData() } returns Exception("Network error").asFailure()
val result = repository.refresh()
assertTrue(result.isFailure) // Cached data preserved on failure assertEquals(cachedData, fakeDiskSource.storedData) } }
// Example types (normally in separate files) data class ExampleData( val id: String, val name: String, val updatedAt: Instant, )
interface ExampleService { suspend fun getData(): Result<ExampleData> }
interface ExampleDiskSource { val dataFlow: kotlinx.coroutines.flow.Flow<ExampleData?> fun getData(): ExampleData? fun saveData(data: ExampleData) fun clearData() }
/**
- PATTERN: Fake implementation for happy path testing.
*
- Key characteristics:
- - Uses bufferedMutableSharedFlow(replay = 1) for proper replay behavior
- - Uses .onSubscription { emit(state) } for immediate state emission
- - Private storage with override property setter that emits to flow
- - Test assertions done via the override property getter
*/ class FakeExampleDiskSource : ExampleDiskSource { private var storedDataValue: ExampleData? = null private val mutableDataFlow = bufferedMutableSharedFlow<ExampleData?>(replay = 1)
/**
- Override property with getter/setter. Setter emits to flow automatically.
- Tests can read this property for assertions and write to trigger emissions.
*/ var storedData: ExampleData? get() = storedDataValue set(value) { storedDataValue = value mutableDataFlow.tryEmit(value) }
override val dataFlow: Flow<ExampleData?> get() = mutableDataFlow.onSubscription { emit(storedData) }
override fun getData(): ExampleData? = storedData
override fun saveData(data: ExampleData) { storedData = data }
override fun clearData() { storedData = null } }
/**
- Complete ViewModel Test Example
*
- Key patterns demonstrated:
- - Extending BaseViewModelTest
- - Testing StateFlow with Turbine
- - Testing EventFlow with Turbine
- - Using stateEventFlow() for simultaneous testing
- - MockK mocking patterns
- - Test factory method design (accepts domain state, not SavedStateHandle)
- - Complete state assertions (assert entire state objects)
*/ package com.bitwarden.example.feature
import androidx.lifecycle.SavedStateHandle import app.cash.turbine.test import com.bitwarden.ui.platform.base.BaseViewModelTest import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test
class ExampleViewModelTest : BaseViewModelTest() {
// Mock dependencies private val mockRepository: ExampleRepository = mockk() private val mockAuthDiskSource: AuthDiskSource = mockk { every { userStateFlow } returns MutableStateFlow(null) }
/**
- StateFlow has replay=1, so first awaitItem() returns current state
*/ @Test fun initial state should be default state() = runTest { val viewModel = createViewModel()
viewModel.stateFlow.test { assertEquals(DEFAULT_STATE, awaitItem()) } }
/**
- Test state transitions: initial -> loading -> success
*/ @Test fun LoadData action should update state from idle to loading to success() = runTest { val expectedData = "loaded data" coEvery { mockRepository.fetchData(any()) } returns Result.success(expectedData)
val viewModel = createViewModel()
viewModel.stateFlow.test { assertEquals(DEFAULT_STATE, awaitItem())
viewModel.trySendAction(ExampleAction.LoadData)
assertEquals(DEFAULT_STATE.copy(isLoading = true), awaitItem()) assertEquals(DEFAULT_STATE.copy(isLoading = false, data = expectedData), awaitItem()) }
coVerify { mockRepository.fetchData(any()) } }
/**
- EventFlow has no replay - MUST call expectNoEvents() first
*/ @Test fun SubmitClick action should emit NavigateToNext event() = runTest { coEvery { mockRepository.submitData(any()) } returns Result.success(Unit)
val viewModel = createViewModel()
viewModel.eventFlow.test { expectNoEvents() // CRITICAL for EventFlow viewModel.trySendAction(ExampleAction.SubmitClick) assertEquals(ExampleEvent.NavigateToNext, awaitItem()) } }
/**
- Use stateEventFlow() helper for simultaneous testing
*/ @Test fun complex action should update state and emit event() = runTest { coEvery { mockRepository.complexOperation(any()) } returns Result.success("result")
val viewModel = createViewModel()
viewModel.stateEventFlow(backgroundScope) { stateFlow, eventFlow -> assertEquals(DEFAULT_STATE, stateFlow.awaitItem()) eventFlow.expectNoEvents()
viewModel.trySendAction(ExampleAction.ComplexAction)
assertEquals(DEFAULT_STATE.copy(isLoading = true), stateFlow.awaitItem()) assertEquals(DEFAULT_STATE.copy(data = "result"), stateFlow.awaitItem()) assertEquals(ExampleEvent.ShowToast("Success!"), eventFlow.awaitItem()) } }
/**
- Test state restoration from saved state.
- Note: Use initialState parameter, NOT SavedStateHandle directly.
*/ @Test fun initial state from saved state should be preserved() = runTest { // Build complete expected state - always assert full objects val savedState = ExampleState( isLoading = false, data = "restored data", errorMessage = null, )
val viewModel = createViewModel(initialState = savedState)
viewModel.stateFlow.test { assertEquals(savedState, awaitItem()) } }
/**
- Factory method accepts domain state, NOT SavedStateHandle.
- This hides Android framework details from test logic.
*/ private fun createViewModel( initialState: ExampleState? = null, ): ExampleViewModel = ExampleViewModel( savedStateHandle = SavedStateHandle().apply { set("state", initialState) }, repository = mockRepository, authDiskSource = mockAuthDiskSource, ) }
private val DEFAULT_STATE = ExampleState( isLoading = false, data = null, errorMessage = null, )
// Example types (normally in separate files) data class ExampleState( val isLoading: Boolean = false, val data: String? = null, val errorMessage: String? = null, )
sealed class ExampleAction { data object LoadData : ExampleAction() data object SubmitClick : ExampleAction() data object ComplexAction : ExampleAction() }
sealed class ExampleEvent { data object NavigateToNext : ExampleEvent() data class ShowToast(val message: String) : ExampleEvent() }
Critical Gotchas and Anti-Patterns
Common mistakes and pitfalls when writing tests in the Bitwarden Android codebase.
❌ NEVER wrap assertCoroutineThrows in runTest
The Problem
runTest catches exceptions and rethrows them, which breaks the assertCoroutineThrows assertion pattern.
Wrong
@Test
fun `test exception`() = runTest {
assertCoroutineThrows<Exception> {
repository.throwingFunction()
} // Won't work - exception is caught by runTest!
}Correct
@Test
fun `test exception`() {
assertCoroutineThrows<Exception> {
repository.throwingFunction()
} // Works correctly
}Why This Happens
runTest provides a coroutine scope and catches exceptions to provide better error messages. However, assertCoroutineThrows needs to catch the exception itself to verify it was thrown. When wrapped in runTest, the exception is caught twice, breaking the assertion.
❌ ALWAYS unmock static functions
The Problem
MockK's static mocking persists across tests. Forgetting to clean up causes mysterious failures in subsequent tests.
Wrong
@Before
fun setup() {
mockkStatic(::isBuildVersionAtLeast)
every { isBuildVersionAtLeast(any()) } returns true
}
// Forgot @After - subsequent tests will fail mysteriously!Correct
@Before
fun setup() {
mockkStatic(::isBuildVersionAtLeast)
every { isBuildVersionAtLeast(any()) } returns true
}
@After
fun tearDown() {
unmockkStatic(::isBuildVersionAtLeast) // CRITICAL
}Common Static Functions to Watch
// Platform version checks
mockkStatic(::isBuildVersionAtLeast)
unmockkStatic(::isBuildVersionAtLeast)
// URI parsing
mockkStatic(Uri::class)
unmockkStatic(Uri::class)
// Static utility functions
mockkStatic(MyUtilClass::class)
unmockkStatic(MyUtilClass::class)Debugging Tip
If tests pass individually but fail when run together, suspect static mocking cleanup issues.
❌ Don't confuse StateFlow and EventFlow testing
StateFlow (replay = 1)
// CORRECT - StateFlow always has current value
viewModel.stateFlow.test {
val initial = awaitItem() // Gets current state immediately
viewModel.trySendAction(action)
val updated = awaitItem() // Gets new state
}EventFlow (no replay)
// CORRECT - EventFlow has no initial value
viewModel.eventFlow.test {
expectNoEvents() // MUST do this first
viewModel.trySendAction(action)
val event = awaitItem() // Gets emitted event
}Common Mistake
// WRONG - Forgetting expectNoEvents() on EventFlow
viewModel.eventFlow.test {
viewModel.trySendAction(action) // May cause flaky tests
assertEquals(event, awaitItem())
}❌ Don't mix real and test dispatchers
Wrong
private val repository = ExampleRepositoryImpl(
dispatcherManager = DispatcherManagerImpl(), // Real dispatcher!
)
@Test
fun `test repository`() = runTest {
// Test will have timing issues - real dispatcher != test dispatcher
}Correct
private val repository = ExampleRepositoryImpl(
dispatcherManager = FakeDispatcherManager(), // Test dispatcher
)
@Test
fun `test repository`() = runTest {
// Test runs deterministically
}Why This Matters
Real dispatchers use actual thread pools and delays. Test dispatchers (UnconfinedTestDispatcher) execute immediately and deterministically. Mixing them causes:
- Non-deterministic test failures
- Real delays in tests (slow test suite)
- Race conditions
Always Use
FakeDispatcherManager()for repositoriesUnconfinedTestDispatcher()when manually creating dispatchersrunTestfor coroutine tests (provides TestDispatcher automatically)
❌ Don't forget to use runTest for coroutine tests
Wrong
@Test
fun `test coroutine`() {
viewModel.stateFlow.test { /* ... */ } // Missing runTest!
}This causes:
- Test completes before coroutines finish
- False positives (test passes but assertions never run)
- Mysterious failures
Correct
@Test
fun `test coroutine`() = runTest {
viewModel.stateFlow.test { /* ... */ }
}When runTest is Required
- Testing ViewModels (they use
viewModelScope) - Testing Flows with Turbine
.test {} - Testing repositories with suspend functions
- Any test calling suspend functions
Exception: assertCoroutineThrows
As noted above, assertCoroutineThrows should NOT be wrapped in runTest.
❌ Don't forget relaxed = true for complex mocks
Without relaxed
private val viewModel = mockk<ExampleViewModel>() // Must mock every method!
// Error: "no answer found for: stateFlow"With relaxed
private val viewModel = mockk<ExampleViewModel>(relaxed = true) {
// Only mock what you care about
every { stateFlow } returns mutableStateFlow
every { eventFlow } returns mutableEventFlow
}When to Use relaxed
- Mocking ViewModels in Compose tests
- Mocking complex objects with many methods
- When you only care about specific method calls
When NOT to Use relaxed
- Mocking repository interfaces (be explicit about behavior)
- When you want to verify NO unexpected calls
- Testing error paths (want test to fail if unexpected method called)
❌ Don't assert individual fields when complete state is available
The Problem
Asserting individual state fields can miss unintended side effects on other fields.
Wrong
@Test
fun `action should update state`() = runTest {
viewModel.trySendAction(SomeAction.DoThing)
val state = viewModel.stateFlow.value
assertEquals(null, state.dialog) // Only checks one field!
}Correct
@Test
fun `action should update state`() = runTest {
viewModel.trySendAction(SomeAction.DoThing)
val expected = SomeState(
isLoading = false,
data = "result",
dialog = null,
)
assertEquals(expected, viewModel.stateFlow.value) // Checks all fields
}Why This Matters
- Catches unintended mutations to other state fields
- Makes expected state explicit and readable
- Prevents silent regressions when state structure changes
---
❌ Don't use Kotlin assert() for boolean checks
The Problem
Kotlin's assert() doesn't follow JUnit conventions and provides poor failure messages.
Wrong
@Test
fun `event should trigger callback`() {
mutableEventFlow.tryEmit(SomeEvent.Navigate)
assert(onNavigateCalled) // Kotlin assert - bad failure messages
}Correct
@Test
fun `event should trigger callback`() {
mutableEventFlow.tryEmit(SomeEvent.Navigate)
assertTrue(onNavigateCalled) // JUnit assertTrue - proper assertion
}Always Use JUnit Assertions
assertTrue()/assertFalse()for booleansassertEquals()for value comparisonsassertNotNull()/assertNull()for nullabilityassertThrows<T>()for exceptions
---
❌ Don't pass SavedStateHandle to test factory methods
The Problem
Exposing SavedStateHandle in test factory methods leaks Android framework details into test logic.
Wrong
private fun createViewModel(
savedStateHandle: SavedStateHandle = SavedStateHandle(), // Framework type exposed
): MyViewModel = MyViewModel(
savedStateHandle = savedStateHandle,
repository = mockRepository,
)
@Test
fun `initial state from saved state`() = runTest {
val savedState = MyState(isLoading = true)
val savedStateHandle = SavedStateHandle(mapOf("state" to savedState))
val viewModel = createViewModel(savedStateHandle = savedStateHandle)
// ...
}Correct
private fun createViewModel(
initialState: MyState? = null, // Domain type only
): MyViewModel = MyViewModel(
savedStateHandle = SavedStateHandle().apply { set("state", initialState) },
repository = mockRepository,
)
@Test
fun `initial state from saved state`() = runTest {
val savedState = MyState(isLoading = true)
val viewModel = createViewModel(initialState = savedState)
// ...
}Why This Matters
- Cleaner, more intuitive test code
- Hides SavedStateHandle implementation details
- Follows Bitwarden conventions
---
❌ Don't test SavedStateHandle persistence in unit tests
The Problem
Testing whether state persists to SavedStateHandle is testing Android framework behavior, not your business logic.
Wrong
@Test
fun `state should persist to SavedStateHandle`() = runTest {
val savedStateHandle = SavedStateHandle()
val viewModel = createViewModel(savedStateHandle = savedStateHandle)
viewModel.trySendAction(SomeAction)
val savedState = savedStateHandle.get<MyState>("state")
assertEquals(expectedState, savedState) // Testing framework, not logic!
}Correct
Focus on testing business logic and state transformations:
@Test
fun `action should update state correctly`() = runTest {
val viewModel = createViewModel()
viewModel.trySendAction(SomeAction)
assertEquals(expectedState, viewModel.stateFlow.value) // Test observable state
}---
❌ Don't use static mocking when DI pattern is available
The Problem
Static mocking (mockkStatic) is harder to maintain and less testable than dependency injection.
Wrong
class ParserTest {
@BeforeEach
fun setup() {
mockkStatic(UUID::class)
every { UUID.randomUUID() } returns mockk {
every { toString() } returns "fixed-uuid"
}
}
@AfterEach
fun tearDown() {
unmockkStatic(UUID::class)
}
}Correct
Extract an interface and inject it:
// Production code
interface UuidManager {
fun generateUuid(): String
}
class UuidManagerImpl : UuidManager {
override fun generateUuid(): String = UUID.randomUUID().toString()
}
class Parser(private val uuidManager: UuidManager) { ... }
// Test code
class ParserTest {
private val mockUuidManager = mockk<UuidManager>()
@BeforeEach
fun setup() {
every { mockUuidManager.generateUuid() } returns "fixed-uuid"
}
// No tearDown needed - no static mocking!
}When to Use This Pattern
- UUID generation
- Timestamp/Clock operations
- System property access
- Any static function that needs deterministic testing
---
❌ Don't forget to test null stream returns from Android APIs
The Problem
Android's ContentResolver.openOutputStream() and openInputStream() can return null, not just throw exceptions.
Wrong
class FileManagerTest {
@Test
fun `stringToUri with exception should return false`() = runTest {
every { mockContentResolver.openOutputStream(any()) } throws IOException()
val result = fileManager.stringToUri(mockUri, "data")
assertFalse(result)
}
// Missing: test for null return!
}Correct
class FileManagerTest {
@Test
fun `stringToUri with exception should return false`() = runTest {
every { mockContentResolver.openOutputStream(any()) } throws IOException()
val result = fileManager.stringToUri(mockUri, "data")
assertFalse(result)
}
@Test
fun `stringToUri with null stream should return false`() = runTest {
every { mockContentResolver.openOutputStream(any()) } returns null
val result = fileManager.stringToUri(mockUri, "data")
assertFalse(result) // CRITICAL: must handle null!
}
}Common Android APIs That Return Null
ContentResolver.openOutputStream()/openInputStream()Context.getExternalFilesDir()PackageManager.getApplicationInfo()(can throw)
---
Bitwarden Mocking Guidelines
Mock at architectural boundaries:
- Repository → ViewModel (mock repository)
- Service → Repository (mock service)
- API → Service (use MockWebServer, not mocks)
- DiskSource → Repository (mock disk source)
Fake vs Mock Strategy (IMPORTANT):
- Happy paths: Use Fake implementations (
FakeAuthenticatorDiskSource,FakeVaultDiskSource) - Error paths: Use MockK with isolated repository instances
// Happy path - use Fake
private val fakeDiskSource = FakeAuthenticatorDiskSource()
@Test
fun `createItem should return Success`() = runTest {
val result = repository.createItem(mockItem)
assertEquals(CreateItemResult.Success, result)
}
// Error path - use isolated Mock
@Test
fun `createItem with exception should return Error`() = runTest {
val mockDiskSource = mockk<AuthenticatorDiskSource> {
coEvery { saveItem(any()) } throws RuntimeException()
}
val repository = RepositoryImpl(diskSource = mockDiskSource)
val result = repository.createItem(mockItem)
assertEquals(CreateItemResult.Error, result)
}Use Fakes for:
FakeDispatcherManager- deterministic coroutinesFakeConfigDiskSource- in-memory config storageFakeSharedPreferences- memory-backed preferencesFakeAuthenticatorDiskSource- in-memory authenticator storage
Create real instances for:
- Data classes, value objects (User, Config, CipherView)
- Test data builders (
createMockCipher(number = 1))
❌ Don't forget bufferedMutableSharedFlow with onSubscription for Fakes
The Problem
Fake data sources using MutableSharedFlow won't emit cached state to new subscribers without explicit handling.
Wrong
class FakeDataSource : DataSource {
private val mutableFlow = MutableSharedFlow<List<Item>>()
private val storedItems = mutableListOf<Item>()
override fun getItems(): Flow<List<Item>> = mutableFlow
override suspend fun saveItem(item: Item) {
storedItems.add(item)
mutableFlow.emit(storedItems)
}
}
// Test: Initial collection gets nothing!
repository.dataFlow.test {
// Hangs or fails - no initial emission
}Correct
class FakeDataSource : DataSource {
private val mutableFlow = bufferedMutableSharedFlow<List<Item>>()
private val storedItems = mutableListOf<Item>()
override fun getItems(): Flow<List<Item>> = mutableFlow
.onSubscription { emit(storedItems.toList()) }
override suspend fun saveItem(item: Item) {
storedItems.add(item)
mutableFlow.emit(storedItems.toList())
}
}
// Test: Initial collection receives current state
repository.dataFlow.test {
assertEquals(emptyList(), awaitItem()) // Works!
}Key Points
- Use
bufferedMutableSharedFlow()fromcore/data/repository/util/ - Add
.onSubscription { emit(currentState) }for immediate state emission - This ensures new collectors receive the current cached state
---
✅ Use Result extension functions for assertions
The Pattern
Use asSuccess() and asFailure() extensions from com.bitwarden.core.data.util for cleaner Result assertions.
Success Path
@Test
fun `getData should return success`() = runTest {
val result = repository.getData()
val expected = expectedData.asSuccess()
assertEquals(expected.getOrNull(), result.getOrNull())
}Failure Path
@Test
fun `getData with error should return failure`() = runTest {
val exception = IOException("Network error")
coEvery { mockService.getData() } returns exception.asFailure()
val result = repository.getData()
assertTrue(result.isFailure)
assertEquals(exception, result.exceptionOrNull())
}Avoid Redundant Assertions
// WRONG - redundant success checks
assertTrue(result.isSuccess)
assertTrue(expected.isSuccess)
assertArrayEquals(expected.getOrNull(), result.getOrNull())
// CORRECT - final assertion is sufficient
assertArrayEquals(expected.getOrNull(), result.getOrNull())---
Summary Checklist
Before submitting tests, verify:
Core Patterns:
- [ ] No
assertCoroutineThrowsinsiderunTest - [ ] All static mocks have
unmockkin@After - [ ] EventFlow tests start with
expectNoEvents() - [ ] Using FakeDispatcherManager, not real dispatchers
- [ ] All coroutine tests use
runTest
Assertion Patterns:
- [ ] Assert complete state objects, not individual fields
- [ ] Use JUnit
assertTrue(), not Kotlinassert() - [ ] Use
asSuccess()for Result type assertions - [ ] Avoid redundant assertion patterns
Test Design:
- [ ] Test factory methods accept domain types, not SavedStateHandle
- [ ] Use Fakes for happy paths, Mocks for error paths
- [ ] Prefer DI patterns over static mocking
- [ ] Test null returns from Android APIs (streams, files)
- [ ] Fakes use
bufferedMutableSharedFlow()with.onSubscription
General:
- [ ] Tests don't depend on execution order
- [ ] Complex mocks use
relaxed = true - [ ] Test data is created fresh for each test
- [ ] Mocking behavior, not value objects
- [ ] Testing observable behavior, not implementation
When tests fail mysteriously, check these gotchas first.
Flow Testing with Turbine
Bitwarden Android uses Turbine for testing Kotlin Flows, including the critical distinction between StateFlow and EventFlow patterns.
StateFlow vs EventFlow
StateFlow (Replayed)
Characteristics:
replay = 1- Always emits current value to new collectors- First
awaitItem()returns the current/initial state - Survives configuration changes
- Used for UI state that needs to be immediately available
Test Pattern:
@Test
fun `action should update state`() = runTest {
val viewModel = MyViewModel(savedStateHandle, mockRepository)
viewModel.stateFlow.test {
// First awaitItem() gets CURRENT state
assertEquals(INITIAL_STATE, awaitItem())
// Trigger action
viewModel.trySendAction(MyAction.LoadData)
// Next awaitItem() gets UPDATED state
assertEquals(LOADING_STATE, awaitItem())
assertEquals(SUCCESS_STATE, awaitItem())
}
}EventFlow (No Replay)
Characteristics:
replay = 0- Only emits new events after subscription- No initial value emission
- One-time events (navigation, toasts, dialogs)
- Does not survive configuration changes
Test Pattern:
@Test
fun `action should emit event`() = runTest {
val viewModel = MyViewModel(savedStateHandle, mockRepository)
viewModel.eventFlow.test {
// MUST call expectNoEvents() first - nothing emitted yet
expectNoEvents()
// Trigger action
viewModel.trySendAction(MyAction.Submit)
// Now expect the event
assertEquals(MyEvent.NavigateToNext, awaitItem())
}
}Critical: Always call expectNoEvents() before triggering actions on EventFlow. Forgetting this causes flaky tests.
Testing State and Events Simultaneously
Use the stateEventFlow() helper from BaseViewModelTest:
@Test
fun `complex action should update state and emit event`() = runTest {
val viewModel = MyViewModel(savedStateHandle, mockRepository)
viewModel.stateEventFlow(backgroundScope) { stateFlow, eventFlow ->
// Initial state
assertEquals(INITIAL_STATE, stateFlow.awaitItem())
// No events yet
eventFlow.expectNoEvents()
// Trigger action
viewModel.trySendAction(MyAction.ComplexAction)
// Verify state progression
assertEquals(LOADING_STATE, stateFlow.awaitItem())
assertEquals(SUCCESS_STATE, stateFlow.awaitItem())
// Verify event emission
assertEquals(MyEvent.ShowToast, eventFlow.awaitItem())
}
}Repository Flow Testing
Testing Database Flows
@Test
fun `dataFlow should emit when database updates`() = runTest {
val dataFlow = MutableStateFlow(initialData)
every { mockDiskSource.dataFlow } returns dataFlow
repository.dataFlow.test {
// Initial value
assertEquals(initialData, awaitItem())
// Update disk source
dataFlow.value = updatedData
// Verify emission
assertEquals(updatedData, awaitItem())
}
}Testing Transformed Flows
@Test
fun `flow transformation should map correctly`() = runTest {
val sourceFlow = MutableStateFlow(UserEntity(id = "1", name = "John"))
every { mockDao.observeUser() } returns sourceFlow
// Repository transforms entity to domain model
repository.userFlow.test {
val expectedUser = User(id = "1", name = "John")
assertEquals(expectedUser, awaitItem())
}
}Common Patterns
Pattern 1: Testing Initial State + Action
@Test
fun `load data should update from idle to loading to success`() = runTest {
coEvery { repository.getData() } returns "data".asSuccess()
viewModel.stateFlow.test {
assertEquals(DEFAULT_STATE, awaitItem())
viewModel.loadData()
assertEquals(DEFAULT_STATE.copy(loadingState = LoadingState.Loading), awaitItem())
assertEquals(DEFAULT_STATE.copy(loadingState = LoadingState.Success), awaitItem())
}
}Pattern 2: Testing Error States
@Test
fun `load data with error should emit failure state`() = runTest {
val error = Exception("Network error")
coEvery { repository.getData() } returns error.asFailure()
viewModel.stateFlow.test {
assertEquals(DEFAULT_STATE, awaitItem())
viewModel.loadData()
assertEquals(DEFAULT_STATE.copy(loadingState = LoadingState.Loading), awaitItem())
assertEquals(
DEFAULT_STATE.copy(loadingState = LoadingState.Error("Network error")),
awaitItem(),
)
}
}Pattern 3: Testing Event Sequences
@Test
fun `submit should emit validation then navigation events`() = runTest {
viewModel.eventFlow.test {
expectNoEvents()
viewModel.trySendAction(MyAction.Submit)
assertEquals(MyEvent.ShowValidation, awaitItem())
assertEquals(MyEvent.NavigateToNext, awaitItem())
}
}Pattern 4: Testing Cancellation
@Test
fun `cancelling collection should stop emissions`() = runTest {
val flow = flow {
repeat(100) {
emit(it)
delay(100)
}
}
flow.test {
assertEquals(0, awaitItem())
assertEquals(1, awaitItem())
// Cancel after 2 items
cancel()
// No more items received
}
}Anti-Patterns
❌ Forgetting expectNoEvents() on EventFlow
// WRONG
viewModel.eventFlow.test {
viewModel.trySendAction(action) // May fail - no initial expectNoEvents
assertEquals(event, awaitItem())
}
// CORRECT
viewModel.eventFlow.test {
expectNoEvents() // ALWAYS do this first
viewModel.trySendAction(action)
assertEquals(event, awaitItem())
}❌ Not Using runTest
// WRONG - Missing runTest
@Test
fun `test flow`() {
flow.test { /* ... */ }
}
// CORRECT
@Test
fun `test flow`() = runTest {
flow.test { /* ... */ }
}❌ Mixing StateFlow and EventFlow Patterns
// WRONG - Treating StateFlow like EventFlow
stateFlow.test {
expectNoEvents() // Unnecessary - StateFlow always has value
/* ... */
}
// WRONG - Treating EventFlow like StateFlow
eventFlow.test {
val item = awaitItem() // Will hang - no initial value!
/* ... */
}Reference Implementations
ViewModel with StateFlow and EventFlow: app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorViewModelTest.kt
Repository Flow Testing: data/src/test/kotlin/com/bitwarden/data/tools/generator/repository/GeneratorRepositoryTest.kt
Complex Flow Transformations: data/src/test/kotlin/com/bitwarden/data/vault/repository/VaultRepositoryTest.kt
Test Base Classes Reference
Bitwarden Android provides specialized base classes that configure test environments and provide helper utilities.
BaseViewModelTest
Location: ui/src/testFixtures/kotlin/com/bitwarden/ui/platform/base/BaseViewModelTest.kt
Purpose
Provides essential setup for testing ViewModels with proper coroutine dispatcher configuration and Flow testing helpers.
Automatic Configuration
- Registers
MainDispatcherExtensionforUnconfinedTestDispatcher - Ensures deterministic coroutine execution in tests
- All coroutines complete immediately without real delays
Key Feature: stateEventFlow() Helper
Use Case: When you need to test both StateFlow and EventFlow simultaneously.
@Test
fun `complex action should update state and emit event`() = runTest {
val viewModel = ExampleViewModel(savedStateHandle, mockRepository)
viewModel.stateEventFlow(backgroundScope) { stateFlow, eventFlow ->
// Verify initial state
assertEquals(INITIAL_STATE, stateFlow.awaitItem())
// No events yet
eventFlow.expectNoEvents()
// Trigger action
viewModel.trySendAction(ExampleAction.ComplexAction)
// Verify state updated
assertEquals(LOADING_STATE, stateFlow.awaitItem())
// Verify event emitted
assertEquals(ExampleEvent.ShowToast, eventFlow.awaitItem())
}
}Usage Pattern
class MyViewModelTest : BaseViewModelTest() {
private val mockRepository: MyRepository = mockk()
private val savedStateHandle = SavedStateHandle(
mapOf(KEY_STATE to INITIAL_STATE)
)
@Test
fun `test action`() = runTest {
val viewModel = MyViewModel(
savedStateHandle = savedStateHandle,
repository = mockRepository
)
// Test with automatic dispatcher setup
viewModel.stateFlow.test {
assertEquals(INITIAL_STATE, awaitItem())
}
}
}BitwardenComposeTest
Location: app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/base/BitwardenComposeTest.kt
Purpose
Pre-configured test class for Compose UI tests with all Bitwarden managers and theme setup.
Automatic Configuration
- All Bitwarden managers pre-configured (FeatureFlags, AuthTab, Biometrics, etc.)
- Wraps content in
BitwardenThemeandLocalManagerProvider - Provides fixed
Clockfor deterministic time-based tests - Extends
BaseComposeTestfor Robolectric and dispatcher setup
Key Features
Pre-configured Managers:
FeatureFlagManager- Controls feature flag behaviorAuthTabManager- Manages auth tab stateBiometricsManager- Handles biometric authenticationClipboardManager- Clipboard operationsNotificationManager- Notification display
Fixed Clock: All tests use a fixed clock for deterministic time-based testing:
// Tests use consistent time: 2023-10-27T12:00:00Z
val fixedClock: ClockUsage Pattern
class MyScreenTest : BitwardenComposeTest() {
private var haveCalledNavigateBack = false
private val mutableEventFlow = bufferedMutableSharedFlow<MyEvent>()
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
private val viewModel = mockk<MyViewModel>(relaxed = true) {
every { eventFlow } returns mutableEventFlow
every { stateFlow } returns mutableStateFlow
}
@Before
fun setup() {
setContent {
MyScreen(
onNavigateBack = { haveCalledNavigateBack = true },
viewModel = viewModel
)
}
}
@Test
fun `on back click should send action`() {
composeTestRule.onNodeWithContentDescription("Back").performClick()
verify { viewModel.trySendAction(MyAction.BackClick) }
}
@Test
fun `loading state should show progress`() {
mutableStateFlow.value = DEFAULT_STATE.copy(isLoading = true)
composeTestRule.onNode(isProgressBar).assertIsDisplayed()
}
}Important: bufferedMutableSharedFlow for Events
In Compose tests, use bufferedMutableSharedFlow instead of regular MutableSharedFlow (default replay is 0):
// Correct for Compose tests
private val mutableEventFlow = bufferedMutableSharedFlow<MyEvent>()
// This allows triggering events and having the UI react
mutableEventFlow.tryEmit(MyEvent.NavigateBack)BaseServiceTest
Location: network/src/testFixtures/kotlin/com/bitwarden/network/base/BaseServiceTest.kt
Purpose
Provides MockWebServer setup for testing API service implementations.
Automatic Configuration
server: MockWebServer- Auto-started before each test, stopped afterretrofit: Retrofit- Pre-configured with:- JSON converter (kotlinx.serialization)
- NetworkResultCallAdapter for Result<T> responses
- Base URL pointing to MockWebServer
json: Json- kotlinx.serialization JSON instance
Usage Pattern
class MyServiceTest : BaseServiceTest() {
private val api: MyApi = retrofit.create()
private val service = MyServiceImpl(api)
@Test
fun `getConfig should return success when API succeeds`() = runTest {
// Enqueue mock response
server.enqueue(MockResponse().setBody(EXPECTED_JSON))
// Call service
val result = service.getConfig()
// Verify result
assertEquals(EXPECTED_RESPONSE.asSuccess(), result)
}
@Test
fun `getConfig should return failure when API fails`() = runTest {
// Enqueue error response
server.enqueue(MockResponse().setResponseCode(500))
// Call service
val result = service.getConfig()
// Verify failure
assertTrue(result.isFailure)
}
}MockWebServer Patterns
Enqueue successful response:
server.enqueue(MockResponse().setBody("""{"key": "value"}"""))Enqueue error response:
server.enqueue(MockResponse().setResponseCode(404))
server.enqueue(MockResponse().setResponseCode(500))Enqueue delayed response:
server.enqueue(
MockResponse()
.setBody("""{"key": "value"}""")
.setBodyDelay(1000, TimeUnit.MILLISECONDS)
)Verify request details:
val request = server.takeRequest()
assertEquals("/api/config", request.path)
assertEquals("GET", request.method)
assertEquals("Bearer token", request.getHeader("Authorization"))BaseComposeTest
Location: ui/src/testFixtures/kotlin/com/bitwarden/ui/platform/base/BaseComposeTest.kt
Purpose
Base class for Compose tests that extends BaseRobolectricTest and provides setTestContent() helper.
Features
- Robolectric configuration for Compose
- Proper dispatcher setup
composeTestRulefor UI testingsetTestContent()helper wraps content in theme
Usage
Typically you'll extend BitwardenComposeTest which extends this class. Use BaseComposeTest directly only for tests that don't need Bitwarden-specific manager configuration.
When to Use Each Base Class
| Test Type | Base Class | Use When |
|---|---|---|
| ViewModel tests | BaseViewModelTest | Testing ViewModel state and events |
| Compose screen tests | BitwardenComposeTest | Testing Compose UI with Bitwarden components |
| API service tests | BaseServiceTest | Testing network layer with MockWebServer |
| Repository tests | None (manual setup) | Testing repository logic with mocked dependencies |
| Utility/helper tests | None (manual setup) | Testing pure functions or utilities |
Complete Examples
ViewModel Test: ../examples/viewmodel-test-example.md
Compose Screen Test: ../examples/compose-screen-test-example.md
Repository Test: ../examples/repository-test-example.md