|
| 1 | +--- |
| 2 | +name: testing-android-code |
| 3 | +description: This skill should be used when writing or reviewing tests for Android code in Bitwarden. Triggered by "BaseViewModelTest", "BitwardenComposeTest", "BaseServiceTest", "stateEventFlow", "bufferedMutableSharedFlow", "FakeDispatcherManager", "expectNoEvents", "assertCoroutineThrows", "createMockCipher", "createMockSend", "asSuccess", "Why is my Bitwarden test failing?", or testing questions about ViewModels, repositories, Compose screens, or data sources in Bitwarden. |
| 4 | +version: 1.0.0 |
| 5 | +--- |
| 6 | + |
| 7 | +# Testing Android Code - Bitwarden Testing Patterns |
| 8 | + |
| 9 | +**This skill provides tactical testing guidance for Bitwarden-specific patterns.** For comprehensive architecture and testing philosophy, consult `docs/ARCHITECTURE.md`. |
| 10 | + |
| 11 | +## Test Framework Configuration |
| 12 | + |
| 13 | +**Required Dependencies:** |
| 14 | +- **JUnit 5** (jupiter), **MockK**, **Turbine** (app.cash.turbine) |
| 15 | +- **kotlinx.coroutines.test**, **Robolectric**, **Compose Test** |
| 16 | + |
| 17 | +**Critical Note:** Tests run with en-US locale for consistency. Don't assume other locales. |
| 18 | + |
| 19 | +--- |
| 20 | + |
| 21 | +## A. ViewModel Testing Patterns |
| 22 | + |
| 23 | +### Base Class: BaseViewModelTest |
| 24 | + |
| 25 | +**Always extend `BaseViewModelTest` for ViewModel tests.** |
| 26 | + |
| 27 | +**Location:** `ui/src/testFixtures/kotlin/com/bitwarden/ui/platform/base/BaseViewModelTest.kt` |
| 28 | + |
| 29 | +**Benefits:** |
| 30 | +- Automatically registers `MainDispatcherExtension` for `UnconfinedTestDispatcher` |
| 31 | +- Provides `stateEventFlow()` helper for simultaneous StateFlow/EventFlow testing |
| 32 | + |
| 33 | +**Pattern:** |
| 34 | +```kotlin |
| 35 | +class ExampleViewModelTest : BaseViewModelTest() { |
| 36 | + private val mockRepository: ExampleRepository = mockk() |
| 37 | + private val savedStateHandle = SavedStateHandle(mapOf(KEY_STATE to INITIAL_STATE)) |
| 38 | + |
| 39 | + @Test |
| 40 | + fun `ButtonClick should fetch data and update state`() = runTest { |
| 41 | + coEvery { mockRepository.fetchData(any()) } returns Result.success("data") |
| 42 | + |
| 43 | + val viewModel = ExampleViewModel(savedStateHandle, mockRepository) |
| 44 | + |
| 45 | + viewModel.stateFlow.test { |
| 46 | + assertEquals(INITIAL_STATE, awaitItem()) |
| 47 | + viewModel.trySendAction(ExampleAction.ButtonClick) |
| 48 | + assertEquals(INITIAL_STATE.copy(data = "data"), awaitItem()) |
| 49 | + } |
| 50 | + |
| 51 | + coVerify { mockRepository.fetchData(any()) } |
| 52 | + } |
| 53 | +} |
| 54 | +``` |
| 55 | + |
| 56 | +**For complete examples:** See `references/test-base-classes.md` |
| 57 | + |
| 58 | +### StateFlow vs EventFlow (Critical Distinction) |
| 59 | + |
| 60 | +| Flow Type | Replay | First Action | Pattern | |
| 61 | +|-----------|--------|--------------|---------| |
| 62 | +| StateFlow | Yes (1) | `awaitItem()` gets current state | Expect initial → trigger → expect new | |
| 63 | +| EventFlow | No | `expectNoEvents()` first | expectNoEvents → trigger → expect event | |
| 64 | + |
| 65 | +**For detailed patterns:** See `references/flow-testing-patterns.md` |
| 66 | + |
| 67 | +--- |
| 68 | + |
| 69 | +## B. Compose UI Testing Patterns |
| 70 | + |
| 71 | +### Base Class: BitwardenComposeTest |
| 72 | + |
| 73 | +**Always extend `BitwardenComposeTest` for Compose screen tests.** |
| 74 | + |
| 75 | +**Location:** `app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/base/BitwardenComposeTest.kt` |
| 76 | + |
| 77 | +**Benefits:** |
| 78 | +- Pre-configures all Bitwarden managers (FeatureFlags, AuthTab, Biometrics, etc.) |
| 79 | +- Wraps content in `BitwardenTheme` and `LocalManagerProvider` |
| 80 | +- Provides fixed Clock for deterministic time-based tests |
| 81 | + |
| 82 | +**Pattern:** |
| 83 | +```kotlin |
| 84 | +class ExampleScreenTest : BitwardenComposeTest() { |
| 85 | + private var haveCalledNavigateBack = false |
| 86 | + private val mutableEventFlow = bufferedMutableSharedFlow<ExampleEvent>() |
| 87 | + private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE) |
| 88 | + private val viewModel = mockk<ExampleViewModel>(relaxed = true) { |
| 89 | + every { eventFlow } returns mutableEventFlow |
| 90 | + every { stateFlow } returns mutableStateFlow |
| 91 | + } |
| 92 | + |
| 93 | + @Before |
| 94 | + fun setup() { |
| 95 | + setContent { |
| 96 | + ExampleScreen( |
| 97 | + onNavigateBack = { haveCalledNavigateBack = true }, |
| 98 | + viewModel = viewModel, |
| 99 | + ) |
| 100 | + } |
| 101 | + } |
| 102 | + |
| 103 | + @Test |
| 104 | + fun `on back click should send BackClick action`() { |
| 105 | + composeTestRule.onNodeWithContentDescription("Back").performClick() |
| 106 | + verify { viewModel.trySendAction(ExampleAction.BackClick) } |
| 107 | + } |
| 108 | +} |
| 109 | +``` |
| 110 | + |
| 111 | +**Note:** Use `bufferedMutableSharedFlow` for event testing in Compose tests. Default replay is 0; pass `replay = 1` if needed. |
| 112 | + |
| 113 | +**For complete base class details:** See `references/test-base-classes.md` |
| 114 | + |
| 115 | +--- |
| 116 | + |
| 117 | +## C. Repository and Service Testing |
| 118 | + |
| 119 | +### Service Testing with MockWebServer |
| 120 | + |
| 121 | +**Base Class:** `BaseServiceTest` (`network/src/testFixtures/`) |
| 122 | + |
| 123 | +```kotlin |
| 124 | +class ExampleServiceTest : BaseServiceTest() { |
| 125 | + private val api: ExampleApi = retrofit.create() |
| 126 | + private val service = ExampleServiceImpl(api) |
| 127 | + |
| 128 | + @Test |
| 129 | + fun `getConfig should return success when API succeeds`() = runTest { |
| 130 | + server.enqueue(MockResponse().setBody(EXPECTED_JSON)) |
| 131 | + val result = service.getConfig() |
| 132 | + assertEquals(EXPECTED_RESPONSE.asSuccess(), result) |
| 133 | + } |
| 134 | +} |
| 135 | +``` |
| 136 | + |
| 137 | +### Repository Testing Pattern |
| 138 | + |
| 139 | +```kotlin |
| 140 | +class ExampleRepositoryTest { |
| 141 | + private val fixedClock: Clock = Clock.fixed( |
| 142 | + Instant.parse("2023-10-27T12:00:00Z"), |
| 143 | + ZoneOffset.UTC, |
| 144 | + ) |
| 145 | + private val dispatcherManager = FakeDispatcherManager() |
| 146 | + private val mockDiskSource: ExampleDiskSource = mockk() |
| 147 | + private val mockService: ExampleService = mockk() |
| 148 | + |
| 149 | + private val repository = ExampleRepositoryImpl( |
| 150 | + clock = fixedClock, |
| 151 | + exampleDiskSource = mockDiskSource, |
| 152 | + exampleService = mockService, |
| 153 | + dispatcherManager = dispatcherManager, |
| 154 | + ) |
| 155 | + |
| 156 | + @Test |
| 157 | + fun `fetchData should return success when service succeeds`() = runTest { |
| 158 | + coEvery { mockService.getData(any()) } returns expectedData.asSuccess() |
| 159 | + val result = repository.fetchData(userId) |
| 160 | + assertTrue(result.isSuccess) |
| 161 | + } |
| 162 | +} |
| 163 | +``` |
| 164 | + |
| 165 | +**Key patterns:** Use `FakeDispatcherManager`, fixed Clock, and `.asSuccess()` helpers. |
| 166 | + |
| 167 | +--- |
| 168 | + |
| 169 | +## D. Test Data Builders |
| 170 | + |
| 171 | +### Builder Pattern with Number Parameter |
| 172 | + |
| 173 | +**Location:** `network/src/testFixtures/kotlin/com/bitwarden/network/model/` |
| 174 | + |
| 175 | +```kotlin |
| 176 | +fun createMockCipher( |
| 177 | + number: Int, |
| 178 | + id: String = "mockId-$number", |
| 179 | + name: String? = "mockName-$number", |
| 180 | + // ... more parameters with defaults |
| 181 | +): SyncResponseJson.Cipher |
| 182 | + |
| 183 | +// Usage: |
| 184 | +val cipher1 = createMockCipher(number = 1) // mockId-1, mockName-1 |
| 185 | +val cipher2 = createMockCipher(number = 2) // mockId-2, mockName-2 |
| 186 | +val custom = createMockCipher(number = 3, name = "Custom") |
| 187 | +``` |
| 188 | + |
| 189 | +**Available Builders (35+):** |
| 190 | +- **Cipher:** `createMockCipher()`, `createMockLogin()`, `createMockCard()`, `createMockIdentity()`, `createMockSecureNote()`, `createMockSshKey()`, `createMockField()`, `createMockUri()`, `createMockFido2Credential()`, `createMockPasswordHistory()`, `createMockCipherPermissions()` |
| 191 | +- **Sync:** `createMockSyncResponse()`, `createMockFolder()`, `createMockCollection()`, `createMockPolicy()`, `createMockDomains()` |
| 192 | +- **Send:** `createMockSend()`, `createMockFile()`, `createMockText()`, `createMockSendJsonRequest()` |
| 193 | +- **Profile:** `createMockProfile()`, `createMockOrganization()`, `createMockProvider()`, `createMockPermissions()` |
| 194 | +- **Attachments:** `createMockAttachment()`, `createMockAttachmentJsonRequest()`, `createMockAttachmentResponse()` |
| 195 | + |
| 196 | +See `network/src/testFixtures/kotlin/com/bitwarden/network/model/` for full list. |
| 197 | + |
| 198 | +--- |
| 199 | + |
| 200 | +## E. Result Type Testing |
| 201 | + |
| 202 | +**Locations:** |
| 203 | +- `.asSuccess()`, `.asFailure()`: `core/src/main/kotlin/com/bitwarden/core/data/util/ResultExtensions.kt` |
| 204 | +- `assertCoroutineThrows`: `core/src/testFixtures/kotlin/com/bitwarden/core/data/util/TestHelpers.kt` |
| 205 | + |
| 206 | +```kotlin |
| 207 | +// Create results |
| 208 | +"data".asSuccess() // Result.success("data") |
| 209 | +throwable.asFailure() // Result.failure<T>(throwable) |
| 210 | + |
| 211 | +// Assertions |
| 212 | +assertTrue(result.isSuccess) |
| 213 | +assertEquals(expectedValue, result.getOrNull()) |
| 214 | +``` |
| 215 | + |
| 216 | +--- |
| 217 | + |
| 218 | +## F. Test Utilities and Helpers |
| 219 | + |
| 220 | +### Fake Implementations |
| 221 | + |
| 222 | +| Fake | Location | Purpose | |
| 223 | +|------|----------|---------| |
| 224 | +| `FakeDispatcherManager` | `core/src/testFixtures/` | Deterministic coroutine execution | |
| 225 | +| `FakeConfigDiskSource` | `data/src/testFixtures/` | In-memory config storage | |
| 226 | +| `FakeSharedPreferences` | `data/src/testFixtures/` | Memory-backed SharedPreferences | |
| 227 | + |
| 228 | +### Exception Testing (CRITICAL) |
| 229 | + |
| 230 | +```kotlin |
| 231 | +// CORRECT - Call directly, NOT inside runTest |
| 232 | +@Test |
| 233 | +fun `test exception`() { |
| 234 | + assertCoroutineThrows<IllegalStateException> { |
| 235 | + repository.throwingFunction() |
| 236 | + } |
| 237 | +} |
| 238 | +``` |
| 239 | + |
| 240 | +**Why:** `runTest` catches exceptions and rethrows them, breaking the assertion pattern. |
| 241 | + |
| 242 | +--- |
| 243 | + |
| 244 | +## G. Critical Gotchas |
| 245 | + |
| 246 | +Common testing mistakes in Bitwarden. **For complete details and examples:** See `references/critical-gotchas.md` |
| 247 | + |
| 248 | +**Core Patterns:** |
| 249 | +- **assertCoroutineThrows + runTest** - Never wrap in `runTest`; call directly |
| 250 | +- **Static mock cleanup** - Always `unmockkStatic()` in `@After` |
| 251 | +- **StateFlow vs EventFlow** - StateFlow: `awaitItem()` first; EventFlow: `expectNoEvents()` first |
| 252 | +- **FakeDispatcherManager** - Always use instead of real `DispatcherManagerImpl` |
| 253 | +- **Coroutine test wrapper** - Use `runTest { }` for all Flow/coroutine tests |
| 254 | + |
| 255 | +**Assertion Patterns:** |
| 256 | +- **Complete state assertions** - Assert entire state objects, not individual fields |
| 257 | +- **JUnit over Kotlin** - Use `assertTrue()`, not Kotlin's `assert()` |
| 258 | +- **Use Result extensions** - Use `asSuccess()` and `asFailure()` for Result type assertions |
| 259 | + |
| 260 | +**Test Design:** |
| 261 | +- **Fake vs Mock strategy** - Use Fakes for happy paths, Mocks for error paths |
| 262 | +- **DI over static mocking** - Extract interfaces (like UuidManager) instead of mockkStatic |
| 263 | +- **Null stream testing** - Test null returns from ContentResolver operations |
| 264 | +- **bufferedMutableSharedFlow** - Use with `.onSubscription { emit(state) }` in Fakes |
| 265 | +- **Test factory methods** - Accept domain state types, not SavedStateHandle |
| 266 | + |
| 267 | +--- |
| 268 | + |
| 269 | +## H. Test File Organization |
| 270 | + |
| 271 | +### Directory Structure |
| 272 | + |
| 273 | +``` |
| 274 | +module/src/test/kotlin/com/bitwarden/.../ |
| 275 | +├── ui/*ScreenTest.kt, *ViewModelTest.kt |
| 276 | +├── data/repository/*RepositoryTest.kt |
| 277 | +└── network/service/*ServiceTest.kt |
| 278 | +
|
| 279 | +module/src/testFixtures/kotlin/com/bitwarden/.../ |
| 280 | +├── util/TestHelpers.kt |
| 281 | +├── base/Base*Test.kt |
| 282 | +└── model/*Util.kt |
| 283 | +``` |
| 284 | + |
| 285 | +### Test Naming |
| 286 | + |
| 287 | +- Classes: `*Test.kt`, `*ScreenTest.kt`, `*ViewModelTest.kt` |
| 288 | +- Functions: `` `given state when action should result` `` |
| 289 | + |
| 290 | +--- |
| 291 | + |
| 292 | +## Summary |
| 293 | + |
| 294 | +Key Bitwarden-specific testing patterns: |
| 295 | + |
| 296 | +1. **BaseViewModelTest** - Automatic dispatcher setup with `stateEventFlow()` helper |
| 297 | +2. **BitwardenComposeTest** - Pre-configured with all managers and theme |
| 298 | +3. **BaseServiceTest** - MockWebServer setup for network testing |
| 299 | +4. **Turbine Flow Testing** - StateFlow (replay) vs EventFlow (no replay) |
| 300 | +5. **Test Data Builders** - Consistent `number: Int` parameter pattern |
| 301 | +6. **Fake Implementations** - FakeDispatcherManager, FakeConfigDiskSource |
| 302 | +7. **Result Type Testing** - `.asSuccess()`, `.asFailure()` |
| 303 | + |
| 304 | +**Always consult:** `docs/ARCHITECTURE.md` and existing test files for reference implementations. |
| 305 | + |
| 306 | +--- |
| 307 | + |
| 308 | +## Reference Documentation |
| 309 | + |
| 310 | +For detailed information, see: |
| 311 | + |
| 312 | +- `references/test-base-classes.md` - Detailed base class documentation and usage patterns |
| 313 | +- `references/flow-testing-patterns.md` - Complete Turbine patterns for StateFlow/EventFlow |
| 314 | +- `references/critical-gotchas.md` - Full anti-pattern reference and debugging tips |
| 315 | + |
| 316 | +**Complete Examples:** |
| 317 | +- `examples/viewmodel-test-example.md` - Full ViewModel test with StateFlow/EventFlow |
| 318 | +- `examples/compose-screen-test-example.md` - Full Compose screen test |
| 319 | +- `examples/repository-test-example.md` - Full repository test with mocks and fakes |
0 commit comments