@buivietphi/skill-mobile-mt 2.1.0 → 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.
@@ -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
+ ```