android-sdd 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +143 -0
- package/package.json +27 -0
- package/skills/Android Ecosystem/Baseline Profile Generator/SKILL.md +277 -0
- package/skills/Android Ecosystem/Glance/SKILL.md +315 -0
- package/skills/Android Platform/Configuration/SKILL.md +201 -0
- package/skills/Android Platform/Filesystem/SKILL.md +216 -0
- package/skills/Android Platform/Lifecycle/SKILL.md +233 -0
- package/skills/Android Platform/Manifest/SKILL.md +226 -0
- package/skills/Android Platform/Process Death Recovery/SKILL.md +214 -0
- package/skills/Android Platform/Resources/SKILL.md +234 -0
- package/skills/Android Platform/SavedStateHandle/SKILL.md +217 -0
- package/skills/Android Platform/State Restoration/SKILL.md +210 -0
- package/skills/Architecture/Bounded Context/SKILL.md +207 -0
- package/skills/Architecture/Clean Architecture/SKILL.md +229 -0
- package/skills/Architecture/Domain Modeling/SKILL.md +236 -0
- package/skills/Architecture/Entity Design/SKILL.md +243 -0
- package/skills/Architecture/Feature Isolation/SKILL.md +216 -0
- package/skills/Architecture/MVI/SKILL.md +224 -0
- package/skills/Architecture/MVVM/SKILL.md +198 -0
- package/skills/Architecture/Modularization/SKILL.md +194 -0
- package/skills/Architecture/Offline First/SKILL.md +249 -0
- package/skills/Architecture/Repository Pattern/SKILL.md +216 -0
- package/skills/Architecture/Side Effect Management/SKILL.md +278 -0
- package/skills/Architecture/State Management/SKILL.md +229 -0
- package/skills/Architecture/Unidirectional Data Flow/SKILL.md +196 -0
- package/skills/Architecture/Use Case Design/SKILL.md +244 -0
- package/skills/Architecture/Value Object/SKILL.md +226 -0
- package/skills/Build Infrastructure/Build Orchestration/SKILL.md +257 -0
- package/skills/Build Infrastructure/Dependency Compatibility Resolver/SKILL.md +259 -0
- package/skills/Build Infrastructure/Environment Validator/SKILL.md +311 -0
- package/skills/Build System/Build Cache/SKILL.md +233 -0
- package/skills/Build System/Build Flavor Strategy/SKILL.md +171 -0
- package/skills/Build System/Build Variant/SKILL.md +215 -0
- package/skills/Build System/Convention Plugin/SKILL.md +288 -0
- package/skills/Build System/Dependency Management/SKILL.md +261 -0
- package/skills/Build System/Gradle/SKILL.md +284 -0
- package/skills/Build System/Incremental Build/SKILL.md +199 -0
- package/skills/Build System/KAPT/SKILL.md +198 -0
- package/skills/Build System/KSP/SKILL.md +263 -0
- package/skills/Build System/Module Dependency Graph Validation/SKILL.md +223 -0
- package/skills/Build System/Specialized/C++/SKILL.md +308 -0
- package/skills/Build System/Specialized/JNI/SKILL.md +306 -0
- package/skills/Build System/Specialized/NDK/SKILL.md +264 -0
- package/skills/Build System/Version Catalog/SKILL.md +304 -0
- package/skills/Concurrency/Background Processing/SKILL.md +185 -0
- package/skills/Concurrency/Channel/SKILL.md +207 -0
- package/skills/Concurrency/Coroutine/SKILL.md +200 -0
- package/skills/Concurrency/Flow/SKILL.md +179 -0
- package/skills/Concurrency/Mutex Strategy/SKILL.md +185 -0
- package/skills/Concurrency/SharedFlow/SKILL.md +171 -0
- package/skills/Concurrency/StateFlow/SKILL.md +175 -0
- package/skills/Concurrency/Structured Concurrency/SKILL.md +197 -0
- package/skills/Concurrency/Synchronization Policy/SKILL.md +192 -0
- package/skills/Core Language/Annotation Processing/SKILL.md +224 -0
- package/skills/Core Language/DSL/SKILL.md +186 -0
- package/skills/Core Language/Extension Functions Design/SKILL.md +191 -0
- package/skills/Core Language/Immutability/SKILL.md +156 -0
- package/skills/Core Language/KMP/SKILL.md +182 -0
- package/skills/Core Language/Kotlin/SKILL.md +187 -0
- package/skills/Core Language/Reactive State Management/SKILL.md +228 -0
- package/skills/Core Language/Reactive Streams/SKILL.md +235 -0
- package/skills/Core Language/Serialization/SKILL.md +191 -0
- package/skills/Data Layer/Cache Strategy/SKILL.md +261 -0
- package/skills/Data Layer/Conflict Resolution/SKILL.md +248 -0
- package/skills/Data Layer/DAO/SKILL.md +225 -0
- package/skills/Data Layer/DTO Mapping/SKILL.md +269 -0
- package/skills/Data Layer/DataStore/SKILL.md +264 -0
- package/skills/Data Layer/Database Versioning Strategy/SKILL.md +215 -0
- package/skills/Data Layer/Encrypted Database/SKILL.md +212 -0
- package/skills/Data Layer/File Storage/SKILL.md +247 -0
- package/skills/Data Layer/Indexing/SKILL.md +184 -0
- package/skills/Data Layer/Key-Value Store Strategy/SKILL.md +185 -0
- package/skills/Data Layer/Merge Strategy/SKILL.md +240 -0
- package/skills/Data Layer/Migration/SKILL.md +243 -0
- package/skills/Data Layer/Paging/SKILL.md +264 -0
- package/skills/Data Layer/Proto DataStore/SKILL.md +250 -0
- package/skills/Data Layer/Room/SKILL.md +244 -0
- package/skills/Data Layer/SQLite/SKILL.md +255 -0
- package/skills/Data Layer/Sync Engine/SKILL.md +268 -0
- package/skills/Dependency Injection/Dagger/SKILL.md +283 -0
- package/skills/Dependency Injection/Hilt/SKILL.md +345 -0
- package/skills/Dependency Injection/Koin/SKILL.md +282 -0
- package/skills/Developer Experience/Detekt/SKILL.md +272 -0
- package/skills/Developer Experience/Lint Rule/SKILL.md +281 -0
- package/skills/Google Ecosystem/Analytics/SKILL.md +281 -0
- package/skills/Google Ecosystem/Crashlytics/SKILL.md +234 -0
- package/skills/Google Ecosystem/Firebase/SKILL.md +200 -0
- package/skills/Google Ecosystem/Firebase Messaging/SKILL.md +266 -0
- package/skills/Media/Audio/SKILL.md +257 -0
- package/skills/Media/Camera/SKILL.md +229 -0
- package/skills/Media/CameraX/SKILL.md +295 -0
- package/skills/Media/ExoPlayer/SKILL.md +258 -0
- package/skills/Media/Video/SKILL.md +228 -0
- package/skills/Meta Skills/Domain Error Model/SKILL.md +238 -0
- package/skills/Meta Skills/Error Handling/SKILL.md +255 -0
- package/skills/Meta Skills/Error Mapping/SKILL.md +232 -0
- package/skills/Meta Skills/Failure Strategy/SKILL.md +294 -0
- package/skills/Meta Skills/Migration Strategy/SKILL.md +305 -0
- package/skills/Meta Skills/User Friendly Errors/SKILL.md +334 -0
- package/skills/Navigation/Deep Navigation/SKILL.md +209 -0
- package/skills/Navigation/Navigation/SKILL.md +215 -0
- package/skills/Navigation/Nested Navigation/SKILL.md +214 -0
- package/skills/Networking/API Contract/SKILL.md +220 -0
- package/skills/Networking/Authentication/SKILL.md +210 -0
- package/skills/Networking/Certificate Pinning/SKILL.md +167 -0
- package/skills/Networking/Fallback Strategy/SKILL.md +182 -0
- package/skills/Networking/Ktor/SKILL.md +219 -0
- package/skills/Networking/Multipart Upload/SKILL.md +213 -0
- package/skills/Networking/OkHttp/SKILL.md +193 -0
- package/skills/Networking/REST/SKILL.md +178 -0
- package/skills/Networking/Rate Limiting/SKILL.md +170 -0
- package/skills/Networking/Retrofit/SKILL.md +241 -0
- package/skills/Networking/Retry-Backoff/SKILL.md +181 -0
- package/skills/Networking/Server-Sent Events (SSE)/SKILL.md +196 -0
- package/skills/Networking/WebSocket/SKILL.md +224 -0
- package/skills/Observability/Crash Reporting/SKILL.md +219 -0
- package/skills/Observability/Logging/SKILL.md +168 -0
- package/skills/Observability/Metrics/SKILL.md +227 -0
- package/skills/Observability/Structured Logging/SKILL.md +234 -0
- package/skills/Performance/ANR Prevention/SKILL.md +192 -0
- package/skills/Performance/Allocation Optimization/SKILL.md +179 -0
- package/skills/Performance/App Startup/SKILL.md +183 -0
- package/skills/Performance/Baseline Profile/SKILL.md +205 -0
- package/skills/Performance/Battery Optimization/SKILL.md +192 -0
- package/skills/Performance/Benchmark/SKILL.md +182 -0
- package/skills/Performance/Bitmap Optimization/SKILL.md +178 -0
- package/skills/Performance/Compose Optimization/SKILL.md +187 -0
- package/skills/Performance/Heap Management/SKILL.md +184 -0
- package/skills/Performance/Macrobenchmark/SKILL.md +214 -0
- package/skills/Performance/Memory Leak Prevention/SKILL.md +218 -0
- package/skills/Performance/Rendering Performance/SKILL.md +205 -0
- package/skills/Performance/Startup Optimization/SKILL.md +219 -0
- package/skills/Security/Biometric/SKILL.md +224 -0
- package/skills/Security/Certificate Transparency/SKILL.md +158 -0
- package/skills/Security/Cryptography/SKILL.md +244 -0
- package/skills/Security/Encrypted Storage/SKILL.md +273 -0
- package/skills/Security/Frida Detection/SKILL.md +230 -0
- package/skills/Security/Hook Detection/SKILL.md +197 -0
- package/skills/Security/Keystore/SKILL.md +272 -0
- package/skills/Security/Network Security Config/SKILL.md +186 -0
- package/skills/Security/Obfuscation/SKILL.md +226 -0
- package/skills/Security/Proguard/SKILL.md +202 -0
- package/skills/Security/R8/SKILL.md +234 -0
- package/skills/Security/Reverse Engineering Resistance/SKILL.md +267 -0
- package/skills/Security/Root Detection/SKILL.md +220 -0
- package/skills/Security/Secure Networking/SKILL.md +220 -0
- package/skills/System Integration/AlarmManager/SKILL.md +182 -0
- package/skills/System Integration/App Widget/SKILL.md +182 -0
- package/skills/System Integration/Deep Link/SKILL.md +187 -0
- package/skills/System Integration/Foreground Service/SKILL.md +212 -0
- package/skills/System Integration/Notification/SKILL.md +237 -0
- package/skills/System Integration/WorkManager/SKILL.md +256 -0
- package/skills/System Integration/clipboard/SKILL.md +155 -0
- package/skills/System Integration/share-intent/SKILL.md +182 -0
- package/skills/Testing/Compose Testing/SKILL.md +296 -0
- package/skills/Testing/Espresso/SKILL.md +292 -0
- package/skills/Testing/Fake Data/SKILL.md +245 -0
- package/skills/Testing/Integration Testing/SKILL.md +288 -0
- package/skills/Testing/Mocking/SKILL.md +229 -0
- package/skills/Testing/Snapshot Testing/SKILL.md +259 -0
- package/skills/Testing/UI Testing/SKILL.md +293 -0
- package/skills/Testing/Unit Testing/SKILL.md +309 -0
- package/skills/UI System/Bottom Sheet Patterns/SKILL.md +279 -0
- package/skills/UI System/Compose/SKILL.md +296 -0
- package/skills/UI System/Compose Animation/SKILL.md +281 -0
- package/skills/UI System/Compose Multiplatform/SKILL.md +261 -0
- package/skills/UI System/Compose Navigation/SKILL.md +255 -0
- package/skills/UI System/Compose Performance/SKILL.md +274 -0
- package/skills/UI System/Design System/SKILL.md +217 -0
- package/skills/UI System/Empty State Strategy/SKILL.md +208 -0
- package/skills/UI System/Keyboard Navigation/SKILL.md +214 -0
- package/skills/UI System/Loading Strategy/SKILL.md +254 -0
- package/skills/UI System/Material 3/SKILL.md +279 -0
- package/skills/UI System/RTL/SKILL.md +179 -0
- package/src/index.ts +182 -0
- package/tsconfig.json +19 -0
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: unit-testing
|
|
3
|
+
description: >
|
|
4
|
+
Unit testing for Android with JUnit5, MockK, and Kotlin coroutines.
|
|
5
|
+
Load this skill when writing unit tests for ViewModels, UseCases,
|
|
6
|
+
Repositories, or any pure logic class, testing coroutines and flows,
|
|
7
|
+
or structuring test classes and assertions.
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
# Unit Testing
|
|
11
|
+
|
|
12
|
+
## Overview
|
|
13
|
+
Unit tests verify a single class or function in isolation. Dependencies are replaced with fakes or mocks. In Android, unit tests run on the JVM (not a device), making them fast. ViewModels, UseCases, Repositories, and domain logic are the primary targets.
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## Core Principles
|
|
18
|
+
|
|
19
|
+
- **Test behavior, not implementation** — test what a class does, not how
|
|
20
|
+
- **One assertion per test** — focused tests are easier to diagnose
|
|
21
|
+
- **AAA structure** — Arrange, Act, Assert
|
|
22
|
+
- **Fakes over mocks** — fakes are more maintainable; use mocks for verification
|
|
23
|
+
- **Test edge cases** — empty lists, null values, errors, boundary conditions
|
|
24
|
+
- **Fast and deterministic** — no network, no disk, no sleep()
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## Dependencies
|
|
29
|
+
|
|
30
|
+
```kotlin
|
|
31
|
+
// build.gradle.kts (app module)
|
|
32
|
+
dependencies {
|
|
33
|
+
testImplementation(libs.junit)
|
|
34
|
+
testImplementation(libs.junit.params) // parameterized tests
|
|
35
|
+
testImplementation(libs.mockk) // mocking
|
|
36
|
+
testImplementation(libs.kotlinx.coroutines.test)
|
|
37
|
+
testImplementation(libs.turbine) // Flow testing
|
|
38
|
+
testImplementation(libs.truth) // Google Truth assertions
|
|
39
|
+
}
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
```toml
|
|
43
|
+
# libs.versions.toml
|
|
44
|
+
[versions]
|
|
45
|
+
junit = "5.10.2"
|
|
46
|
+
mockk = "1.13.10"
|
|
47
|
+
coroutines-test = "1.8.0"
|
|
48
|
+
turbine = "1.1.0"
|
|
49
|
+
truth = "1.4.2"
|
|
50
|
+
|
|
51
|
+
[libraries]
|
|
52
|
+
junit = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit" }
|
|
53
|
+
junit-params = { module = "org.junit.jupiter:junit-jupiter-params", version.ref = "junit" }
|
|
54
|
+
mockk = { module = "io.mockk:mockk", version.ref = "mockk" }
|
|
55
|
+
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines-test" }
|
|
56
|
+
turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" }
|
|
57
|
+
truth = { module = "com.google.truth:truth", version.ref = "truth" }
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
---
|
|
61
|
+
|
|
62
|
+
## Basic Test Structure
|
|
63
|
+
|
|
64
|
+
```kotlin
|
|
65
|
+
// ✅ AAA pattern with JUnit5
|
|
66
|
+
class GetUserUseCaseTest {
|
|
67
|
+
|
|
68
|
+
// Fake instead of mock for repository
|
|
69
|
+
private val repository = FakeUserRepository()
|
|
70
|
+
private val useCase = GetUserUseCase(repository)
|
|
71
|
+
|
|
72
|
+
@Test
|
|
73
|
+
fun `returns user when found`() {
|
|
74
|
+
// Arrange
|
|
75
|
+
val expected = User(id = "1", name = "Ali", email = Email("ali@test.com"))
|
|
76
|
+
repository.emit(listOf(expected))
|
|
77
|
+
|
|
78
|
+
// Act
|
|
79
|
+
val result = runBlocking { useCase("1") }
|
|
80
|
+
|
|
81
|
+
// Assert
|
|
82
|
+
assertThat(result.isSuccess).isTrue()
|
|
83
|
+
assertThat(result.getOrNull()).isEqualTo(expected)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
@Test
|
|
87
|
+
fun `returns failure when user not found`() {
|
|
88
|
+
// Arrange
|
|
89
|
+
repository.emit(emptyList())
|
|
90
|
+
|
|
91
|
+
// Act
|
|
92
|
+
val result = runBlocking { useCase("nonexistent") }
|
|
93
|
+
|
|
94
|
+
// Assert
|
|
95
|
+
assertThat(result.isFailure).isTrue()
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
---
|
|
101
|
+
|
|
102
|
+
## Testing ViewModels
|
|
103
|
+
|
|
104
|
+
```kotlin
|
|
105
|
+
// ✅ ViewModel test with TestCoroutineScheduler
|
|
106
|
+
class UserListViewModelTest {
|
|
107
|
+
|
|
108
|
+
@get:Rule
|
|
109
|
+
val mainDispatcherRule = MainDispatcherRule() // replaces Main dispatcher
|
|
110
|
+
|
|
111
|
+
private val getUsersUseCase = mockk<GetUsersUseCase>()
|
|
112
|
+
private lateinit var viewModel: UserListViewModel
|
|
113
|
+
|
|
114
|
+
@BeforeEach
|
|
115
|
+
fun setup() {
|
|
116
|
+
viewModel = UserListViewModel(getUsersUseCase)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
@Test
|
|
120
|
+
fun `state is Loading initially`() {
|
|
121
|
+
// Initial state before any collection
|
|
122
|
+
assertThat(viewModel.state.value).isEqualTo(UserListUiState.Loading)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
@Test
|
|
126
|
+
fun `state is Success when use case returns users`() = runTest {
|
|
127
|
+
// Arrange
|
|
128
|
+
val users = listOf(
|
|
129
|
+
User(id = "1", name = "Ali", email = Email("ali@test.com"))
|
|
130
|
+
)
|
|
131
|
+
every { getUsersUseCase() } returns flowOf(users)
|
|
132
|
+
|
|
133
|
+
// Re-create ViewModel after stubbing
|
|
134
|
+
viewModel = UserListViewModel(getUsersUseCase)
|
|
135
|
+
|
|
136
|
+
// Act + Assert
|
|
137
|
+
viewModel.state.test {
|
|
138
|
+
assertThat(awaitItem()).isEqualTo(UserListUiState.Loading)
|
|
139
|
+
assertThat(awaitItem()).isEqualTo(UserListUiState.Success(users))
|
|
140
|
+
cancelAndIgnoreRemainingEvents()
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
@Test
|
|
145
|
+
fun `state is Error when use case throws`() = runTest {
|
|
146
|
+
// Arrange
|
|
147
|
+
every { getUsersUseCase() } returns flow { throw Exception("Network error") }
|
|
148
|
+
viewModel = UserListViewModel(getUsersUseCase)
|
|
149
|
+
|
|
150
|
+
// Act + Assert
|
|
151
|
+
viewModel.state.test {
|
|
152
|
+
skipItems(1) // skip Loading
|
|
153
|
+
val error = awaitItem()
|
|
154
|
+
assertThat(error).isInstanceOf(UserListUiState.Error::class.java)
|
|
155
|
+
cancelAndIgnoreRemainingEvents()
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
@Test
|
|
160
|
+
fun `delete emits NavigateBack event on success`() = runTest {
|
|
161
|
+
// Arrange
|
|
162
|
+
val deleteUseCase = mockk<DeleteUserUseCase>()
|
|
163
|
+
coEvery { deleteUseCase("1") } returns Result.success(Unit)
|
|
164
|
+
viewModel = UserDetailViewModel(savedStateHandle, getUsersUseCase, deleteUseCase)
|
|
165
|
+
|
|
166
|
+
// Act + Assert
|
|
167
|
+
viewModel.events.test {
|
|
168
|
+
viewModel.onDeleteClick()
|
|
169
|
+
assertThat(awaitItem()).isEqualTo(UserDetailEvent.NavigateBack)
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
---
|
|
176
|
+
|
|
177
|
+
## MainDispatcherRule
|
|
178
|
+
|
|
179
|
+
```kotlin
|
|
180
|
+
// ✅ Replace Main dispatcher in tests (required for ViewModel tests)
|
|
181
|
+
class MainDispatcherRule(
|
|
182
|
+
private val dispatcher: TestDispatcher = UnconfinedTestDispatcher()
|
|
183
|
+
) : TestWatcher() {
|
|
184
|
+
|
|
185
|
+
override fun starting(description: Description) {
|
|
186
|
+
Dispatchers.setMain(dispatcher)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
override fun finished(description: Description) {
|
|
190
|
+
Dispatchers.resetMain()
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
---
|
|
196
|
+
|
|
197
|
+
## Testing Flows with Turbine
|
|
198
|
+
|
|
199
|
+
```kotlin
|
|
200
|
+
// ✅ Turbine — clean Flow assertion API
|
|
201
|
+
@Test
|
|
202
|
+
fun `observe users emits list updates`() = runTest {
|
|
203
|
+
val repository = FakeUserRepository()
|
|
204
|
+
val useCase = ObserveUsersUseCase(repository)
|
|
205
|
+
|
|
206
|
+
useCase().test {
|
|
207
|
+
// Initial empty state
|
|
208
|
+
assertThat(awaitItem()).isEmpty()
|
|
209
|
+
|
|
210
|
+
// Emit new data
|
|
211
|
+
val user = User(id = "1", name = "Ali", email = Email("ali@test.com"))
|
|
212
|
+
repository.emit(listOf(user))
|
|
213
|
+
assertThat(awaitItem()).containsExactly(user)
|
|
214
|
+
|
|
215
|
+
// Update existing
|
|
216
|
+
val updated = user.copy(name = "Ali Updated")
|
|
217
|
+
repository.emit(listOf(updated))
|
|
218
|
+
assertThat(awaitItem()).containsExactly(updated)
|
|
219
|
+
|
|
220
|
+
cancelAndIgnoreRemainingEvents()
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
---
|
|
226
|
+
|
|
227
|
+
## Testing with MockK
|
|
228
|
+
|
|
229
|
+
```kotlin
|
|
230
|
+
// ✅ MockK for mocking Kotlin classes
|
|
231
|
+
class UserRepositoryImplTest {
|
|
232
|
+
|
|
233
|
+
private val dao = mockk<UserDao>()
|
|
234
|
+
private val api = mockk<UserApiService>()
|
|
235
|
+
private val repository = UserRepositoryImpl(dao, api)
|
|
236
|
+
|
|
237
|
+
@Test
|
|
238
|
+
fun `getUser returns cached value when available`() = runTest {
|
|
239
|
+
// Arrange
|
|
240
|
+
val entity = UserEntity(id = "1", name = "Ali", email = "ali@test.com", role = "member")
|
|
241
|
+
coEvery { dao.getById("1") } returns entity
|
|
242
|
+
|
|
243
|
+
// Act
|
|
244
|
+
val result = repository.getUser("1")
|
|
245
|
+
|
|
246
|
+
// Assert
|
|
247
|
+
assertThat(result.isSuccess).isTrue()
|
|
248
|
+
coVerify(exactly = 0) { api.getUser(any()) } // ✅ API not called
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
@Test
|
|
252
|
+
fun `getUser fetches from API when not cached`() = runTest {
|
|
253
|
+
// Arrange
|
|
254
|
+
val dto = UserDto(id = "1", name = "Ali", email = "ali@test.com", role = "member")
|
|
255
|
+
coEvery { dao.getById("1") } returns null
|
|
256
|
+
coEvery { api.getUser("1") } returns dto
|
|
257
|
+
coEvery { dao.insert(any()) } just Runs
|
|
258
|
+
|
|
259
|
+
// Act
|
|
260
|
+
val result = repository.getUser("1")
|
|
261
|
+
|
|
262
|
+
// Assert
|
|
263
|
+
assertThat(result.isSuccess).isTrue()
|
|
264
|
+
coVerify(exactly = 1) { api.getUser("1") }
|
|
265
|
+
coVerify(exactly = 1) { dao.insert(any()) }
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
---
|
|
271
|
+
|
|
272
|
+
## Parameterized Tests
|
|
273
|
+
|
|
274
|
+
```kotlin
|
|
275
|
+
// ✅ Test multiple inputs with one test function
|
|
276
|
+
class EmailValidationTest {
|
|
277
|
+
|
|
278
|
+
@ParameterizedTest
|
|
279
|
+
@ValueSource(strings = ["ali@test.com", "user@domain.co.uk", "a@b.io"])
|
|
280
|
+
fun `valid email addresses are accepted`(email: String) {
|
|
281
|
+
assertThat(runCatching { Email(email) }.isSuccess).isTrue()
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
@ParameterizedTest
|
|
285
|
+
@ValueSource(strings = ["notanemail", "@nodomain", "missing@", ""])
|
|
286
|
+
fun `invalid email addresses throw`(email: String) {
|
|
287
|
+
assertThat(runCatching { Email(email) }.isFailure).isTrue()
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
---
|
|
293
|
+
|
|
294
|
+
## Anti-Patterns
|
|
295
|
+
|
|
296
|
+
- Testing implementation details — if refactoring breaks tests without changing behavior, tests are wrong
|
|
297
|
+
- `Thread.sleep()` in tests — use `runTest` and `TestDispatcher`
|
|
298
|
+
- Not resetting mocks between tests — use `@BeforeEach` or `clearAllMocks()`
|
|
299
|
+
- Testing private methods — test through the public API instead
|
|
300
|
+
- One giant test class — split by the class under test
|
|
301
|
+
|
|
302
|
+
---
|
|
303
|
+
|
|
304
|
+
## Related Skills
|
|
305
|
+
- `mocking` — MockK patterns and verification
|
|
306
|
+
- `fake-data` — building test data with factories
|
|
307
|
+
- `integration-testing` — testing multiple components together
|
|
308
|
+
- `coroutine` — coroutine dispatcher setup for tests
|
|
309
|
+
- `flow` — Flow testing with Turbine
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: bottom-sheet-pattern
|
|
3
|
+
description: >
|
|
4
|
+
Bottom sheet and side sheet patterns in Jetpack Compose with Material 3.
|
|
5
|
+
Load this skill when implementing modal bottom sheets, persistent bottom
|
|
6
|
+
sheets, side sheets, sheet state management, nested scrolling inside sheets,
|
|
7
|
+
or handling keyboard interaction with sheets.
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
# Bottom Sheet / Side Sheet Patterns
|
|
11
|
+
|
|
12
|
+
## Overview
|
|
13
|
+
Bottom sheets and side sheets are surface containers that slide into view from the edge of the screen. M3 provides `ModalBottomSheet` and `BottomSheetScaffold`. Side sheets are handled via custom `AnimatedVisibility` or `Drawer` depending on the use case.
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## Core Principles
|
|
18
|
+
|
|
19
|
+
- Use `ModalBottomSheet` for transient, overlay content (actions, pickers)
|
|
20
|
+
- Use `BottomSheetScaffold` for persistent sheets that coexist with screen content
|
|
21
|
+
- Always hoist `SheetState` to the caller — never manage it inside the sheet
|
|
22
|
+
- Add `imePadding()` inside sheet content when it contains input fields
|
|
23
|
+
- Never put a bottom sheet inside another bottom sheet
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## Modal Bottom Sheet
|
|
28
|
+
|
|
29
|
+
```kotlin
|
|
30
|
+
// ✅ Basic modal bottom sheet
|
|
31
|
+
@Composable
|
|
32
|
+
fun UserActionsSheet(
|
|
33
|
+
onDismiss: () -> Unit,
|
|
34
|
+
onEdit: () -> Unit,
|
|
35
|
+
onDelete: () -> Unit
|
|
36
|
+
) {
|
|
37
|
+
val sheetState = rememberModalBottomSheetState(
|
|
38
|
+
skipPartiallyExpanded = true // go directly to full height
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
ModalBottomSheet(
|
|
42
|
+
onDismissRequest = onDismiss,
|
|
43
|
+
sheetState = sheetState
|
|
44
|
+
) {
|
|
45
|
+
Column(
|
|
46
|
+
modifier = Modifier
|
|
47
|
+
.fillMaxWidth()
|
|
48
|
+
.padding(bottom = AppSpacing.lg)
|
|
49
|
+
) {
|
|
50
|
+
ListItem(
|
|
51
|
+
headlineContent = { Text("Edit") },
|
|
52
|
+
leadingContent = { Icon(Icons.Default.Edit, null) },
|
|
53
|
+
modifier = Modifier.clickable { onEdit(); onDismiss() }
|
|
54
|
+
)
|
|
55
|
+
ListItem(
|
|
56
|
+
headlineContent = { Text("Delete") },
|
|
57
|
+
leadingContent = { Icon(Icons.Default.Delete, null) },
|
|
58
|
+
modifier = Modifier.clickable { onDelete(); onDismiss() }
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ✅ Show/hide from parent
|
|
65
|
+
@Composable
|
|
66
|
+
fun UserScreen() {
|
|
67
|
+
var showSheet by remember { mutableStateOf(false) }
|
|
68
|
+
|
|
69
|
+
Scaffold { padding ->
|
|
70
|
+
// screen content
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (showSheet) {
|
|
74
|
+
UserActionsSheet(
|
|
75
|
+
onDismiss = { showSheet = false },
|
|
76
|
+
onEdit = { /* ... */ },
|
|
77
|
+
onDelete = { /* ... */ }
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
---
|
|
84
|
+
|
|
85
|
+
## Sheet with Input Fields (Keyboard)
|
|
86
|
+
|
|
87
|
+
```kotlin
|
|
88
|
+
// ✅ imePadding + windowInsetsPadding inside sheet with text fields
|
|
89
|
+
@Composable
|
|
90
|
+
fun AddItemSheet(
|
|
91
|
+
onDismiss: () -> Unit,
|
|
92
|
+
onAdd: (String) -> Unit
|
|
93
|
+
) {
|
|
94
|
+
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
|
95
|
+
var text by remember { mutableStateOf("") }
|
|
96
|
+
val focusRequester = remember { FocusRequester() }
|
|
97
|
+
|
|
98
|
+
ModalBottomSheet(
|
|
99
|
+
onDismissRequest = onDismiss,
|
|
100
|
+
sheetState = sheetState,
|
|
101
|
+
windowInsets = WindowInsets.ime // ✅ sheet moves above keyboard
|
|
102
|
+
) {
|
|
103
|
+
Column(
|
|
104
|
+
modifier = Modifier
|
|
105
|
+
.fillMaxWidth()
|
|
106
|
+
.padding(AppSpacing.md)
|
|
107
|
+
.imePadding() // ✅ extra safety for content inside
|
|
108
|
+
) {
|
|
109
|
+
OutlinedTextField(
|
|
110
|
+
value = text,
|
|
111
|
+
onValueChange = { text = it },
|
|
112
|
+
label = { Text("Item name") },
|
|
113
|
+
modifier = Modifier
|
|
114
|
+
.fillMaxWidth()
|
|
115
|
+
.focusRequester(focusRequester),
|
|
116
|
+
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
|
117
|
+
keyboardActions = KeyboardActions(onDone = { onAdd(text) })
|
|
118
|
+
)
|
|
119
|
+
Spacer(Modifier.height(AppSpacing.md))
|
|
120
|
+
Button(
|
|
121
|
+
onClick = { onAdd(text) },
|
|
122
|
+
modifier = Modifier.fillMaxWidth()
|
|
123
|
+
) {
|
|
124
|
+
Text("Add")
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
LaunchedEffect(Unit) {
|
|
130
|
+
focusRequester.requestFocus()
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
---
|
|
136
|
+
|
|
137
|
+
## Scrollable Content Inside Sheet
|
|
138
|
+
|
|
139
|
+
```kotlin
|
|
140
|
+
// ✅ Nested scroll in modal bottom sheet
|
|
141
|
+
@Composable
|
|
142
|
+
fun FilterSheet(
|
|
143
|
+
filters: List<Filter>,
|
|
144
|
+
onDismiss: () -> Unit
|
|
145
|
+
) {
|
|
146
|
+
val sheetState = rememberModalBottomSheetState()
|
|
147
|
+
|
|
148
|
+
ModalBottomSheet(
|
|
149
|
+
onDismissRequest = onDismiss,
|
|
150
|
+
sheetState = sheetState
|
|
151
|
+
) {
|
|
152
|
+
LazyColumn(
|
|
153
|
+
modifier = Modifier
|
|
154
|
+
.fillMaxWidth()
|
|
155
|
+
.heightIn(max = 400.dp), // ✅ cap height for large lists
|
|
156
|
+
contentPadding = PaddingValues(
|
|
157
|
+
start = AppSpacing.md,
|
|
158
|
+
end = AppSpacing.md,
|
|
159
|
+
bottom = AppSpacing.lg
|
|
160
|
+
)
|
|
161
|
+
) {
|
|
162
|
+
items(filters) { filter ->
|
|
163
|
+
FilterItem(filter)
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
---
|
|
171
|
+
|
|
172
|
+
## Persistent Bottom Sheet (BottomSheetScaffold)
|
|
173
|
+
|
|
174
|
+
```kotlin
|
|
175
|
+
// ✅ Persistent sheet coexists with screen content
|
|
176
|
+
@Composable
|
|
177
|
+
fun MapScreen() {
|
|
178
|
+
val scaffoldState = rememberBottomSheetScaffoldState(
|
|
179
|
+
bottomSheetState = rememberStandardBottomSheetState(
|
|
180
|
+
initialValue = SheetValue.PartiallyExpanded
|
|
181
|
+
)
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
BottomSheetScaffold(
|
|
185
|
+
scaffoldState = scaffoldState,
|
|
186
|
+
sheetContent = {
|
|
187
|
+
Column(
|
|
188
|
+
modifier = Modifier
|
|
189
|
+
.fillMaxWidth()
|
|
190
|
+
.padding(AppSpacing.md)
|
|
191
|
+
) {
|
|
192
|
+
Text("Location Details", style = MaterialTheme.typography.titleMedium)
|
|
193
|
+
// sheet content
|
|
194
|
+
}
|
|
195
|
+
},
|
|
196
|
+
sheetPeekHeight = 120.dp // ✅ height when partially expanded
|
|
197
|
+
) { padding ->
|
|
198
|
+
// main screen content (map)
|
|
199
|
+
Box(modifier = Modifier.padding(padding)) { ... }
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
---
|
|
205
|
+
|
|
206
|
+
## Side Sheet (Custom)
|
|
207
|
+
|
|
208
|
+
```kotlin
|
|
209
|
+
// ✅ Side sheet via AnimatedVisibility — for tablets or landscape
|
|
210
|
+
@Composable
|
|
211
|
+
fun SideSheet(
|
|
212
|
+
visible: Boolean,
|
|
213
|
+
onDismiss: () -> Unit,
|
|
214
|
+
content: @Composable () -> Unit
|
|
215
|
+
) {
|
|
216
|
+
AnimatedVisibility(
|
|
217
|
+
visible = visible,
|
|
218
|
+
enter = slideInHorizontally(initialOffsetX = { it }),
|
|
219
|
+
exit = slideOutHorizontally(targetOffsetX = { it })
|
|
220
|
+
) {
|
|
221
|
+
Surface(
|
|
222
|
+
modifier = Modifier
|
|
223
|
+
.fillMaxHeight()
|
|
224
|
+
.width(320.dp)
|
|
225
|
+
.align(Alignment.End), // in a Box parent
|
|
226
|
+
tonalElevation = 4.dp,
|
|
227
|
+
shadowElevation = 4.dp
|
|
228
|
+
) {
|
|
229
|
+
Column(modifier = Modifier.padding(AppSpacing.md)) {
|
|
230
|
+
content()
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ✅ Wrap in a Box at screen level
|
|
237
|
+
Box(modifier = Modifier.fillMaxSize()) {
|
|
238
|
+
MainContent()
|
|
239
|
+
SideSheet(visible = showSideSheet, onDismiss = { showSideSheet = false }) {
|
|
240
|
+
FilterPanel()
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
---
|
|
246
|
+
|
|
247
|
+
## Programmatic Sheet Control
|
|
248
|
+
|
|
249
|
+
```kotlin
|
|
250
|
+
// ✅ Expand / collapse programmatically
|
|
251
|
+
val sheetState = rememberModalBottomSheetState()
|
|
252
|
+
val scope = rememberCoroutineScope()
|
|
253
|
+
|
|
254
|
+
Button(onClick = {
|
|
255
|
+
scope.launch { sheetState.expand() }
|
|
256
|
+
}) { Text("Expand") }
|
|
257
|
+
|
|
258
|
+
Button(onClick = {
|
|
259
|
+
scope.launch { sheetState.hide() }
|
|
260
|
+
}) { Text("Hide") }
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
---
|
|
264
|
+
|
|
265
|
+
## Anti-Patterns
|
|
266
|
+
|
|
267
|
+
- Managing `SheetState` inside the sheet composable — hoist it to the caller
|
|
268
|
+
- Putting a `ModalBottomSheet` inside another `ModalBottomSheet`
|
|
269
|
+
- Not adding `imePadding()` or `windowInsets = WindowInsets.ime` in sheets with text fields
|
|
270
|
+
- Using `Dialog` instead of `ModalBottomSheet` for action menus on mobile
|
|
271
|
+
- Not capping height on scrollable sheet content — sheet can fill the entire screen
|
|
272
|
+
|
|
273
|
+
---
|
|
274
|
+
|
|
275
|
+
## Related Skills
|
|
276
|
+
- `compose` — Compose layout and animation fundamentals
|
|
277
|
+
- `keyboard-navigation` — IME handling inside sheets
|
|
278
|
+
- `material3` — M3 surface and elevation tokens
|
|
279
|
+
- `navigation` — navigating to/from content shown in sheets
|