@zigrivers/scaffold 3.6.0 → 3.8.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/README.md +127 -12
- package/content/knowledge/backend/backend-api-design.md +103 -0
- package/content/knowledge/backend/backend-architecture.md +100 -0
- package/content/knowledge/backend/backend-async-patterns.md +101 -0
- package/content/knowledge/backend/backend-auth-patterns.md +100 -0
- package/content/knowledge/backend/backend-conventions.md +105 -0
- package/content/knowledge/backend/backend-data-modeling.md +102 -0
- package/content/knowledge/backend/backend-deployment.md +100 -0
- package/content/knowledge/backend/backend-dev-environment.md +102 -0
- package/content/knowledge/backend/backend-observability.md +102 -0
- package/content/knowledge/backend/backend-project-structure.md +100 -0
- package/content/knowledge/backend/backend-requirements.md +103 -0
- package/content/knowledge/backend/backend-security.md +104 -0
- package/content/knowledge/backend/backend-testing.md +101 -0
- package/content/knowledge/backend/backend-worker-patterns.md +100 -0
- package/content/knowledge/cli/cli-architecture.md +101 -0
- package/content/knowledge/cli/cli-conventions.md +117 -0
- package/content/knowledge/cli/cli-dev-environment.md +121 -0
- package/content/knowledge/cli/cli-distribution-patterns.md +106 -0
- package/content/knowledge/cli/cli-interactivity-patterns.md +116 -0
- package/content/knowledge/cli/cli-output-patterns.md +107 -0
- package/content/knowledge/cli/cli-project-structure.md +124 -0
- package/content/knowledge/cli/cli-requirements.md +101 -0
- package/content/knowledge/cli/cli-shell-integration.md +130 -0
- package/content/knowledge/cli/cli-testing.md +134 -0
- package/content/knowledge/library/library-api-design.md +306 -0
- package/content/knowledge/library/library-architecture.md +247 -0
- package/content/knowledge/library/library-bundling.md +244 -0
- package/content/knowledge/library/library-conventions.md +229 -0
- package/content/knowledge/library/library-dev-environment.md +220 -0
- package/content/knowledge/library/library-documentation.md +300 -0
- package/content/knowledge/library/library-project-structure.md +237 -0
- package/content/knowledge/library/library-requirements.md +173 -0
- package/content/knowledge/library/library-security.md +257 -0
- package/content/knowledge/library/library-testing.md +319 -0
- package/content/knowledge/library/library-type-definitions.md +284 -0
- package/content/knowledge/library/library-versioning.md +300 -0
- package/content/knowledge/mobile-app/mobile-app-architecture.md +283 -0
- package/content/knowledge/mobile-app/mobile-app-conventions.md +180 -0
- package/content/knowledge/mobile-app/mobile-app-deployment.md +298 -0
- package/content/knowledge/mobile-app/mobile-app-dev-environment.md +257 -0
- package/content/knowledge/mobile-app/mobile-app-distribution.md +264 -0
- package/content/knowledge/mobile-app/mobile-app-observability.md +317 -0
- package/content/knowledge/mobile-app/mobile-app-offline-patterns.md +311 -0
- package/content/knowledge/mobile-app/mobile-app-project-structure.md +245 -0
- package/content/knowledge/mobile-app/mobile-app-push-notifications.md +321 -0
- package/content/knowledge/mobile-app/mobile-app-requirements.md +147 -0
- package/content/knowledge/mobile-app/mobile-app-security.md +338 -0
- package/content/knowledge/mobile-app/mobile-app-testing.md +400 -0
- package/content/knowledge/web-app/web-app-api-patterns.md +224 -0
- package/content/knowledge/web-app/web-app-architecture.md +116 -0
- package/content/knowledge/web-app/web-app-auth-patterns.md +256 -0
- package/content/knowledge/web-app/web-app-conventions.md +121 -0
- package/content/knowledge/web-app/web-app-data-patterns.md +218 -0
- package/content/knowledge/web-app/web-app-deployment-workflow.md +143 -0
- package/content/knowledge/web-app/web-app-deployment.md +134 -0
- package/content/knowledge/web-app/web-app-design-system.md +158 -0
- package/content/knowledge/web-app/web-app-dev-environment.md +173 -0
- package/content/knowledge/web-app/web-app-observability.md +221 -0
- package/content/knowledge/web-app/web-app-project-structure.md +160 -0
- package/content/knowledge/web-app/web-app-rendering-strategies.md +133 -0
- package/content/knowledge/web-app/web-app-requirements.md +112 -0
- package/content/knowledge/web-app/web-app-security.md +193 -0
- package/content/knowledge/web-app/web-app-session-patterns.md +214 -0
- package/content/knowledge/web-app/web-app-testing.md +249 -0
- package/content/knowledge/web-app/web-app-ux-patterns.md +162 -0
- package/content/methodology/backend-overlay.yml +73 -0
- package/content/methodology/cli-overlay.yml +69 -0
- package/content/methodology/library-overlay.yml +67 -0
- package/content/methodology/mobile-app-overlay.yml +71 -0
- package/content/methodology/web-app-overlay.yml +79 -0
- package/dist/cli/commands/init.d.ts +21 -0
- package/dist/cli/commands/init.d.ts.map +1 -1
- package/dist/cli/commands/init.js +261 -13
- package/dist/cli/commands/init.js.map +1 -1
- package/dist/cli/commands/init.test.js +206 -0
- package/dist/cli/commands/init.test.js.map +1 -1
- package/dist/config/schema.d.ts +1392 -64
- package/dist/config/schema.d.ts.map +1 -1
- package/dist/config/schema.js +82 -5
- package/dist/config/schema.js.map +1 -1
- package/dist/config/schema.test.js +302 -1
- package/dist/config/schema.test.js.map +1 -1
- package/dist/core/assembly/overlay-loader.d.ts.map +1 -1
- package/dist/core/assembly/overlay-loader.js +2 -1
- package/dist/core/assembly/overlay-loader.js.map +1 -1
- package/dist/core/assembly/overlay-loader.test.js +56 -0
- package/dist/core/assembly/overlay-loader.test.js.map +1 -1
- package/dist/e2e/game-pipeline.test.js +1 -0
- package/dist/e2e/game-pipeline.test.js.map +1 -1
- package/dist/e2e/project-type-overlays.test.d.ts +16 -0
- package/dist/e2e/project-type-overlays.test.d.ts.map +1 -0
- package/dist/e2e/project-type-overlays.test.js +834 -0
- package/dist/e2e/project-type-overlays.test.js.map +1 -0
- package/dist/types/config.d.ts +19 -2
- package/dist/types/config.d.ts.map +1 -1
- package/dist/types/index.d.ts +0 -1
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/index.js +0 -1
- package/dist/types/index.js.map +1 -1
- package/dist/wizard/questions.d.ts +27 -1
- package/dist/wizard/questions.d.ts.map +1 -1
- package/dist/wizard/questions.js +142 -3
- package/dist/wizard/questions.js.map +1 -1
- package/dist/wizard/questions.test.js +206 -8
- package/dist/wizard/questions.test.js.map +1 -1
- package/dist/wizard/wizard.d.ts +21 -0
- package/dist/wizard/wizard.d.ts.map +1 -1
- package/dist/wizard/wizard.js +27 -1
- package/dist/wizard/wizard.js.map +1 -1
- package/package.json +1 -1
- package/dist/types/wizard.d.ts +0 -14
- package/dist/types/wizard.d.ts.map +0 -1
- package/dist/types/wizard.js +0 -2
- package/dist/types/wizard.js.map +0 -1
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: mobile-app-testing
|
|
3
|
+
description: Unit tests, UI tests (XCTest/Espresso/Detox), snapshot tests, accessibility testing, and test architecture for iOS and Android
|
|
4
|
+
topics: [mobile-app, testing, xctest, espresso, detox, snapshot-testing, accessibility-testing, unit-tests]
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
Mobile testing requires a multi-layer strategy: unit tests for business logic, integration tests for repository and network layers, UI tests for critical user flows, and snapshot tests for visual regression. The test pyramid applies — fast unit tests outnumber slow UI tests. Mobile UI tests are inherently fragile (timing, simulator state, animations) — structure them to minimize flakiness and run only the highest-value flows in CI.
|
|
8
|
+
|
|
9
|
+
## Summary
|
|
10
|
+
|
|
11
|
+
iOS testing uses XCTest for unit and UI tests, with third-party additions (Quick/Nimble for BDD, snapshot-testing for visual regression). Android uses JUnit4/5 + Mockito/MockK for unit tests, Espresso for UI tests (in-process), and optional Detox for cross-platform end-to-end testing. Snapshot tests catch unintended UI changes automatically. Test architecture follows the same clean separation as production code — inject fakes, not mocks, for stable tests. Run unit tests on every commit; UI tests on PR merge.
|
|
12
|
+
|
|
13
|
+
## Deep Guidance
|
|
14
|
+
|
|
15
|
+
### iOS Unit Testing (XCTest)
|
|
16
|
+
|
|
17
|
+
**Test file structure**
|
|
18
|
+
```swift
|
|
19
|
+
import XCTest
|
|
20
|
+
@testable import MyApp
|
|
21
|
+
|
|
22
|
+
final class UserProfileViewModelTests: XCTestCase {
|
|
23
|
+
var sut: UserProfileViewModel! // System Under Test
|
|
24
|
+
var mockRepository: MockUserRepository!
|
|
25
|
+
|
|
26
|
+
override func setUp() {
|
|
27
|
+
super.setUp()
|
|
28
|
+
mockRepository = MockUserRepository()
|
|
29
|
+
sut = UserProfileViewModel(repository: mockRepository)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
override func tearDown() {
|
|
33
|
+
sut = nil
|
|
34
|
+
mockRepository = nil
|
|
35
|
+
super.tearDown()
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
func test_loadUser_success_setsUserOnState() async throws {
|
|
39
|
+
// Arrange
|
|
40
|
+
let expectedUser = User(id: "1", name: "Jane", email: "jane@example.com")
|
|
41
|
+
mockRepository.stubbedUser = expectedUser
|
|
42
|
+
|
|
43
|
+
// Act
|
|
44
|
+
await sut.loadUser(id: "1")
|
|
45
|
+
|
|
46
|
+
// Assert
|
|
47
|
+
XCTAssertEqual(sut.user, expectedUser)
|
|
48
|
+
XCTAssertFalse(sut.isLoading)
|
|
49
|
+
XCTAssertNil(sut.error)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
func test_loadUser_failure_setsErrorOnState() async {
|
|
53
|
+
// Arrange
|
|
54
|
+
mockRepository.stubbedError = NetworkError.serverError(500)
|
|
55
|
+
|
|
56
|
+
// Act
|
|
57
|
+
await sut.loadUser(id: "1")
|
|
58
|
+
|
|
59
|
+
// Assert
|
|
60
|
+
XCTAssertNil(sut.user)
|
|
61
|
+
XCTAssertNotNil(sut.error)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
**Protocol-based fakes over Mocks**
|
|
67
|
+
```swift
|
|
68
|
+
protocol UserRepository {
|
|
69
|
+
func fetchUser(id: String) async throws -> User
|
|
70
|
+
func updateUser(_ user: User) async throws
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Fake — implements the real behavior with in-memory data
|
|
74
|
+
final class FakeUserRepository: UserRepository {
|
|
75
|
+
var stubbedUser: User?
|
|
76
|
+
var stubbedError: Error?
|
|
77
|
+
var updatedUsers: [User] = []
|
|
78
|
+
|
|
79
|
+
func fetchUser(id: String) async throws -> User {
|
|
80
|
+
if let error = stubbedError { throw error }
|
|
81
|
+
return stubbedUser ?? User(id: id, name: "Test User", email: "test@example.com")
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
func updateUser(_ user: User) async throws {
|
|
85
|
+
if let error = stubbedError { throw error }
|
|
86
|
+
updatedUsers.append(user)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Fakes are preferable to generated mocks (Cuckoo, Mockingbird) because they compile without code generation, work in Swift Previews, and test realistic behavior rather than exact call sequences.
|
|
92
|
+
|
|
93
|
+
**Testing async code**
|
|
94
|
+
```swift
|
|
95
|
+
// Test async throws with async test methods (Swift concurrency)
|
|
96
|
+
func test_loadUser_callsRepository() async throws {
|
|
97
|
+
let result = try await sut.loadUser(id: "1")
|
|
98
|
+
XCTAssertEqual(result.id, "1")
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Test Combine publishers
|
|
102
|
+
func test_isLoading_trueWhileLoading() {
|
|
103
|
+
var loadingStates: [Bool] = []
|
|
104
|
+
let expectation = expectation(description: "Loading state changes")
|
|
105
|
+
expectation.expectedFulfillmentCount = 3 // false → true → false
|
|
106
|
+
|
|
107
|
+
let cancellable = sut.$isLoading.sink { loadingStates.append($0) }
|
|
108
|
+
|
|
109
|
+
Task { await sut.loadUser(id: "1") }
|
|
110
|
+
|
|
111
|
+
wait(for: [expectation], timeout: 2.0)
|
|
112
|
+
XCTAssertEqual(loadingStates, [false, true, false])
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Performance tests
|
|
116
|
+
func test_fetchAllUsers_performance() throws {
|
|
117
|
+
measure {
|
|
118
|
+
let _ = try! mockRepository.fetchAllUsers()
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
### Android Unit Testing (JUnit + MockK)
|
|
124
|
+
|
|
125
|
+
**ViewModel unit test**
|
|
126
|
+
```kotlin
|
|
127
|
+
@OptIn(ExperimentalCoroutinesApi::class)
|
|
128
|
+
class UserProfileViewModelTest {
|
|
129
|
+
|
|
130
|
+
@get:Rule val mainDispatcherRule = MainDispatcherRule() // replaces Main dispatcher with test dispatcher
|
|
131
|
+
|
|
132
|
+
private val userRepository = mockk<UserRepository>()
|
|
133
|
+
private lateinit var viewModel: UserProfileViewModel
|
|
134
|
+
|
|
135
|
+
@BeforeEach
|
|
136
|
+
fun setUp() {
|
|
137
|
+
viewModel = UserProfileViewModel(userRepository)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
@Test
|
|
141
|
+
fun `loadUser success sets user in state`() = runTest {
|
|
142
|
+
// Arrange
|
|
143
|
+
val expectedUser = User(id = "1", name = "Jane", email = "jane@example.com")
|
|
144
|
+
coEvery { userRepository.fetchUser("1") } returns Result.success(expectedUser)
|
|
145
|
+
|
|
146
|
+
// Act
|
|
147
|
+
viewModel.loadUser("1")
|
|
148
|
+
advanceUntilIdle()
|
|
149
|
+
|
|
150
|
+
// Assert
|
|
151
|
+
val state = viewModel.uiState.value
|
|
152
|
+
assertThat(state.user).isEqualTo(expectedUser)
|
|
153
|
+
assertThat(state.isLoading).isFalse()
|
|
154
|
+
assertThat(state.error).isNull()
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
@Test
|
|
158
|
+
fun `loadUser failure sets error in state`() = runTest {
|
|
159
|
+
coEvery { userRepository.fetchUser(any()) } returns Result.failure(IOException("Network error"))
|
|
160
|
+
|
|
161
|
+
viewModel.loadUser("1")
|
|
162
|
+
advanceUntilIdle()
|
|
163
|
+
|
|
164
|
+
assertThat(viewModel.uiState.value.error).isNotNull()
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
**MainDispatcherRule (required for ViewModel tests)**
|
|
170
|
+
```kotlin
|
|
171
|
+
class MainDispatcherRule(
|
|
172
|
+
private val dispatcher: TestCoroutineDispatcher = TestCoroutineDispatcher()
|
|
173
|
+
) : TestWatcher() {
|
|
174
|
+
override fun starting(description: Description) {
|
|
175
|
+
Dispatchers.setMain(dispatcher)
|
|
176
|
+
}
|
|
177
|
+
override fun finished(description: Description) {
|
|
178
|
+
Dispatchers.resetMain()
|
|
179
|
+
dispatcher.cleanupTestCoroutines()
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
**Repository integration test (with in-memory Room)**
|
|
185
|
+
```kotlin
|
|
186
|
+
@RunWith(AndroidJUnit4::class)
|
|
187
|
+
class UserRepositoryTest {
|
|
188
|
+
private lateinit var database: AppDatabase
|
|
189
|
+
private lateinit var userDao: UserDao
|
|
190
|
+
|
|
191
|
+
@Before
|
|
192
|
+
fun setUp() {
|
|
193
|
+
database = Room.inMemoryDatabaseBuilder(
|
|
194
|
+
ApplicationProvider.getApplicationContext(),
|
|
195
|
+
AppDatabase::class.java
|
|
196
|
+
).allowMainThreadQueries().build()
|
|
197
|
+
userDao = database.userDao()
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
@After
|
|
201
|
+
fun tearDown() {
|
|
202
|
+
database.close()
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
@Test
|
|
206
|
+
fun insertAndRetrieveUser() = runTest {
|
|
207
|
+
val user = UserEntity(id = "1", name = "Jane", email = "jane@example.com", updatedAt = 0L)
|
|
208
|
+
userDao.upsert(user)
|
|
209
|
+
val retrieved = userDao.observeUsers().first()
|
|
210
|
+
assertThat(retrieved).contains(user)
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
### UI Testing
|
|
216
|
+
|
|
217
|
+
**iOS XCUITest**
|
|
218
|
+
```swift
|
|
219
|
+
final class LoginUITests: XCTestCase {
|
|
220
|
+
var app: XCUIApplication!
|
|
221
|
+
|
|
222
|
+
override func setUpWithError() throws {
|
|
223
|
+
continueAfterFailure = false
|
|
224
|
+
app = XCUIApplication()
|
|
225
|
+
app.launchArguments = ["--uitesting", "--reset-state"]
|
|
226
|
+
app.launch()
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
func test_login_validCredentials_navigatesToHome() throws {
|
|
230
|
+
let emailField = app.textFields["login_email_field"]
|
|
231
|
+
XCTAssertTrue(emailField.waitForExistence(timeout: 5))
|
|
232
|
+
emailField.tap()
|
|
233
|
+
emailField.typeText("test@example.com")
|
|
234
|
+
|
|
235
|
+
let passwordField = app.secureTextFields["login_password_field"]
|
|
236
|
+
passwordField.tap()
|
|
237
|
+
passwordField.typeText("password123")
|
|
238
|
+
|
|
239
|
+
app.buttons["login_submit_button"].tap()
|
|
240
|
+
|
|
241
|
+
let homeTitle = app.staticTexts["home_screen_title"]
|
|
242
|
+
XCTAssertTrue(homeTitle.waitForExistence(timeout: 10))
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
Accessibility identifiers:
|
|
248
|
+
```swift
|
|
249
|
+
// Set in SwiftUI
|
|
250
|
+
Button("Submit") { submitAction() }
|
|
251
|
+
.accessibilityIdentifier("login_submit_button")
|
|
252
|
+
|
|
253
|
+
// Set in UIKit
|
|
254
|
+
loginButton.accessibilityIdentifier = "login_submit_button"
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
Use `accessibilityIdentifier` for test targeting — never use display text or position-based queries which break on localization or layout changes.
|
|
258
|
+
|
|
259
|
+
**Android Espresso**
|
|
260
|
+
```kotlin
|
|
261
|
+
@RunWith(AndroidJUnit4::class)
|
|
262
|
+
class LoginInstrumentedTest {
|
|
263
|
+
|
|
264
|
+
@get:Rule val activityRule = ActivityScenarioRule(LoginActivity::class.java)
|
|
265
|
+
|
|
266
|
+
@Test
|
|
267
|
+
fun login_validCredentials_navigatesToHome() {
|
|
268
|
+
onView(withId(R.id.email_field))
|
|
269
|
+
.perform(typeText("test@example.com"), closeSoftKeyboard())
|
|
270
|
+
onView(withId(R.id.password_field))
|
|
271
|
+
.perform(typeText("password123"), closeSoftKeyboard())
|
|
272
|
+
onView(withId(R.id.submit_button)).perform(click())
|
|
273
|
+
|
|
274
|
+
onView(withId(R.id.home_screen_title))
|
|
275
|
+
.check(matches(isDisplayed()))
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
**Compose UI testing**
|
|
281
|
+
```kotlin
|
|
282
|
+
@RunWith(AndroidJUnit4::class)
|
|
283
|
+
class UserProfileScreenTest {
|
|
284
|
+
|
|
285
|
+
@get:Rule val composeTestRule = createComposeRule()
|
|
286
|
+
|
|
287
|
+
@Test
|
|
288
|
+
fun userCard_displaysName() {
|
|
289
|
+
val user = User(id = "1", name = "Jane Smith", email = "jane@example.com")
|
|
290
|
+
composeTestRule.setContent {
|
|
291
|
+
MyAppTheme { UserCard(user = user) }
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
composeTestRule.onNodeWithText("Jane Smith").assertIsDisplayed()
|
|
295
|
+
composeTestRule.onNodeWithContentDescription("User avatar").assertExists()
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
### Snapshot Testing
|
|
301
|
+
|
|
302
|
+
**iOS: swift-snapshot-testing (Point-Free)**
|
|
303
|
+
```swift
|
|
304
|
+
import SnapshotTesting
|
|
305
|
+
|
|
306
|
+
final class UserCardSnapshotTests: XCTestCase {
|
|
307
|
+
func test_userCard_defaultState() {
|
|
308
|
+
let view = UserCardView(user: .fixture)
|
|
309
|
+
assertSnapshot(of: view, as: .image(on: .iPhone13Pro))
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
func test_userCard_loadingState() {
|
|
313
|
+
let view = UserCardView(isLoading: true)
|
|
314
|
+
assertSnapshot(of: view, as: .image(on: .iPhone13Pro))
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
func test_userCard_darkMode() {
|
|
318
|
+
let view = UserCardView(user: .fixture)
|
|
319
|
+
assertSnapshot(of: view, as: .image(on: .iPhone13Pro, traits: .init(userInterfaceStyle: .dark)))
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
Run snapshots in `record` mode once to generate reference images, then in normal mode to detect regressions. Commit reference images to git. Update references only for intentional UI changes.
|
|
325
|
+
|
|
326
|
+
**Android: Paparazzi (Cashapp)**
|
|
327
|
+
```kotlin
|
|
328
|
+
@RunWith(AndroidJUnit4::class)
|
|
329
|
+
class UserCardSnapshotTest {
|
|
330
|
+
@get:Rule val paparazzi = Paparazzi(deviceConfig = DeviceConfig.PIXEL_6)
|
|
331
|
+
|
|
332
|
+
@Test
|
|
333
|
+
fun userCard_defaultState() {
|
|
334
|
+
paparazzi.snapshot {
|
|
335
|
+
MyAppTheme {
|
|
336
|
+
UserCard(user = previewUser)
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
Paparazzi runs on the JVM without a device or emulator — fast, CI-friendly.
|
|
344
|
+
|
|
345
|
+
### Accessibility Testing
|
|
346
|
+
|
|
347
|
+
**iOS: Accessibility Inspector + XCTest**
|
|
348
|
+
```swift
|
|
349
|
+
func test_loginButton_hasAccessibilityLabel() {
|
|
350
|
+
let button = app.buttons["login_submit_button"]
|
|
351
|
+
XCTAssertTrue(button.exists)
|
|
352
|
+
XCTAssertFalse(button.label.isEmpty) // has accessibility label
|
|
353
|
+
}
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
Automated accessibility audit:
|
|
357
|
+
```swift
|
|
358
|
+
func test_homeScreen_passesAccessibilityAudit() throws {
|
|
359
|
+
// iOS 17+: built-in accessibility audit
|
|
360
|
+
try app.performAccessibilityAudit()
|
|
361
|
+
}
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
`performAccessibilityAudit()` checks: missing labels, insufficient contrast, small touch targets, and other WCAG violations automatically.
|
|
365
|
+
|
|
366
|
+
**Android: Accessibility Test Framework**
|
|
367
|
+
```kotlin
|
|
368
|
+
@RunWith(AndroidJUnit4::class)
|
|
369
|
+
class AccessibilityTest {
|
|
370
|
+
@get:Rule val rule = AccessibilityChecksRule() // auto-runs checks on every view interaction
|
|
371
|
+
|
|
372
|
+
@Test
|
|
373
|
+
fun loginScreen_allElementsAccessible() {
|
|
374
|
+
onView(withId(R.id.submit_button)).perform(click())
|
|
375
|
+
// AccessibilityChecksRule automatically fails on accessibility issues
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
Compose accessibility testing:
|
|
381
|
+
```kotlin
|
|
382
|
+
composeTestRule.onNodeWithText("Submit")
|
|
383
|
+
.assertHasClickAction()
|
|
384
|
+
.assertContentDescriptionEquals("Submit order")
|
|
385
|
+
.assertIsEnabled()
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
### CI Test Strategy
|
|
389
|
+
|
|
390
|
+
**What runs when**
|
|
391
|
+
- Every commit: unit tests (fast, < 60 seconds)
|
|
392
|
+
- Every PR: unit tests + instrumented tests on emulator (< 10 minutes)
|
|
393
|
+
- Every merge to main: full suite including UI tests and snapshot tests (< 30 minutes)
|
|
394
|
+
- Nightly: device farm run across full device matrix
|
|
395
|
+
|
|
396
|
+
**Handling flaky tests**
|
|
397
|
+
- Retry mechanism in CI: rerun failed tests up to 2 times before marking as failed
|
|
398
|
+
- iOS: `--retry-tests-on-failure` flag in `xcodebuild test`
|
|
399
|
+
- Android: `android { testOptions { unitTests { isReturnDefaultValues = true } } }`
|
|
400
|
+
- Track flaky tests in a flakiness registry — tests flagged as flaky do not block merges but get prioritized for fixing
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: web-app-api-patterns
|
|
3
|
+
description: REST API design for web clients, GraphQL client patterns, error handling strategies, request deduplication, auth injection, and CORS
|
|
4
|
+
topics: [web-app, api, rest, graphql, cors, error-handling, auth]
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
The API layer is the seam between frontend and backend. Poor design here manifests as waterfall requests that serialize page loads, inconsistent error shapes that require fragile client-side guessing, auth token handling bugs that cause random 401 errors, and CORS misconfigurations that block legitimate requests. A well-designed API client is boring: it handles auth transparently, errors consistently, and requests efficiently — so product engineers can focus on features rather than network plumbing.
|
|
8
|
+
|
|
9
|
+
## Summary
|
|
10
|
+
|
|
11
|
+
### REST API Design for Web Clients
|
|
12
|
+
|
|
13
|
+
REST conventions for web clients go beyond HTTP verb selection:
|
|
14
|
+
|
|
15
|
+
**Resource naming:** `/users/{id}/posts` not `/getUserPosts`. Nouns, not verbs. Plural collections.
|
|
16
|
+
|
|
17
|
+
**Consistent response envelope:** Every response should have a predictable shape. Either always return the resource directly (`200 OK` with the object) or always wrap it (`{ data: {...}, meta: {...} }`) — never mix.
|
|
18
|
+
|
|
19
|
+
**Status codes must be semantically correct:**
|
|
20
|
+
- `200 OK` — successful read
|
|
21
|
+
- `201 Created` — successful create (include `Location` header with new resource URL)
|
|
22
|
+
- `204 No Content` — successful delete or update with no response body
|
|
23
|
+
- `400 Bad Request` — validation failure (include field-level errors)
|
|
24
|
+
- `401 Unauthorized` — not authenticated
|
|
25
|
+
- `403 Forbidden` — authenticated but not authorized
|
|
26
|
+
- `404 Not Found` — resource does not exist
|
|
27
|
+
- `409 Conflict` — state conflict (duplicate resource, version mismatch)
|
|
28
|
+
- `422 Unprocessable Entity` — business rule violation
|
|
29
|
+
- `429 Too Many Requests` — rate limited (include `Retry-After` header)
|
|
30
|
+
|
|
31
|
+
**Error response shape (standardize on RFC 7807 Problem Details):**
|
|
32
|
+
```json
|
|
33
|
+
{
|
|
34
|
+
"type": "https://api.example.com/errors/validation",
|
|
35
|
+
"title": "Validation Failed",
|
|
36
|
+
"status": 400,
|
|
37
|
+
"detail": "2 fields failed validation",
|
|
38
|
+
"errors": [
|
|
39
|
+
{ "field": "email", "message": "Invalid email format" },
|
|
40
|
+
{ "field": "username", "message": "Username already taken" }
|
|
41
|
+
]
|
|
42
|
+
}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### GraphQL Client Patterns
|
|
46
|
+
|
|
47
|
+
GraphQL shifts the API design from server-defined endpoints to client-defined data requirements:
|
|
48
|
+
|
|
49
|
+
**Fragments for co-location:** Define data requirements alongside the component that uses the data. This prevents over-fetching and makes it obvious when a component's data requirements change.
|
|
50
|
+
|
|
51
|
+
**Normalized caching:** Apollo Client and urql maintain a normalized cache where each entity is stored once (by `__typename + id`) and automatically updated across all queries that reference it. Mutations that return the updated entity automatically update all queries that include that entity.
|
|
52
|
+
|
|
53
|
+
**Fragment colocation pattern:** Each component defines its own fragment; the parent query composes them. This is the Relay-inspired pattern that scales to large teams.
|
|
54
|
+
|
|
55
|
+
### Error Handling: Toast vs Inline
|
|
56
|
+
|
|
57
|
+
Two display patterns for API errors, each appropriate in different contexts:
|
|
58
|
+
|
|
59
|
+
**Toast notifications:** Background mutations, non-blocking operations, and errors where the user's current context is unchanged. "Failed to save changes — try again." The user doesn't lose their work.
|
|
60
|
+
|
|
61
|
+
**Inline errors:** Form submissions, operations where the error requires user action to resolve. Show the error adjacent to the field or action that caused it. A `400` validation error must show which fields failed.
|
|
62
|
+
|
|
63
|
+
**Never show raw API errors to users.** Map error codes to human-readable messages on the client. Log the technical detail to your error tracker.
|
|
64
|
+
|
|
65
|
+
### Request Deduplication
|
|
66
|
+
|
|
67
|
+
Multiple components mounting simultaneously and each calling the same endpoint produces redundant parallel requests. Deduplication ensures the second identical in-flight request waits for the first's result rather than issuing a new request.
|
|
68
|
+
|
|
69
|
+
React Query deduplicates automatically — all components with the same `queryKey` share one in-flight request. For custom fetch layers, implement deduplication with a request-in-flight map.
|
|
70
|
+
|
|
71
|
+
## Deep Guidance
|
|
72
|
+
|
|
73
|
+
### API Client with Auth Injection and Token Refresh
|
|
74
|
+
|
|
75
|
+
```typescript
|
|
76
|
+
// api-client.ts — Centralized API client with automatic token refresh
|
|
77
|
+
import ky from 'ky'; // Or axios, or native fetch
|
|
78
|
+
|
|
79
|
+
let isRefreshing = false;
|
|
80
|
+
let refreshQueue: Array<(token: string) => void> = [];
|
|
81
|
+
|
|
82
|
+
export const apiClient = ky.create({
|
|
83
|
+
prefixUrl: process.env.NEXT_PUBLIC_API_URL,
|
|
84
|
+
hooks: {
|
|
85
|
+
beforeRequest: [
|
|
86
|
+
async (request) => {
|
|
87
|
+
const token = getAccessToken(); // From memory, not localStorage
|
|
88
|
+
if (token) {
|
|
89
|
+
request.headers.set('Authorization', `Bearer ${token}`);
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
],
|
|
93
|
+
afterResponse: [
|
|
94
|
+
async (request, options, response) => {
|
|
95
|
+
if (response.status !== 401) return response;
|
|
96
|
+
|
|
97
|
+
// Token expired — refresh and retry
|
|
98
|
+
if (isRefreshing) {
|
|
99
|
+
// Queue this request until refresh completes
|
|
100
|
+
return new Promise((resolve) => {
|
|
101
|
+
refreshQueue.push((newToken) => {
|
|
102
|
+
request.headers.set('Authorization', `Bearer ${newToken}`);
|
|
103
|
+
resolve(ky(request));
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
isRefreshing = true;
|
|
109
|
+
try {
|
|
110
|
+
const newToken = await refreshAccessToken();
|
|
111
|
+
setAccessToken(newToken);
|
|
112
|
+
|
|
113
|
+
// Replay all queued requests with new token
|
|
114
|
+
refreshQueue.forEach(cb => cb(newToken));
|
|
115
|
+
refreshQueue = [];
|
|
116
|
+
|
|
117
|
+
// Retry the original request
|
|
118
|
+
request.headers.set('Authorization', `Bearer ${newToken}`);
|
|
119
|
+
return ky(request);
|
|
120
|
+
} catch (refreshError) {
|
|
121
|
+
// Refresh failed — redirect to login
|
|
122
|
+
clearSession();
|
|
123
|
+
window.location.href = '/login';
|
|
124
|
+
throw refreshError;
|
|
125
|
+
} finally {
|
|
126
|
+
isRefreshing = false;
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
],
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
The token refresh queue pattern prevents multiple simultaneous 401s from triggering multiple refresh requests. Only the first request initiates the refresh; subsequent requests queue and replay once the token is available.
|
|
135
|
+
|
|
136
|
+
### CORS Configuration
|
|
137
|
+
|
|
138
|
+
CORS is a browser-enforced security mechanism, not an API security mechanism. Servers must explicitly allow cross-origin requests from trusted origins.
|
|
139
|
+
|
|
140
|
+
```typescript
|
|
141
|
+
// Express CORS configuration
|
|
142
|
+
import cors from 'cors';
|
|
143
|
+
|
|
144
|
+
const allowedOrigins = [
|
|
145
|
+
'https://app.example.com',
|
|
146
|
+
'https://www.example.com',
|
|
147
|
+
// Development origins — only in non-production
|
|
148
|
+
...(process.env.NODE_ENV !== 'production' ? ['http://localhost:3000'] : []),
|
|
149
|
+
];
|
|
150
|
+
|
|
151
|
+
app.use(cors({
|
|
152
|
+
origin: (origin, callback) => {
|
|
153
|
+
// Allow requests with no origin (server-to-server, curl)
|
|
154
|
+
if (!origin) return callback(null, true);
|
|
155
|
+
if (allowedOrigins.includes(origin)) return callback(null, true);
|
|
156
|
+
callback(new Error(`Origin ${origin} not allowed by CORS policy`));
|
|
157
|
+
},
|
|
158
|
+
credentials: true, // Required when client sends cookies
|
|
159
|
+
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
|
160
|
+
allowedHeaders: ['Content-Type', 'Authorization', 'X-Request-ID'],
|
|
161
|
+
exposedHeaders: ['X-Request-ID', 'X-Rate-Limit-Remaining'],
|
|
162
|
+
maxAge: 86400, // Cache preflight for 24 hours (reduces OPTIONS requests)
|
|
163
|
+
}));
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
**CORS mistakes to avoid:**
|
|
167
|
+
- `origin: '*'` with `credentials: true` is invalid and rejected by browsers
|
|
168
|
+
- Wildcard subdomain matching (`*.example.com`) requires explicit pattern matching, not a string
|
|
169
|
+
- Missing `maxAge` causes an OPTIONS preflight on every non-simple cross-origin request
|
|
170
|
+
|
|
171
|
+
### GraphQL Fragment Colocation
|
|
172
|
+
|
|
173
|
+
```typescript
|
|
174
|
+
// UserCard.tsx — Component defines its own data requirements
|
|
175
|
+
const USER_CARD_FRAGMENT = gql`
|
|
176
|
+
fragment UserCardFields on User {
|
|
177
|
+
id
|
|
178
|
+
displayName
|
|
179
|
+
avatarUrl
|
|
180
|
+
followerCount
|
|
181
|
+
}
|
|
182
|
+
`;
|
|
183
|
+
|
|
184
|
+
function UserCard({ user }: { user: UserCardFields }) {
|
|
185
|
+
return (/* render using user.displayName, user.avatarUrl, etc. */);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
UserCard.fragments = { user: USER_CARD_FRAGMENT };
|
|
189
|
+
|
|
190
|
+
// ProfilePage.tsx — Composes fragments, doesn't know what UserCard needs
|
|
191
|
+
const PROFILE_PAGE_QUERY = gql`
|
|
192
|
+
query ProfilePage($userId: ID!) {
|
|
193
|
+
user(id: $userId) {
|
|
194
|
+
...UserCardFields
|
|
195
|
+
email # Only ProfilePage needs this
|
|
196
|
+
createdAt
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
${UserCard.fragments.user}
|
|
200
|
+
`;
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
When `UserCard` needs a new field, the change is isolated to the fragment and its parent query is automatically updated. No need to modify multiple components or add fields "just in case."
|
|
204
|
+
|
|
205
|
+
### API Error Handling Pattern
|
|
206
|
+
|
|
207
|
+
```typescript
|
|
208
|
+
// Typed error handling — map API errors to user-facing messages
|
|
209
|
+
const API_ERROR_MESSAGES: Record<string, string> = {
|
|
210
|
+
'validation/email-taken': 'That email address is already registered.',
|
|
211
|
+
'auth/session-expired': 'Your session has expired. Please sign in again.',
|
|
212
|
+
'rate-limit/exceeded': 'Too many attempts. Please wait a moment and try again.',
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
function getErrorMessage(error: ApiError): string {
|
|
216
|
+
if (error.type && API_ERROR_MESSAGES[error.type]) {
|
|
217
|
+
return API_ERROR_MESSAGES[error.type];
|
|
218
|
+
}
|
|
219
|
+
// Safe fallback — never expose raw server errors
|
|
220
|
+
return 'Something went wrong. Please try again.';
|
|
221
|
+
}
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
Maintain error message strings in one place. Never construct user-facing messages from raw `error.message` strings returned by the server.
|