@buivietphi/skill-mobile-mt 2.0.1 → 2.2.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/AGENTS.md +96 -40
- package/README.md +77 -40
- package/SKILL.md +762 -54
- package/package.json +1 -1
- package/shared/bug-detection.md +411 -27
- package/shared/code-generation-templates.md +656 -0
- package/shared/code-review.md +899 -37
- package/shared/complex-ui-patterns.md +526 -0
- package/shared/data-flow-patterns.md +422 -0
- package/shared/debugging-intelligence.md +787 -0
- package/shared/error-handling.md +394 -0
- package/shared/i18n-localization.md +426 -0
- package/shared/intent-analysis.md +473 -0
- package/shared/navigation-patterns.md +375 -0
- package/shared/prompt-engineering.md +176 -20
- package/shared/spec-to-code.md +293 -0
- package/shared/storage-patterns.md +312 -0
- package/shared/testing-patterns.md +428 -0
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
# Testing Patterns — Component, Integration & Snapshot
|
|
2
|
+
|
|
3
|
+
> On-demand module. Loaded when writing unit tests, component tests, or integration tests.
|
|
4
|
+
> Contains production test templates — not just framework setup.
|
|
5
|
+
> For E2E tests (Detox/Maestro), see testing-strategy.md instead.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Test File Structure
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
RULE: Tests live next to the code they test.
|
|
13
|
+
RULE: Test file = [filename].test.ts or [filename].spec.ts
|
|
14
|
+
|
|
15
|
+
src/features/product/
|
|
16
|
+
├── ProductDetailScreen.tsx
|
|
17
|
+
├── ProductDetailScreen.test.tsx ← Component test
|
|
18
|
+
├── hooks/
|
|
19
|
+
│ ├── useProductDetail.ts
|
|
20
|
+
│ └── useProductDetail.test.ts ← Hook test
|
|
21
|
+
├── services/
|
|
22
|
+
│ ├── productService.ts
|
|
23
|
+
│ └── productService.test.ts ← Service test
|
|
24
|
+
└── types/
|
|
25
|
+
└── product.types.ts ← No test (types are compile-time)
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## React Native Component Tests (React Testing Library)
|
|
31
|
+
|
|
32
|
+
### Basic Component Test
|
|
33
|
+
|
|
34
|
+
```typescript
|
|
35
|
+
// components/ProductCard.test.tsx
|
|
36
|
+
import { render, screen, fireEvent } from '@testing-library/react-native';
|
|
37
|
+
import { ProductCard } from './ProductCard';
|
|
38
|
+
import { mockProduct } from '@/test/factories';
|
|
39
|
+
|
|
40
|
+
describe('ProductCard', () => {
|
|
41
|
+
const onPress = jest.fn();
|
|
42
|
+
|
|
43
|
+
beforeEach(() => jest.clearAllMocks());
|
|
44
|
+
|
|
45
|
+
it('renders product title and price', () => {
|
|
46
|
+
render(<ProductCard product={mockProduct()} onPress={onPress} />);
|
|
47
|
+
|
|
48
|
+
expect(screen.getByText('Test Product')).toBeTruthy();
|
|
49
|
+
expect(screen.getByText('$29.99')).toBeTruthy();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('calls onPress when tapped', () => {
|
|
53
|
+
render(<ProductCard product={mockProduct()} onPress={onPress} />);
|
|
54
|
+
|
|
55
|
+
fireEvent.press(screen.getByText('Test Product'));
|
|
56
|
+
expect(onPress).toHaveBeenCalledTimes(1);
|
|
57
|
+
expect(onPress).toHaveBeenCalledWith(mockProduct().id);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('shows out of stock badge when not in stock', () => {
|
|
61
|
+
render(<ProductCard product={mockProduct({ inStock: false })} onPress={onPress} />);
|
|
62
|
+
|
|
63
|
+
expect(screen.getByText('Out of Stock')).toBeTruthy();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('does not show badge when in stock', () => {
|
|
67
|
+
render(<ProductCard product={mockProduct({ inStock: true })} onPress={onPress} />);
|
|
68
|
+
|
|
69
|
+
expect(screen.queryByText('Out of Stock')).toBeNull();
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### Test with Providers (Navigation, Theme, Query)
|
|
75
|
+
|
|
76
|
+
```typescript
|
|
77
|
+
// test/renderWithProviders.tsx
|
|
78
|
+
import { render, RenderOptions } from '@testing-library/react-native';
|
|
79
|
+
import { NavigationContainer } from '@react-navigation/native';
|
|
80
|
+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
81
|
+
import { ThemeProvider } from '@/theme/ThemeProvider';
|
|
82
|
+
|
|
83
|
+
function AllProviders({ children }: { children: React.ReactNode }) {
|
|
84
|
+
const queryClient = new QueryClient({
|
|
85
|
+
defaultOptions: {
|
|
86
|
+
queries: { retry: false, gcTime: 0 },
|
|
87
|
+
mutations: { retry: false },
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
<QueryClientProvider client={queryClient}>
|
|
93
|
+
<ThemeProvider>
|
|
94
|
+
<NavigationContainer>
|
|
95
|
+
{children}
|
|
96
|
+
</NavigationContainer>
|
|
97
|
+
</ThemeProvider>
|
|
98
|
+
</QueryClientProvider>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function renderWithProviders(ui: React.ReactElement, options?: RenderOptions) {
|
|
103
|
+
return render(ui, { wrapper: AllProviders, ...options });
|
|
104
|
+
}
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### Screen Test (4 states)
|
|
108
|
+
|
|
109
|
+
```typescript
|
|
110
|
+
// features/product/ProductDetailScreen.test.tsx
|
|
111
|
+
import { renderWithProviders } from '@/test/renderWithProviders';
|
|
112
|
+
import { screen, waitFor } from '@testing-library/react-native';
|
|
113
|
+
import { ProductDetailScreen } from './ProductDetailScreen';
|
|
114
|
+
import { productService } from './services/productService';
|
|
115
|
+
import { mockProduct } from '@/test/factories';
|
|
116
|
+
|
|
117
|
+
jest.mock('./services/productService');
|
|
118
|
+
const mockedService = productService as jest.Mocked<typeof productService>;
|
|
119
|
+
|
|
120
|
+
const route = { params: { productId: 'prod-1' as ProductId } };
|
|
121
|
+
|
|
122
|
+
describe('ProductDetailScreen', () => {
|
|
123
|
+
it('shows skeleton while loading', () => {
|
|
124
|
+
mockedService.getById.mockReturnValue(new Promise(() => {})); // never resolves
|
|
125
|
+
renderWithProviders(<ProductDetailScreen route={route} />);
|
|
126
|
+
|
|
127
|
+
expect(screen.getByTestId('product-skeleton')).toBeTruthy();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('shows product data on success', async () => {
|
|
131
|
+
mockedService.getById.mockResolvedValue(mockProduct({ title: 'Blue Shirt' }));
|
|
132
|
+
renderWithProviders(<ProductDetailScreen route={route} />);
|
|
133
|
+
|
|
134
|
+
await waitFor(() => {
|
|
135
|
+
expect(screen.getByText('Blue Shirt')).toBeTruthy();
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('shows error view on failure', async () => {
|
|
140
|
+
mockedService.getById.mockRejectedValue(new Error('Server error'));
|
|
141
|
+
renderWithProviders(<ProductDetailScreen route={route} />);
|
|
142
|
+
|
|
143
|
+
await waitFor(() => {
|
|
144
|
+
expect(screen.getByText(/something went wrong/i)).toBeTruthy();
|
|
145
|
+
expect(screen.getByText('Try Again')).toBeTruthy();
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('retries on error button press', async () => {
|
|
150
|
+
mockedService.getById
|
|
151
|
+
.mockRejectedValueOnce(new Error('fail'))
|
|
152
|
+
.mockResolvedValueOnce(mockProduct({ title: 'Blue Shirt' }));
|
|
153
|
+
|
|
154
|
+
renderWithProviders(<ProductDetailScreen route={route} />);
|
|
155
|
+
|
|
156
|
+
await waitFor(() => screen.getByText('Try Again'));
|
|
157
|
+
fireEvent.press(screen.getByText('Try Again'));
|
|
158
|
+
|
|
159
|
+
await waitFor(() => {
|
|
160
|
+
expect(screen.getByText('Blue Shirt')).toBeTruthy();
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
---
|
|
167
|
+
|
|
168
|
+
## Hook Tests
|
|
169
|
+
|
|
170
|
+
```typescript
|
|
171
|
+
// hooks/useProductDetail.test.ts
|
|
172
|
+
import { renderHook, waitFor } from '@testing-library/react-native';
|
|
173
|
+
import { useProductDetail } from './useProductDetail';
|
|
174
|
+
import { QueryWrapper } from '@/test/renderWithProviders';
|
|
175
|
+
import { productService } from '../services/productService';
|
|
176
|
+
import { mockProduct } from '@/test/factories';
|
|
177
|
+
|
|
178
|
+
jest.mock('../services/productService');
|
|
179
|
+
const mockedService = productService as jest.Mocked<typeof productService>;
|
|
180
|
+
|
|
181
|
+
describe('useProductDetail', () => {
|
|
182
|
+
it('fetches product by ID', async () => {
|
|
183
|
+
const product = mockProduct();
|
|
184
|
+
mockedService.getById.mockResolvedValue(product);
|
|
185
|
+
|
|
186
|
+
const { result } = renderHook(
|
|
187
|
+
() => useProductDetail('prod-1' as ProductId),
|
|
188
|
+
{ wrapper: QueryWrapper }
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
expect(result.current.isLoading).toBe(true);
|
|
192
|
+
|
|
193
|
+
await waitFor(() => {
|
|
194
|
+
expect(result.current.isLoading).toBe(false);
|
|
195
|
+
expect(result.current.product).toEqual(product);
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('returns error on failure', async () => {
|
|
200
|
+
mockedService.getById.mockRejectedValue(new Error('Not found'));
|
|
201
|
+
|
|
202
|
+
const { result } = renderHook(
|
|
203
|
+
() => useProductDetail('bad-id' as ProductId),
|
|
204
|
+
{ wrapper: QueryWrapper }
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
await waitFor(() => {
|
|
208
|
+
expect(result.current.error).toBeTruthy();
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
---
|
|
215
|
+
|
|
216
|
+
## Test Factories (Mock Data Generators)
|
|
217
|
+
|
|
218
|
+
```typescript
|
|
219
|
+
// test/factories.ts
|
|
220
|
+
import { Product, User, Review, ProductId, UserId } from '@/types/api.types';
|
|
221
|
+
|
|
222
|
+
let counter = 0;
|
|
223
|
+
|
|
224
|
+
export function mockProduct(overrides?: Partial<Product>): Product {
|
|
225
|
+
counter++;
|
|
226
|
+
return {
|
|
227
|
+
id: `prod-${counter}` as ProductId,
|
|
228
|
+
title: 'Test Product',
|
|
229
|
+
description: 'A great product for testing',
|
|
230
|
+
price: 29.99,
|
|
231
|
+
images: ['https://example.com/img1.jpg'],
|
|
232
|
+
category: 'electronics',
|
|
233
|
+
inStock: true,
|
|
234
|
+
...overrides,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export function mockUser(overrides?: Partial<User>): User {
|
|
239
|
+
counter++;
|
|
240
|
+
return {
|
|
241
|
+
id: `user-${counter}` as UserId,
|
|
242
|
+
email: `user${counter}@example.com`,
|
|
243
|
+
name: 'Test User',
|
|
244
|
+
avatarUrl: null,
|
|
245
|
+
role: 'user',
|
|
246
|
+
createdAt: new Date().toISOString(),
|
|
247
|
+
...overrides,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export function mockReview(overrides?: Partial<Review>): Review {
|
|
252
|
+
counter++;
|
|
253
|
+
return {
|
|
254
|
+
id: `review-${counter}`,
|
|
255
|
+
userId: `user-${counter}` as UserId,
|
|
256
|
+
rating: 4,
|
|
257
|
+
comment: 'Great product!',
|
|
258
|
+
createdAt: new Date().toISOString(),
|
|
259
|
+
...overrides,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Factory for lists
|
|
264
|
+
export function mockProductList(count = 5, overrides?: Partial<Product>): Product[] {
|
|
265
|
+
return Array.from({ length: count }, () => mockProduct(overrides));
|
|
266
|
+
}
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
---
|
|
270
|
+
|
|
271
|
+
## Flutter Widget Tests
|
|
272
|
+
|
|
273
|
+
```dart
|
|
274
|
+
// widgets/product_card_test.dart
|
|
275
|
+
import 'package:flutter_test/flutter_test.dart';
|
|
276
|
+
import 'package:your_app/widgets/product_card.dart';
|
|
277
|
+
import '../test_helpers.dart';
|
|
278
|
+
|
|
279
|
+
void main() {
|
|
280
|
+
group('ProductCard', () {
|
|
281
|
+
testWidgets('renders title and price', (tester) async {
|
|
282
|
+
await tester.pumpWidget(wrapWithMaterial(
|
|
283
|
+
ProductCard(
|
|
284
|
+
product: mockProduct(title: 'Blue Shirt', price: 29.99),
|
|
285
|
+
onTap: () {},
|
|
286
|
+
),
|
|
287
|
+
));
|
|
288
|
+
|
|
289
|
+
expect(find.text('Blue Shirt'), findsOneWidget);
|
|
290
|
+
expect(find.text('\$29.99'), findsOneWidget);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
testWidgets('calls onTap when pressed', (tester) async {
|
|
294
|
+
var tapped = false;
|
|
295
|
+
await tester.pumpWidget(wrapWithMaterial(
|
|
296
|
+
ProductCard(
|
|
297
|
+
product: mockProduct(),
|
|
298
|
+
onTap: () => tapped = true,
|
|
299
|
+
),
|
|
300
|
+
));
|
|
301
|
+
|
|
302
|
+
await tester.tap(find.byType(ProductCard));
|
|
303
|
+
expect(tapped, isTrue);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
testWidgets('shows out of stock badge', (tester) async {
|
|
307
|
+
await tester.pumpWidget(wrapWithMaterial(
|
|
308
|
+
ProductCard(
|
|
309
|
+
product: mockProduct(inStock: false),
|
|
310
|
+
onTap: () {},
|
|
311
|
+
),
|
|
312
|
+
));
|
|
313
|
+
|
|
314
|
+
expect(find.text('Out of Stock'), findsOneWidget);
|
|
315
|
+
});
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// test_helpers.dart
|
|
320
|
+
Widget wrapWithMaterial(Widget child) {
|
|
321
|
+
return MaterialApp(home: Scaffold(body: child));
|
|
322
|
+
}
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
---
|
|
326
|
+
|
|
327
|
+
## Service Tests (API Mocking)
|
|
328
|
+
|
|
329
|
+
```typescript
|
|
330
|
+
// services/productService.test.ts
|
|
331
|
+
import api from '@/services/api';
|
|
332
|
+
import { productService } from './productService';
|
|
333
|
+
import { mockProduct } from '@/test/factories';
|
|
334
|
+
|
|
335
|
+
jest.mock('@/services/api');
|
|
336
|
+
const mockedApi = api as jest.Mocked<typeof api>;
|
|
337
|
+
|
|
338
|
+
describe('productService', () => {
|
|
339
|
+
it('getById calls correct endpoint', async () => {
|
|
340
|
+
const product = mockProduct();
|
|
341
|
+
mockedApi.get.mockResolvedValue({ data: product });
|
|
342
|
+
|
|
343
|
+
const result = await productService.getById('prod-1' as ProductId);
|
|
344
|
+
|
|
345
|
+
expect(mockedApi.get).toHaveBeenCalledWith('/products/prod-1');
|
|
346
|
+
expect(result).toEqual(product);
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it('getProducts passes pagination params', async () => {
|
|
350
|
+
mockedApi.get.mockResolvedValue({ data: { items: [], nextCursor: null } });
|
|
351
|
+
|
|
352
|
+
await productService.getProducts({ cursor: 'abc', limit: 20, category: 'shoes' });
|
|
353
|
+
|
|
354
|
+
expect(mockedApi.get).toHaveBeenCalledWith('/products', {
|
|
355
|
+
params: { cursor: 'abc', limit: 20, category: 'shoes' },
|
|
356
|
+
});
|
|
357
|
+
});
|
|
358
|
+
});
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
---
|
|
362
|
+
|
|
363
|
+
## Snapshot Testing Strategy
|
|
364
|
+
|
|
365
|
+
```
|
|
366
|
+
WHEN to use snapshots:
|
|
367
|
+
✅ Static components (Header, Footer, Badge, Tag)
|
|
368
|
+
✅ Design system components (Button, Card, Input variants)
|
|
369
|
+
✅ Error/empty states (they rarely change)
|
|
370
|
+
|
|
371
|
+
WHEN NOT to use snapshots:
|
|
372
|
+
⛔ Dynamic content (lists with variable data)
|
|
373
|
+
⛔ Screens with complex state (too many snapshot variants)
|
|
374
|
+
⛔ Components that change frequently (breaks snapshot every PR)
|
|
375
|
+
|
|
376
|
+
HANDLING DYNAMIC DATA:
|
|
377
|
+
- Use consistent mock data (factories with fixed counter)
|
|
378
|
+
- Mock Date.now() for timestamps
|
|
379
|
+
- Mock Math.random() for IDs
|
|
380
|
+
|
|
381
|
+
REVIEWING SNAPSHOT CHANGES:
|
|
382
|
+
- Every snapshot update in PR → reviewer MUST check diff
|
|
383
|
+
- If snapshot changes are "too noisy" → switch to specific assertions
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
```typescript
|
|
387
|
+
// components/Badge.test.tsx
|
|
388
|
+
import { render } from '@testing-library/react-native';
|
|
389
|
+
import { Badge } from './Badge';
|
|
390
|
+
|
|
391
|
+
describe('Badge', () => {
|
|
392
|
+
it('renders success variant correctly', () => {
|
|
393
|
+
const tree = render(<Badge variant="success" text="Active" />);
|
|
394
|
+
expect(tree.toJSON()).toMatchSnapshot();
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
it('renders error variant correctly', () => {
|
|
398
|
+
const tree = render(<Badge variant="error" text="Failed" />);
|
|
399
|
+
expect(tree.toJSON()).toMatchSnapshot();
|
|
400
|
+
});
|
|
401
|
+
});
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
---
|
|
405
|
+
|
|
406
|
+
## Testing Checklist
|
|
407
|
+
|
|
408
|
+
```
|
|
409
|
+
FOR EVERY COMPONENT:
|
|
410
|
+
□ Renders correctly (default state)
|
|
411
|
+
□ Loading state
|
|
412
|
+
□ Error state
|
|
413
|
+
□ Empty state
|
|
414
|
+
□ User interactions (press, type, scroll)
|
|
415
|
+
□ Accessibility labels present
|
|
416
|
+
|
|
417
|
+
FOR EVERY HOOK:
|
|
418
|
+
□ Returns expected data
|
|
419
|
+
□ Handles loading
|
|
420
|
+
□ Handles errors
|
|
421
|
+
□ Cleanup on unmount
|
|
422
|
+
|
|
423
|
+
FOR EVERY SERVICE:
|
|
424
|
+
□ Calls correct endpoint
|
|
425
|
+
□ Passes correct params
|
|
426
|
+
□ Handles success response
|
|
427
|
+
□ Handles error response
|
|
428
|
+
```
|