elsabro 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/commands/elsabro/add-phase.md +17 -0
- package/commands/elsabro/add-todo.md +111 -53
- package/commands/elsabro/audit-milestone.md +19 -0
- package/commands/elsabro/check-todos.md +210 -31
- package/commands/elsabro/complete-milestone.md +20 -1
- package/commands/elsabro/debug.md +19 -0
- package/commands/elsabro/discuss-phase.md +18 -1
- package/commands/elsabro/execute.md +496 -52
- package/commands/elsabro/insert-phase.md +18 -1
- package/commands/elsabro/list-phase-assumptions.md +17 -0
- package/commands/elsabro/new-milestone.md +19 -0
- package/commands/elsabro/new.md +19 -0
- package/commands/elsabro/pause-work.md +75 -0
- package/commands/elsabro/plan-milestone-gaps.md +20 -1
- package/commands/elsabro/plan.md +264 -36
- package/commands/elsabro/progress.md +203 -79
- package/commands/elsabro/quick.md +19 -0
- package/commands/elsabro/remove-phase.md +17 -0
- package/commands/elsabro/research-phase.md +18 -1
- package/commands/elsabro/resume-work.md +130 -2
- package/commands/elsabro/start.md +365 -98
- package/commands/elsabro/verify-work.md +271 -12
- package/package.json +1 -1
- package/references/SYSTEM_INDEX.md +241 -0
- package/references/command-flow.md +352 -0
- package/references/enforcement-rules.md +331 -0
- package/references/error-handling-instructions.md +26 -12
- package/references/state-sync.md +381 -0
- package/references/task-dispatcher.md +388 -0
- package/references/tasks-integration.md +380 -0
- package/skills/api-microservice.md +765 -0
- package/skills/api-setup.md +76 -3
- package/skills/auth-setup.md +46 -6
- package/skills/chrome-extension.md +584 -0
- package/skills/cicd-setup.md +1206 -0
- package/skills/cli-tool.md +884 -0
- package/skills/database-setup.md +41 -5
- package/skills/desktop-app.md +1351 -0
- package/skills/expo-app.md +35 -2
- package/skills/full-stack-app.md +543 -0
- package/skills/mobile-app.md +813 -0
- package/skills/nextjs-app.md +33 -2
- package/skills/payments-setup.md +76 -1
- package/skills/saas-starter.md +639 -0
- package/skills/sentry-setup.md +41 -7
- package/skills/testing-setup.md +1218 -0
|
@@ -0,0 +1,1218 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: testing-setup
|
|
3
|
+
description: Configurar testing completo con unit tests (Vitest), E2E (Playwright), mocking (MSW) y coverage
|
|
4
|
+
tags: [testing, vitest, playwright, e2e, unit-tests, tdd, quality, coverage, msw]
|
|
5
|
+
difficulty: intermediate
|
|
6
|
+
estimated_time: 30min
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# Skill: Testing Setup
|
|
10
|
+
|
|
11
|
+
<when_to_use>
|
|
12
|
+
Usar cuando el usuario menciona:
|
|
13
|
+
- "agregar tests"
|
|
14
|
+
- "testing"
|
|
15
|
+
- "TDD"
|
|
16
|
+
- "coverage"
|
|
17
|
+
- "E2E"
|
|
18
|
+
- "pruebas automatizadas"
|
|
19
|
+
- "unit tests"
|
|
20
|
+
- "integration tests"
|
|
21
|
+
- "mocking"
|
|
22
|
+
- "test driven development"
|
|
23
|
+
</when_to_use>
|
|
24
|
+
|
|
25
|
+
<pre_requisites>
|
|
26
|
+
## Pre-requisitos
|
|
27
|
+
- Node.js 20+
|
|
28
|
+
- Proyecto existente con package.json
|
|
29
|
+
- TypeScript configurado (recomendado)
|
|
30
|
+
- Conocimiento basico de Jest/testing
|
|
31
|
+
</pre_requisites>
|
|
32
|
+
|
|
33
|
+
<tech_stack>
|
|
34
|
+
## Stack Tecnologico
|
|
35
|
+
| Categoria | Tecnologia | Version | Proposito |
|
|
36
|
+
|-----------|------------|---------|-----------|
|
|
37
|
+
| Unit Tests | Vitest | 2.x | Testing rapido con soporte nativo ESM |
|
|
38
|
+
| E2E Tests | Playwright | 1.49.x | Testing cross-browser automatizado |
|
|
39
|
+
| Mocking | MSW | 2.x | Mock de APIs a nivel de red |
|
|
40
|
+
| Coverage | c8 | 10.x | Reporte de cobertura de codigo |
|
|
41
|
+
| React Testing | @testing-library/react | 16.x | Testing de componentes React |
|
|
42
|
+
| DOM Testing | @testing-library/dom | 10.x | Utilidades de testing DOM |
|
|
43
|
+
| User Events | @testing-library/user-event | 14.x | Simulacion de eventos de usuario |
|
|
44
|
+
| Jest DOM | @testing-library/jest-dom | 6.x | Matchers adicionales para DOM |
|
|
45
|
+
</tech_stack>
|
|
46
|
+
|
|
47
|
+
<project_structure>
|
|
48
|
+
## Estructura de Proyecto
|
|
49
|
+
```
|
|
50
|
+
project/
|
|
51
|
+
├── src/
|
|
52
|
+
│ ├── components/
|
|
53
|
+
│ │ └── Button.tsx
|
|
54
|
+
│ ├── lib/
|
|
55
|
+
│ │ └── utils.ts
|
|
56
|
+
│ └── services/
|
|
57
|
+
│ └── api.ts
|
|
58
|
+
├── tests/
|
|
59
|
+
│ ├── unit/
|
|
60
|
+
│ │ ├── utils.test.ts
|
|
61
|
+
│ │ └── components/
|
|
62
|
+
│ │ └── Button.test.tsx
|
|
63
|
+
│ ├── integration/
|
|
64
|
+
│ │ └── api.test.ts
|
|
65
|
+
│ └── e2e/
|
|
66
|
+
│ ├── auth.spec.ts
|
|
67
|
+
│ └── checkout.spec.ts
|
|
68
|
+
├── mocks/
|
|
69
|
+
│ ├── handlers.ts
|
|
70
|
+
│ ├── server.ts
|
|
71
|
+
│ └── browser.ts
|
|
72
|
+
├── vitest.config.ts
|
|
73
|
+
├── vitest.setup.ts
|
|
74
|
+
├── playwright.config.ts
|
|
75
|
+
└── package.json
|
|
76
|
+
```
|
|
77
|
+
</project_structure>
|
|
78
|
+
|
|
79
|
+
<setup_steps>
|
|
80
|
+
## Paso 1: Instalar Dependencias
|
|
81
|
+
|
|
82
|
+
### Dependencias de Vitest (Unit/Integration Tests)
|
|
83
|
+
```bash
|
|
84
|
+
npm install -D vitest @vitest/coverage-v8 @vitest/ui happy-dom
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### Dependencias de Testing Library (React)
|
|
88
|
+
```bash
|
|
89
|
+
npm install -D @testing-library/react @testing-library/dom @testing-library/user-event @testing-library/jest-dom
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### Dependencias de Playwright (E2E)
|
|
93
|
+
```bash
|
|
94
|
+
npm install -D @playwright/test
|
|
95
|
+
npx playwright install
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### Dependencias de MSW (Mocking)
|
|
99
|
+
```bash
|
|
100
|
+
npm install -D msw
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Paso 2: Configurar Vitest
|
|
104
|
+
|
|
105
|
+
### vitest.config.ts
|
|
106
|
+
```typescript
|
|
107
|
+
import { defineConfig } from 'vitest/config';
|
|
108
|
+
import react from '@vitejs/plugin-react';
|
|
109
|
+
import path from 'path';
|
|
110
|
+
|
|
111
|
+
export default defineConfig({
|
|
112
|
+
plugins: [react()],
|
|
113
|
+
test: {
|
|
114
|
+
// Ambiente de testing
|
|
115
|
+
environment: 'happy-dom',
|
|
116
|
+
|
|
117
|
+
// Archivos de setup
|
|
118
|
+
setupFiles: ['./vitest.setup.ts'],
|
|
119
|
+
|
|
120
|
+
// Incluir archivos de test
|
|
121
|
+
include: [
|
|
122
|
+
'tests/unit/**/*.{test,spec}.{ts,tsx}',
|
|
123
|
+
'tests/integration/**/*.{test,spec}.{ts,tsx}',
|
|
124
|
+
'src/**/*.{test,spec}.{ts,tsx}',
|
|
125
|
+
],
|
|
126
|
+
|
|
127
|
+
// Excluir
|
|
128
|
+
exclude: [
|
|
129
|
+
'node_modules',
|
|
130
|
+
'tests/e2e/**/*',
|
|
131
|
+
'.next',
|
|
132
|
+
'dist',
|
|
133
|
+
],
|
|
134
|
+
|
|
135
|
+
// Coverage
|
|
136
|
+
coverage: {
|
|
137
|
+
provider: 'v8',
|
|
138
|
+
reporter: ['text', 'json', 'html', 'lcov'],
|
|
139
|
+
reportsDirectory: './coverage',
|
|
140
|
+
exclude: [
|
|
141
|
+
'node_modules/',
|
|
142
|
+
'tests/',
|
|
143
|
+
'mocks/',
|
|
144
|
+
'**/*.d.ts',
|
|
145
|
+
'**/*.config.*',
|
|
146
|
+
'**/types/**',
|
|
147
|
+
],
|
|
148
|
+
thresholds: {
|
|
149
|
+
lines: 80,
|
|
150
|
+
functions: 80,
|
|
151
|
+
branches: 80,
|
|
152
|
+
statements: 80,
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
|
|
156
|
+
// Globals para jest-dom matchers
|
|
157
|
+
globals: true,
|
|
158
|
+
|
|
159
|
+
// Reporter
|
|
160
|
+
reporter: ['verbose', 'html'],
|
|
161
|
+
|
|
162
|
+
// Timeouts
|
|
163
|
+
testTimeout: 10000,
|
|
164
|
+
hookTimeout: 10000,
|
|
165
|
+
|
|
166
|
+
// Watch mode
|
|
167
|
+
watch: false,
|
|
168
|
+
|
|
169
|
+
// Pool options
|
|
170
|
+
pool: 'forks',
|
|
171
|
+
poolOptions: {
|
|
172
|
+
forks: {
|
|
173
|
+
singleFork: true,
|
|
174
|
+
},
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
resolve: {
|
|
178
|
+
alias: {
|
|
179
|
+
'@': path.resolve(__dirname, './src'),
|
|
180
|
+
'@tests': path.resolve(__dirname, './tests'),
|
|
181
|
+
'@mocks': path.resolve(__dirname, './mocks'),
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
});
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
### vitest.setup.ts
|
|
188
|
+
```typescript
|
|
189
|
+
import { expect, afterEach, beforeAll, afterAll, vi } from 'vitest';
|
|
190
|
+
import { cleanup } from '@testing-library/react';
|
|
191
|
+
import * as matchers from '@testing-library/jest-dom/matchers';
|
|
192
|
+
import { server } from './mocks/server';
|
|
193
|
+
|
|
194
|
+
// Extender matchers de Vitest con jest-dom
|
|
195
|
+
expect.extend(matchers);
|
|
196
|
+
|
|
197
|
+
// Limpiar despues de cada test
|
|
198
|
+
afterEach(() => {
|
|
199
|
+
cleanup();
|
|
200
|
+
vi.clearAllMocks();
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
// Configurar MSW
|
|
204
|
+
beforeAll(() => {
|
|
205
|
+
server.listen({ onUnhandledRequest: 'error' });
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
afterEach(() => {
|
|
209
|
+
server.resetHandlers();
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
afterAll(() => {
|
|
213
|
+
server.close();
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
// Mock de variables de entorno
|
|
217
|
+
vi.stubEnv('NODE_ENV', 'test');
|
|
218
|
+
|
|
219
|
+
// Mock de window.matchMedia
|
|
220
|
+
Object.defineProperty(window, 'matchMedia', {
|
|
221
|
+
writable: true,
|
|
222
|
+
value: vi.fn().mockImplementation((query: string) => ({
|
|
223
|
+
matches: false,
|
|
224
|
+
media: query,
|
|
225
|
+
onchange: null,
|
|
226
|
+
addListener: vi.fn(),
|
|
227
|
+
removeListener: vi.fn(),
|
|
228
|
+
addEventListener: vi.fn(),
|
|
229
|
+
removeEventListener: vi.fn(),
|
|
230
|
+
dispatchEvent: vi.fn(),
|
|
231
|
+
})),
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
// Mock de ResizeObserver
|
|
235
|
+
global.ResizeObserver = vi.fn().mockImplementation(() => ({
|
|
236
|
+
observe: vi.fn(),
|
|
237
|
+
unobserve: vi.fn(),
|
|
238
|
+
disconnect: vi.fn(),
|
|
239
|
+
}));
|
|
240
|
+
|
|
241
|
+
// Mock de IntersectionObserver
|
|
242
|
+
global.IntersectionObserver = vi.fn().mockImplementation(() => ({
|
|
243
|
+
observe: vi.fn(),
|
|
244
|
+
unobserve: vi.fn(),
|
|
245
|
+
disconnect: vi.fn(),
|
|
246
|
+
}));
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
## Paso 3: Configurar Playwright
|
|
250
|
+
|
|
251
|
+
### playwright.config.ts
|
|
252
|
+
```typescript
|
|
253
|
+
import { defineConfig, devices } from '@playwright/test';
|
|
254
|
+
|
|
255
|
+
export default defineConfig({
|
|
256
|
+
// Directorio de tests
|
|
257
|
+
testDir: './tests/e2e',
|
|
258
|
+
|
|
259
|
+
// Timeout por test
|
|
260
|
+
timeout: 30000,
|
|
261
|
+
|
|
262
|
+
// Timeout para expect
|
|
263
|
+
expect: {
|
|
264
|
+
timeout: 5000,
|
|
265
|
+
},
|
|
266
|
+
|
|
267
|
+
// Ejecutar tests en paralelo
|
|
268
|
+
fullyParallel: true,
|
|
269
|
+
|
|
270
|
+
// Fallar el build si hay test.only
|
|
271
|
+
forbidOnly: !!process.env.CI,
|
|
272
|
+
|
|
273
|
+
// Reintentos
|
|
274
|
+
retries: process.env.CI ? 2 : 0,
|
|
275
|
+
|
|
276
|
+
// Workers
|
|
277
|
+
workers: process.env.CI ? 1 : undefined,
|
|
278
|
+
|
|
279
|
+
// Reporter
|
|
280
|
+
reporter: [
|
|
281
|
+
['html', { outputFolder: 'playwright-report' }],
|
|
282
|
+
['json', { outputFile: 'playwright-report/results.json' }],
|
|
283
|
+
process.env.CI ? ['github'] : ['list'],
|
|
284
|
+
],
|
|
285
|
+
|
|
286
|
+
// Configuracion global
|
|
287
|
+
use: {
|
|
288
|
+
// URL base
|
|
289
|
+
baseURL: process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:3000',
|
|
290
|
+
|
|
291
|
+
// Trace on failure
|
|
292
|
+
trace: 'on-first-retry',
|
|
293
|
+
|
|
294
|
+
// Screenshot on failure
|
|
295
|
+
screenshot: 'only-on-failure',
|
|
296
|
+
|
|
297
|
+
// Video on failure
|
|
298
|
+
video: 'on-first-retry',
|
|
299
|
+
|
|
300
|
+
// Locale
|
|
301
|
+
locale: 'en-US',
|
|
302
|
+
|
|
303
|
+
// Timezone
|
|
304
|
+
timezoneId: 'America/New_York',
|
|
305
|
+
},
|
|
306
|
+
|
|
307
|
+
// Proyectos (browsers)
|
|
308
|
+
projects: [
|
|
309
|
+
{
|
|
310
|
+
name: 'chromium',
|
|
311
|
+
use: { ...devices['Desktop Chrome'] },
|
|
312
|
+
},
|
|
313
|
+
{
|
|
314
|
+
name: 'firefox',
|
|
315
|
+
use: { ...devices['Desktop Firefox'] },
|
|
316
|
+
},
|
|
317
|
+
{
|
|
318
|
+
name: 'webkit',
|
|
319
|
+
use: { ...devices['Desktop Safari'] },
|
|
320
|
+
},
|
|
321
|
+
// Mobile viewports
|
|
322
|
+
{
|
|
323
|
+
name: 'Mobile Chrome',
|
|
324
|
+
use: { ...devices['Pixel 5'] },
|
|
325
|
+
},
|
|
326
|
+
{
|
|
327
|
+
name: 'Mobile Safari',
|
|
328
|
+
use: { ...devices['iPhone 12'] },
|
|
329
|
+
},
|
|
330
|
+
],
|
|
331
|
+
|
|
332
|
+
// Servidor de desarrollo
|
|
333
|
+
webServer: {
|
|
334
|
+
command: 'npm run dev',
|
|
335
|
+
url: 'http://localhost:3000',
|
|
336
|
+
reuseExistingServer: !process.env.CI,
|
|
337
|
+
timeout: 120000,
|
|
338
|
+
},
|
|
339
|
+
});
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
## Paso 4: Configurar MSW (Mock Service Worker)
|
|
343
|
+
|
|
344
|
+
### mocks/handlers.ts
|
|
345
|
+
```typescript
|
|
346
|
+
import { http, HttpResponse, delay } from 'msw';
|
|
347
|
+
|
|
348
|
+
// Tipos de datos
|
|
349
|
+
interface User {
|
|
350
|
+
id: string;
|
|
351
|
+
email: string;
|
|
352
|
+
name: string;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
interface LoginRequest {
|
|
356
|
+
email: string;
|
|
357
|
+
password: string;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
interface ApiError {
|
|
361
|
+
message: string;
|
|
362
|
+
code: string;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Base URL
|
|
366
|
+
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api';
|
|
367
|
+
|
|
368
|
+
// Datos de prueba
|
|
369
|
+
const mockUsers: User[] = [
|
|
370
|
+
{ id: '1', email: 'test@example.com', name: 'Test User' },
|
|
371
|
+
{ id: '2', email: 'admin@example.com', name: 'Admin User' },
|
|
372
|
+
];
|
|
373
|
+
|
|
374
|
+
// Handlers
|
|
375
|
+
export const handlers = [
|
|
376
|
+
// Auth handlers
|
|
377
|
+
http.post<never, LoginRequest>(`${API_BASE}/auth/login`, async ({ request }) => {
|
|
378
|
+
await delay(100); // Simular latencia
|
|
379
|
+
|
|
380
|
+
const body = await request.json();
|
|
381
|
+
|
|
382
|
+
if (body.email === 'test@example.com' && body.password === 'password123') {
|
|
383
|
+
return HttpResponse.json({
|
|
384
|
+
user: mockUsers[0],
|
|
385
|
+
token: 'mock-jwt-token',
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
return HttpResponse.json<ApiError>(
|
|
390
|
+
{ message: 'Invalid credentials', code: 'INVALID_CREDENTIALS' },
|
|
391
|
+
{ status: 401 }
|
|
392
|
+
);
|
|
393
|
+
}),
|
|
394
|
+
|
|
395
|
+
http.post(`${API_BASE}/auth/logout`, async () => {
|
|
396
|
+
await delay(50);
|
|
397
|
+
return HttpResponse.json({ success: true });
|
|
398
|
+
}),
|
|
399
|
+
|
|
400
|
+
http.get(`${API_BASE}/auth/me`, async ({ request }) => {
|
|
401
|
+
const authHeader = request.headers.get('Authorization');
|
|
402
|
+
|
|
403
|
+
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
|
404
|
+
return HttpResponse.json<ApiError>(
|
|
405
|
+
{ message: 'Unauthorized', code: 'UNAUTHORIZED' },
|
|
406
|
+
{ status: 401 }
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
return HttpResponse.json({ user: mockUsers[0] });
|
|
411
|
+
}),
|
|
412
|
+
|
|
413
|
+
// User handlers
|
|
414
|
+
http.get(`${API_BASE}/users`, async () => {
|
|
415
|
+
await delay(100);
|
|
416
|
+
return HttpResponse.json({ users: mockUsers });
|
|
417
|
+
}),
|
|
418
|
+
|
|
419
|
+
http.get(`${API_BASE}/users/:id`, async ({ params }) => {
|
|
420
|
+
await delay(50);
|
|
421
|
+
const user = mockUsers.find(u => u.id === params.id);
|
|
422
|
+
|
|
423
|
+
if (!user) {
|
|
424
|
+
return HttpResponse.json<ApiError>(
|
|
425
|
+
{ message: 'User not found', code: 'NOT_FOUND' },
|
|
426
|
+
{ status: 404 }
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
return HttpResponse.json({ user });
|
|
431
|
+
}),
|
|
432
|
+
|
|
433
|
+
http.post<never, Partial<User>>(`${API_BASE}/users`, async ({ request }) => {
|
|
434
|
+
await delay(100);
|
|
435
|
+
const body = await request.json();
|
|
436
|
+
|
|
437
|
+
const newUser: User = {
|
|
438
|
+
id: String(mockUsers.length + 1),
|
|
439
|
+
email: body.email || '',
|
|
440
|
+
name: body.name || '',
|
|
441
|
+
};
|
|
442
|
+
|
|
443
|
+
return HttpResponse.json({ user: newUser }, { status: 201 });
|
|
444
|
+
}),
|
|
445
|
+
|
|
446
|
+
// Error simulation handler
|
|
447
|
+
http.get(`${API_BASE}/error`, async () => {
|
|
448
|
+
return HttpResponse.json<ApiError>(
|
|
449
|
+
{ message: 'Internal server error', code: 'INTERNAL_ERROR' },
|
|
450
|
+
{ status: 500 }
|
|
451
|
+
);
|
|
452
|
+
}),
|
|
453
|
+
|
|
454
|
+
// Network delay simulation
|
|
455
|
+
http.get(`${API_BASE}/slow`, async () => {
|
|
456
|
+
await delay(3000);
|
|
457
|
+
return HttpResponse.json({ message: 'Slow response' });
|
|
458
|
+
}),
|
|
459
|
+
];
|
|
460
|
+
```
|
|
461
|
+
|
|
462
|
+
### mocks/server.ts
|
|
463
|
+
```typescript
|
|
464
|
+
import { setupServer } from 'msw/node';
|
|
465
|
+
import { handlers } from './handlers';
|
|
466
|
+
|
|
467
|
+
// Servidor para Node.js (tests)
|
|
468
|
+
export const server = setupServer(...handlers);
|
|
469
|
+
```
|
|
470
|
+
|
|
471
|
+
### mocks/browser.ts
|
|
472
|
+
```typescript
|
|
473
|
+
import { setupWorker } from 'msw/browser';
|
|
474
|
+
import { handlers } from './handlers';
|
|
475
|
+
|
|
476
|
+
// Worker para browser (desarrollo)
|
|
477
|
+
export const worker = setupWorker(...handlers);
|
|
478
|
+
```
|
|
479
|
+
|
|
480
|
+
## Paso 5: Ejemplos de Tests
|
|
481
|
+
|
|
482
|
+
### tests/unit/utils.test.ts
|
|
483
|
+
```typescript
|
|
484
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
485
|
+
|
|
486
|
+
// Ejemplo de funciones a testear
|
|
487
|
+
function formatCurrency(amount: number, currency = 'USD'): string {
|
|
488
|
+
return new Intl.NumberFormat('en-US', {
|
|
489
|
+
style: 'currency',
|
|
490
|
+
currency,
|
|
491
|
+
}).format(amount);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function validateEmail(email: string): boolean {
|
|
495
|
+
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
496
|
+
return emailRegex.test(email);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
async function fetchWithRetry(
|
|
500
|
+
url: string,
|
|
501
|
+
retries = 3
|
|
502
|
+
): Promise<Response> {
|
|
503
|
+
for (let i = 0; i < retries; i++) {
|
|
504
|
+
try {
|
|
505
|
+
const response = await fetch(url);
|
|
506
|
+
if (response.ok) return response;
|
|
507
|
+
} catch {
|
|
508
|
+
if (i === retries - 1) throw new Error('Max retries reached');
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
throw new Error('Max retries reached');
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// Tests
|
|
515
|
+
describe('formatCurrency', () => {
|
|
516
|
+
it('should format USD currency correctly', () => {
|
|
517
|
+
expect(formatCurrency(100)).toBe('$100.00');
|
|
518
|
+
expect(formatCurrency(1234.56)).toBe('$1,234.56');
|
|
519
|
+
expect(formatCurrency(0)).toBe('$0.00');
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
it('should format other currencies', () => {
|
|
523
|
+
expect(formatCurrency(100, 'EUR')).toContain('100');
|
|
524
|
+
expect(formatCurrency(100, 'GBP')).toContain('100');
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
it('should handle negative amounts', () => {
|
|
528
|
+
expect(formatCurrency(-50)).toBe('-$50.00');
|
|
529
|
+
});
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
describe('validateEmail', () => {
|
|
533
|
+
it('should return true for valid emails', () => {
|
|
534
|
+
expect(validateEmail('test@example.com')).toBe(true);
|
|
535
|
+
expect(validateEmail('user.name@domain.org')).toBe(true);
|
|
536
|
+
expect(validateEmail('user+tag@example.co.uk')).toBe(true);
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
it('should return false for invalid emails', () => {
|
|
540
|
+
expect(validateEmail('invalid')).toBe(false);
|
|
541
|
+
expect(validateEmail('no@domain')).toBe(false);
|
|
542
|
+
expect(validateEmail('@nodomain.com')).toBe(false);
|
|
543
|
+
expect(validateEmail('spaces in@email.com')).toBe(false);
|
|
544
|
+
});
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
describe('fetchWithRetry', () => {
|
|
548
|
+
it('should return response on success', async () => {
|
|
549
|
+
const mockResponse = new Response(JSON.stringify({ data: 'test' }), {
|
|
550
|
+
status: 200,
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
vi.spyOn(global, 'fetch').mockResolvedValueOnce(mockResponse);
|
|
554
|
+
|
|
555
|
+
const result = await fetchWithRetry('https://api.example.com/data');
|
|
556
|
+
|
|
557
|
+
expect(result).toBe(mockResponse);
|
|
558
|
+
expect(fetch).toHaveBeenCalledTimes(1);
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
it('should retry on failure', async () => {
|
|
562
|
+
const mockFetch = vi.spyOn(global, 'fetch');
|
|
563
|
+
|
|
564
|
+
mockFetch
|
|
565
|
+
.mockRejectedValueOnce(new Error('Network error'))
|
|
566
|
+
.mockRejectedValueOnce(new Error('Network error'))
|
|
567
|
+
.mockResolvedValueOnce(new Response('{}', { status: 200 }));
|
|
568
|
+
|
|
569
|
+
await fetchWithRetry('https://api.example.com/data');
|
|
570
|
+
|
|
571
|
+
expect(fetch).toHaveBeenCalledTimes(3);
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
it('should throw after max retries', async () => {
|
|
575
|
+
vi.spyOn(global, 'fetch').mockRejectedValue(new Error('Network error'));
|
|
576
|
+
|
|
577
|
+
await expect(
|
|
578
|
+
fetchWithRetry('https://api.example.com/data', 3)
|
|
579
|
+
).rejects.toThrow('Max retries reached');
|
|
580
|
+
});
|
|
581
|
+
});
|
|
582
|
+
```
|
|
583
|
+
|
|
584
|
+
### tests/unit/components/Button.test.tsx
|
|
585
|
+
```typescript
|
|
586
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
587
|
+
import { render, screen } from '@testing-library/react';
|
|
588
|
+
import userEvent from '@testing-library/user-event';
|
|
589
|
+
|
|
590
|
+
// Componente de ejemplo
|
|
591
|
+
interface ButtonProps {
|
|
592
|
+
children: React.ReactNode;
|
|
593
|
+
onClick?: () => void;
|
|
594
|
+
disabled?: boolean;
|
|
595
|
+
variant?: 'primary' | 'secondary' | 'danger';
|
|
596
|
+
loading?: boolean;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
function Button({
|
|
600
|
+
children,
|
|
601
|
+
onClick,
|
|
602
|
+
disabled = false,
|
|
603
|
+
variant = 'primary',
|
|
604
|
+
loading = false,
|
|
605
|
+
}: ButtonProps) {
|
|
606
|
+
return (
|
|
607
|
+
<button
|
|
608
|
+
onClick={onClick}
|
|
609
|
+
disabled={disabled || loading}
|
|
610
|
+
className={`btn btn-${variant}`}
|
|
611
|
+
aria-busy={loading}
|
|
612
|
+
>
|
|
613
|
+
{loading ? 'Loading...' : children}
|
|
614
|
+
</button>
|
|
615
|
+
);
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// Tests
|
|
619
|
+
describe('Button Component', () => {
|
|
620
|
+
it('should render children correctly', () => {
|
|
621
|
+
render(<Button>Click me</Button>);
|
|
622
|
+
|
|
623
|
+
expect(screen.getByRole('button')).toHaveTextContent('Click me');
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
it('should call onClick when clicked', async () => {
|
|
627
|
+
const user = userEvent.setup();
|
|
628
|
+
const handleClick = vi.fn();
|
|
629
|
+
|
|
630
|
+
render(<Button onClick={handleClick}>Click me</Button>);
|
|
631
|
+
|
|
632
|
+
await user.click(screen.getByRole('button'));
|
|
633
|
+
|
|
634
|
+
expect(handleClick).toHaveBeenCalledTimes(1);
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
it('should not call onClick when disabled', async () => {
|
|
638
|
+
const user = userEvent.setup();
|
|
639
|
+
const handleClick = vi.fn();
|
|
640
|
+
|
|
641
|
+
render(<Button onClick={handleClick} disabled>Click me</Button>);
|
|
642
|
+
|
|
643
|
+
await user.click(screen.getByRole('button'));
|
|
644
|
+
|
|
645
|
+
expect(handleClick).not.toHaveBeenCalled();
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
it('should show loading state', () => {
|
|
649
|
+
render(<Button loading>Submit</Button>);
|
|
650
|
+
|
|
651
|
+
const button = screen.getByRole('button');
|
|
652
|
+
|
|
653
|
+
expect(button).toHaveTextContent('Loading...');
|
|
654
|
+
expect(button).toBeDisabled();
|
|
655
|
+
expect(button).toHaveAttribute('aria-busy', 'true');
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
it('should apply variant class', () => {
|
|
659
|
+
render(<Button variant="danger">Delete</Button>);
|
|
660
|
+
|
|
661
|
+
expect(screen.getByRole('button')).toHaveClass('btn-danger');
|
|
662
|
+
});
|
|
663
|
+
});
|
|
664
|
+
```
|
|
665
|
+
|
|
666
|
+
### tests/integration/api.test.ts
|
|
667
|
+
```typescript
|
|
668
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
669
|
+
import { server } from '@mocks/server';
|
|
670
|
+
import { http, HttpResponse } from 'msw';
|
|
671
|
+
|
|
672
|
+
const API_BASE = 'http://localhost:3001/api';
|
|
673
|
+
|
|
674
|
+
// Servicio de API a testear
|
|
675
|
+
class ApiService {
|
|
676
|
+
private baseUrl: string;
|
|
677
|
+
private token: string | null = null;
|
|
678
|
+
|
|
679
|
+
constructor(baseUrl: string) {
|
|
680
|
+
this.baseUrl = baseUrl;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
setToken(token: string) {
|
|
684
|
+
this.token = token;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
private async request<T>(
|
|
688
|
+
endpoint: string,
|
|
689
|
+
options: RequestInit = {}
|
|
690
|
+
): Promise<T> {
|
|
691
|
+
const headers: HeadersInit = {
|
|
692
|
+
'Content-Type': 'application/json',
|
|
693
|
+
...(this.token && { Authorization: `Bearer ${this.token}` }),
|
|
694
|
+
...options.headers,
|
|
695
|
+
};
|
|
696
|
+
|
|
697
|
+
const response = await fetch(`${this.baseUrl}${endpoint}`, {
|
|
698
|
+
...options,
|
|
699
|
+
headers,
|
|
700
|
+
});
|
|
701
|
+
|
|
702
|
+
if (!response.ok) {
|
|
703
|
+
const error = await response.json();
|
|
704
|
+
throw new Error(error.message);
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
return response.json();
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
async login(email: string, password: string) {
|
|
711
|
+
return this.request<{ user: any; token: string }>('/auth/login', {
|
|
712
|
+
method: 'POST',
|
|
713
|
+
body: JSON.stringify({ email, password }),
|
|
714
|
+
});
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
async getUsers() {
|
|
718
|
+
return this.request<{ users: any[] }>('/users');
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
async getUser(id: string) {
|
|
722
|
+
return this.request<{ user: any }>(`/users/${id}`);
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
describe('ApiService Integration', () => {
|
|
727
|
+
let api: ApiService;
|
|
728
|
+
|
|
729
|
+
beforeEach(() => {
|
|
730
|
+
api = new ApiService(API_BASE);
|
|
731
|
+
});
|
|
732
|
+
|
|
733
|
+
describe('Authentication', () => {
|
|
734
|
+
it('should login successfully with valid credentials', async () => {
|
|
735
|
+
const result = await api.login('test@example.com', 'password123');
|
|
736
|
+
|
|
737
|
+
expect(result.user).toBeDefined();
|
|
738
|
+
expect(result.user.email).toBe('test@example.com');
|
|
739
|
+
expect(result.token).toBe('mock-jwt-token');
|
|
740
|
+
});
|
|
741
|
+
|
|
742
|
+
it('should fail login with invalid credentials', async () => {
|
|
743
|
+
await expect(
|
|
744
|
+
api.login('wrong@example.com', 'wrongpassword')
|
|
745
|
+
).rejects.toThrow('Invalid credentials');
|
|
746
|
+
});
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
describe('Users', () => {
|
|
750
|
+
it('should fetch all users', async () => {
|
|
751
|
+
const result = await api.getUsers();
|
|
752
|
+
|
|
753
|
+
expect(result.users).toHaveLength(2);
|
|
754
|
+
expect(result.users[0].email).toBe('test@example.com');
|
|
755
|
+
});
|
|
756
|
+
|
|
757
|
+
it('should fetch a single user by id', async () => {
|
|
758
|
+
const result = await api.getUser('1');
|
|
759
|
+
|
|
760
|
+
expect(result.user.id).toBe('1');
|
|
761
|
+
expect(result.user.email).toBe('test@example.com');
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
it('should handle user not found', async () => {
|
|
765
|
+
await expect(api.getUser('999')).rejects.toThrow('User not found');
|
|
766
|
+
});
|
|
767
|
+
});
|
|
768
|
+
|
|
769
|
+
describe('Error Handling', () => {
|
|
770
|
+
it('should handle server errors', async () => {
|
|
771
|
+
server.use(
|
|
772
|
+
http.get(`${API_BASE}/users`, () => {
|
|
773
|
+
return HttpResponse.json(
|
|
774
|
+
{ message: 'Database connection failed' },
|
|
775
|
+
{ status: 500 }
|
|
776
|
+
);
|
|
777
|
+
})
|
|
778
|
+
);
|
|
779
|
+
|
|
780
|
+
await expect(api.getUsers()).rejects.toThrow('Database connection failed');
|
|
781
|
+
});
|
|
782
|
+
|
|
783
|
+
it('should handle network errors', async () => {
|
|
784
|
+
server.use(
|
|
785
|
+
http.get(`${API_BASE}/users`, () => {
|
|
786
|
+
return HttpResponse.error();
|
|
787
|
+
})
|
|
788
|
+
);
|
|
789
|
+
|
|
790
|
+
await expect(api.getUsers()).rejects.toThrow();
|
|
791
|
+
});
|
|
792
|
+
});
|
|
793
|
+
});
|
|
794
|
+
```
|
|
795
|
+
|
|
796
|
+
### tests/e2e/auth.spec.ts
|
|
797
|
+
```typescript
|
|
798
|
+
import { test, expect } from '@playwright/test';
|
|
799
|
+
|
|
800
|
+
test.describe('Authentication', () => {
|
|
801
|
+
test.beforeEach(async ({ page }) => {
|
|
802
|
+
await page.goto('/');
|
|
803
|
+
});
|
|
804
|
+
|
|
805
|
+
test('should display login form', async ({ page }) => {
|
|
806
|
+
await page.goto('/login');
|
|
807
|
+
|
|
808
|
+
await expect(page.getByRole('heading', { name: /login/i })).toBeVisible();
|
|
809
|
+
await expect(page.getByLabel(/email/i)).toBeVisible();
|
|
810
|
+
await expect(page.getByLabel(/password/i)).toBeVisible();
|
|
811
|
+
await expect(page.getByRole('button', { name: /sign in/i })).toBeVisible();
|
|
812
|
+
});
|
|
813
|
+
|
|
814
|
+
test('should show validation errors for empty form', async ({ page }) => {
|
|
815
|
+
await page.goto('/login');
|
|
816
|
+
|
|
817
|
+
await page.getByRole('button', { name: /sign in/i }).click();
|
|
818
|
+
|
|
819
|
+
await expect(page.getByText(/email is required/i)).toBeVisible();
|
|
820
|
+
await expect(page.getByText(/password is required/i)).toBeVisible();
|
|
821
|
+
});
|
|
822
|
+
|
|
823
|
+
test('should show error for invalid credentials', async ({ page }) => {
|
|
824
|
+
await page.goto('/login');
|
|
825
|
+
|
|
826
|
+
await page.getByLabel(/email/i).fill('wrong@example.com');
|
|
827
|
+
await page.getByLabel(/password/i).fill('wrongpassword');
|
|
828
|
+
await page.getByRole('button', { name: /sign in/i }).click();
|
|
829
|
+
|
|
830
|
+
await expect(page.getByText(/invalid credentials/i)).toBeVisible();
|
|
831
|
+
});
|
|
832
|
+
|
|
833
|
+
test('should login successfully and redirect', async ({ page }) => {
|
|
834
|
+
await page.goto('/login');
|
|
835
|
+
|
|
836
|
+
await page.getByLabel(/email/i).fill('test@example.com');
|
|
837
|
+
await page.getByLabel(/password/i).fill('password123');
|
|
838
|
+
await page.getByRole('button', { name: /sign in/i }).click();
|
|
839
|
+
|
|
840
|
+
// Esperar redireccion al dashboard
|
|
841
|
+
await expect(page).toHaveURL('/dashboard');
|
|
842
|
+
await expect(page.getByText(/welcome/i)).toBeVisible();
|
|
843
|
+
});
|
|
844
|
+
|
|
845
|
+
test('should logout successfully', async ({ page }) => {
|
|
846
|
+
// Login primero
|
|
847
|
+
await page.goto('/login');
|
|
848
|
+
await page.getByLabel(/email/i).fill('test@example.com');
|
|
849
|
+
await page.getByLabel(/password/i).fill('password123');
|
|
850
|
+
await page.getByRole('button', { name: /sign in/i }).click();
|
|
851
|
+
|
|
852
|
+
await expect(page).toHaveURL('/dashboard');
|
|
853
|
+
|
|
854
|
+
// Logout
|
|
855
|
+
await page.getByRole('button', { name: /logout/i }).click();
|
|
856
|
+
|
|
857
|
+
await expect(page).toHaveURL('/login');
|
|
858
|
+
});
|
|
859
|
+
|
|
860
|
+
test('should persist session after page refresh', async ({ page }) => {
|
|
861
|
+
// Login
|
|
862
|
+
await page.goto('/login');
|
|
863
|
+
await page.getByLabel(/email/i).fill('test@example.com');
|
|
864
|
+
await page.getByLabel(/password/i).fill('password123');
|
|
865
|
+
await page.getByRole('button', { name: /sign in/i }).click();
|
|
866
|
+
|
|
867
|
+
await expect(page).toHaveURL('/dashboard');
|
|
868
|
+
|
|
869
|
+
// Refresh page
|
|
870
|
+
await page.reload();
|
|
871
|
+
|
|
872
|
+
// Should still be on dashboard
|
|
873
|
+
await expect(page).toHaveURL('/dashboard');
|
|
874
|
+
});
|
|
875
|
+
});
|
|
876
|
+
|
|
877
|
+
test.describe('Protected Routes', () => {
|
|
878
|
+
test('should redirect to login for protected routes', async ({ page }) => {
|
|
879
|
+
await page.goto('/dashboard');
|
|
880
|
+
|
|
881
|
+
await expect(page).toHaveURL('/login');
|
|
882
|
+
});
|
|
883
|
+
|
|
884
|
+
test('should redirect back after login', async ({ page }) => {
|
|
885
|
+
// Try to access protected route
|
|
886
|
+
await page.goto('/settings');
|
|
887
|
+
|
|
888
|
+
// Should redirect to login with return URL
|
|
889
|
+
await expect(page).toHaveURL(/\/login\?.*returnUrl/);
|
|
890
|
+
|
|
891
|
+
// Login
|
|
892
|
+
await page.getByLabel(/email/i).fill('test@example.com');
|
|
893
|
+
await page.getByLabel(/password/i).fill('password123');
|
|
894
|
+
await page.getByRole('button', { name: /sign in/i }).click();
|
|
895
|
+
|
|
896
|
+
// Should redirect to original destination
|
|
897
|
+
await expect(page).toHaveURL('/settings');
|
|
898
|
+
});
|
|
899
|
+
});
|
|
900
|
+
```
|
|
901
|
+
|
|
902
|
+
### tests/e2e/checkout.spec.ts
|
|
903
|
+
```typescript
|
|
904
|
+
import { test, expect } from '@playwright/test';
|
|
905
|
+
|
|
906
|
+
test.describe('Checkout Flow', () => {
|
|
907
|
+
test.beforeEach(async ({ page }) => {
|
|
908
|
+
// Login before each test
|
|
909
|
+
await page.goto('/login');
|
|
910
|
+
await page.getByLabel(/email/i).fill('test@example.com');
|
|
911
|
+
await page.getByLabel(/password/i).fill('password123');
|
|
912
|
+
await page.getByRole('button', { name: /sign in/i }).click();
|
|
913
|
+
await expect(page).toHaveURL('/dashboard');
|
|
914
|
+
});
|
|
915
|
+
|
|
916
|
+
test('should complete checkout flow', async ({ page }) => {
|
|
917
|
+
// Add item to cart
|
|
918
|
+
await page.goto('/products');
|
|
919
|
+
await page.getByTestId('product-1').getByRole('button', { name: /add to cart/i }).click();
|
|
920
|
+
|
|
921
|
+
// Verify cart updated
|
|
922
|
+
await expect(page.getByTestId('cart-count')).toHaveText('1');
|
|
923
|
+
|
|
924
|
+
// Go to cart
|
|
925
|
+
await page.getByTestId('cart-icon').click();
|
|
926
|
+
await expect(page).toHaveURL('/cart');
|
|
927
|
+
|
|
928
|
+
// Proceed to checkout
|
|
929
|
+
await page.getByRole('button', { name: /checkout/i }).click();
|
|
930
|
+
await expect(page).toHaveURL('/checkout');
|
|
931
|
+
|
|
932
|
+
// Fill shipping info
|
|
933
|
+
await page.getByLabel(/address/i).fill('123 Main St');
|
|
934
|
+
await page.getByLabel(/city/i).fill('New York');
|
|
935
|
+
await page.getByLabel(/zip/i).fill('10001');
|
|
936
|
+
|
|
937
|
+
// Fill payment info
|
|
938
|
+
await page.getByLabel(/card number/i).fill('4242424242424242');
|
|
939
|
+
await page.getByLabel(/expiry/i).fill('12/25');
|
|
940
|
+
await page.getByLabel(/cvc/i).fill('123');
|
|
941
|
+
|
|
942
|
+
// Place order
|
|
943
|
+
await page.getByRole('button', { name: /place order/i }).click();
|
|
944
|
+
|
|
945
|
+
// Verify success
|
|
946
|
+
await expect(page).toHaveURL(/\/orders\/\d+/);
|
|
947
|
+
await expect(page.getByText(/order confirmed/i)).toBeVisible();
|
|
948
|
+
});
|
|
949
|
+
|
|
950
|
+
test('should validate payment information', async ({ page }) => {
|
|
951
|
+
await page.goto('/checkout');
|
|
952
|
+
|
|
953
|
+
// Try to submit with invalid card
|
|
954
|
+
await page.getByLabel(/card number/i).fill('1234');
|
|
955
|
+
await page.getByRole('button', { name: /place order/i }).click();
|
|
956
|
+
|
|
957
|
+
await expect(page.getByText(/invalid card number/i)).toBeVisible();
|
|
958
|
+
});
|
|
959
|
+
|
|
960
|
+
test('should handle empty cart', async ({ page }) => {
|
|
961
|
+
await page.goto('/checkout');
|
|
962
|
+
|
|
963
|
+
await expect(page.getByText(/your cart is empty/i)).toBeVisible();
|
|
964
|
+
await expect(page.getByRole('link', { name: /continue shopping/i })).toBeVisible();
|
|
965
|
+
});
|
|
966
|
+
});
|
|
967
|
+
|
|
968
|
+
test.describe('Visual Regression', () => {
|
|
969
|
+
test('checkout page matches snapshot', async ({ page }) => {
|
|
970
|
+
await page.goto('/login');
|
|
971
|
+
await page.getByLabel(/email/i).fill('test@example.com');
|
|
972
|
+
await page.getByLabel(/password/i).fill('password123');
|
|
973
|
+
await page.getByRole('button', { name: /sign in/i }).click();
|
|
974
|
+
|
|
975
|
+
await page.goto('/checkout');
|
|
976
|
+
|
|
977
|
+
// Wait for content to load
|
|
978
|
+
await page.waitForLoadState('networkidle');
|
|
979
|
+
|
|
980
|
+
await expect(page).toHaveScreenshot('checkout-page.png', {
|
|
981
|
+
fullPage: true,
|
|
982
|
+
mask: [page.getByTestId('dynamic-content')], // Mask dynamic content
|
|
983
|
+
});
|
|
984
|
+
});
|
|
985
|
+
});
|
|
986
|
+
```
|
|
987
|
+
|
|
988
|
+
## Paso 6: Scripts en package.json
|
|
989
|
+
|
|
990
|
+
```json
|
|
991
|
+
{
|
|
992
|
+
"scripts": {
|
|
993
|
+
"test": "vitest run",
|
|
994
|
+
"test:watch": "vitest",
|
|
995
|
+
"test:ui": "vitest --ui",
|
|
996
|
+
"test:coverage": "vitest run --coverage",
|
|
997
|
+
"test:unit": "vitest run tests/unit",
|
|
998
|
+
"test:integration": "vitest run tests/integration",
|
|
999
|
+
"test:e2e": "playwright test",
|
|
1000
|
+
"test:e2e:ui": "playwright test --ui",
|
|
1001
|
+
"test:e2e:headed": "playwright test --headed",
|
|
1002
|
+
"test:e2e:debug": "playwright test --debug",
|
|
1003
|
+
"test:e2e:codegen": "playwright codegen localhost:3000",
|
|
1004
|
+
"test:e2e:report": "playwright show-report",
|
|
1005
|
+
"test:all": "npm run test && npm run test:e2e",
|
|
1006
|
+
"test:ci": "vitest run --coverage && playwright test"
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
```
|
|
1010
|
+
|
|
1011
|
+
## Paso 7: Configuracion de CI (GitHub Actions)
|
|
1012
|
+
|
|
1013
|
+
### .github/workflows/test.yml
|
|
1014
|
+
```yaml
|
|
1015
|
+
name: Tests
|
|
1016
|
+
|
|
1017
|
+
on:
|
|
1018
|
+
push:
|
|
1019
|
+
branches: [main, develop]
|
|
1020
|
+
pull_request:
|
|
1021
|
+
branches: [main, develop]
|
|
1022
|
+
|
|
1023
|
+
jobs:
|
|
1024
|
+
unit-tests:
|
|
1025
|
+
runs-on: ubuntu-latest
|
|
1026
|
+
steps:
|
|
1027
|
+
- uses: actions/checkout@v4
|
|
1028
|
+
|
|
1029
|
+
- name: Setup Node.js
|
|
1030
|
+
uses: actions/setup-node@v4
|
|
1031
|
+
with:
|
|
1032
|
+
node-version: '20'
|
|
1033
|
+
cache: 'npm'
|
|
1034
|
+
|
|
1035
|
+
- name: Install dependencies
|
|
1036
|
+
run: npm ci
|
|
1037
|
+
|
|
1038
|
+
- name: Run unit tests
|
|
1039
|
+
run: npm run test:coverage
|
|
1040
|
+
|
|
1041
|
+
- name: Upload coverage
|
|
1042
|
+
uses: codecov/codecov-action@v4
|
|
1043
|
+
with:
|
|
1044
|
+
files: ./coverage/lcov.info
|
|
1045
|
+
fail_ci_if_error: true
|
|
1046
|
+
|
|
1047
|
+
e2e-tests:
|
|
1048
|
+
runs-on: ubuntu-latest
|
|
1049
|
+
steps:
|
|
1050
|
+
- uses: actions/checkout@v4
|
|
1051
|
+
|
|
1052
|
+
- name: Setup Node.js
|
|
1053
|
+
uses: actions/setup-node@v4
|
|
1054
|
+
with:
|
|
1055
|
+
node-version: '20'
|
|
1056
|
+
cache: 'npm'
|
|
1057
|
+
|
|
1058
|
+
- name: Install dependencies
|
|
1059
|
+
run: npm ci
|
|
1060
|
+
|
|
1061
|
+
- name: Install Playwright browsers
|
|
1062
|
+
run: npx playwright install --with-deps
|
|
1063
|
+
|
|
1064
|
+
- name: Run E2E tests
|
|
1065
|
+
run: npm run test:e2e
|
|
1066
|
+
|
|
1067
|
+
- name: Upload report
|
|
1068
|
+
uses: actions/upload-artifact@v4
|
|
1069
|
+
if: always()
|
|
1070
|
+
with:
|
|
1071
|
+
name: playwright-report
|
|
1072
|
+
path: playwright-report/
|
|
1073
|
+
retention-days: 30
|
|
1074
|
+
```
|
|
1075
|
+
</setup_steps>
|
|
1076
|
+
|
|
1077
|
+
<best_practices>
|
|
1078
|
+
## Mejores Practicas
|
|
1079
|
+
|
|
1080
|
+
### 1. Estructura de Tests
|
|
1081
|
+
```typescript
|
|
1082
|
+
describe('Feature/Component', () => {
|
|
1083
|
+
describe('Scenario/Method', () => {
|
|
1084
|
+
it('should [expected behavior] when [condition]', () => {
|
|
1085
|
+
// Arrange - setup
|
|
1086
|
+
// Act - execute
|
|
1087
|
+
// Assert - verify
|
|
1088
|
+
});
|
|
1089
|
+
});
|
|
1090
|
+
});
|
|
1091
|
+
```
|
|
1092
|
+
|
|
1093
|
+
### 2. Nombres Descriptivos
|
|
1094
|
+
```typescript
|
|
1095
|
+
// MAL
|
|
1096
|
+
it('works', () => {});
|
|
1097
|
+
it('test 1', () => {});
|
|
1098
|
+
|
|
1099
|
+
// BIEN
|
|
1100
|
+
it('should return empty array when no items match filter', () => {});
|
|
1101
|
+
it('should throw ValidationError when email is invalid', () => {});
|
|
1102
|
+
```
|
|
1103
|
+
|
|
1104
|
+
### 3. Un Assert por Test (cuando posible)
|
|
1105
|
+
```typescript
|
|
1106
|
+
// Preferir multiples tests especificos
|
|
1107
|
+
it('should validate email format', () => {
|
|
1108
|
+
expect(validateEmail('test@example.com')).toBe(true);
|
|
1109
|
+
});
|
|
1110
|
+
|
|
1111
|
+
it('should reject email without domain', () => {
|
|
1112
|
+
expect(validateEmail('test@')).toBe(false);
|
|
1113
|
+
});
|
|
1114
|
+
```
|
|
1115
|
+
|
|
1116
|
+
### 4. Evitar Tests Fragiles
|
|
1117
|
+
```typescript
|
|
1118
|
+
// MAL - depende de implementacion
|
|
1119
|
+
expect(result).toEqual({ id: 1, name: 'Test', createdAt: '2024-01-01' });
|
|
1120
|
+
|
|
1121
|
+
// BIEN - verifica lo importante
|
|
1122
|
+
expect(result.id).toBe(1);
|
|
1123
|
+
expect(result.name).toBe('Test');
|
|
1124
|
+
expect(result.createdAt).toBeDefined();
|
|
1125
|
+
```
|
|
1126
|
+
|
|
1127
|
+
### 5. Data Builders para Tests
|
|
1128
|
+
```typescript
|
|
1129
|
+
// tests/factories/user.ts
|
|
1130
|
+
export function createUser(overrides: Partial<User> = {}): User {
|
|
1131
|
+
return {
|
|
1132
|
+
id: '1',
|
|
1133
|
+
email: 'test@example.com',
|
|
1134
|
+
name: 'Test User',
|
|
1135
|
+
...overrides,
|
|
1136
|
+
};
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
// Uso en test
|
|
1140
|
+
const user = createUser({ name: 'Custom Name' });
|
|
1141
|
+
```
|
|
1142
|
+
</best_practices>
|
|
1143
|
+
|
|
1144
|
+
<verification>
|
|
1145
|
+
## Verificacion
|
|
1146
|
+
|
|
1147
|
+
1. **Unit Tests**
|
|
1148
|
+
```bash
|
|
1149
|
+
npm run test
|
|
1150
|
+
# Deberia mostrar tests pasando
|
|
1151
|
+
```
|
|
1152
|
+
|
|
1153
|
+
2. **Coverage Report**
|
|
1154
|
+
```bash
|
|
1155
|
+
npm run test:coverage
|
|
1156
|
+
# Deberia generar reporte en ./coverage
|
|
1157
|
+
# Abrir coverage/index.html en browser
|
|
1158
|
+
```
|
|
1159
|
+
|
|
1160
|
+
3. **E2E Tests**
|
|
1161
|
+
```bash
|
|
1162
|
+
npm run test:e2e
|
|
1163
|
+
# Deberia correr tests en multiples browsers
|
|
1164
|
+
```
|
|
1165
|
+
|
|
1166
|
+
4. **E2E Report**
|
|
1167
|
+
```bash
|
|
1168
|
+
npm run test:e2e:report
|
|
1169
|
+
# Abre reporte HTML interactivo
|
|
1170
|
+
```
|
|
1171
|
+
|
|
1172
|
+
5. **Vitest UI**
|
|
1173
|
+
```bash
|
|
1174
|
+
npm run test:ui
|
|
1175
|
+
# Abre interfaz visual para tests
|
|
1176
|
+
```
|
|
1177
|
+
</verification>
|
|
1178
|
+
|
|
1179
|
+
<troubleshooting>
|
|
1180
|
+
## Solucion de Problemas
|
|
1181
|
+
|
|
1182
|
+
### Error: "Cannot find module"
|
|
1183
|
+
```bash
|
|
1184
|
+
# Verificar que las dependencias estan instaladas
|
|
1185
|
+
npm install
|
|
1186
|
+
|
|
1187
|
+
# Verificar paths en vitest.config.ts
|
|
1188
|
+
```
|
|
1189
|
+
|
|
1190
|
+
### Error: "MSW handlers not working"
|
|
1191
|
+
```typescript
|
|
1192
|
+
// Verificar que server.listen() se llama en setup
|
|
1193
|
+
// Verificar URL base en handlers
|
|
1194
|
+
```
|
|
1195
|
+
|
|
1196
|
+
### Error: "Playwright browsers not found"
|
|
1197
|
+
```bash
|
|
1198
|
+
npx playwright install
|
|
1199
|
+
npx playwright install-deps
|
|
1200
|
+
```
|
|
1201
|
+
|
|
1202
|
+
### Tests lentos
|
|
1203
|
+
```typescript
|
|
1204
|
+
// vitest.config.ts - usar pool forks
|
|
1205
|
+
pool: 'forks',
|
|
1206
|
+
poolOptions: {
|
|
1207
|
+
forks: {
|
|
1208
|
+
singleFork: true,
|
|
1209
|
+
},
|
|
1210
|
+
},
|
|
1211
|
+
```
|
|
1212
|
+
|
|
1213
|
+
### Coverage bajo
|
|
1214
|
+
```bash
|
|
1215
|
+
# Verificar que archivos correctos estan incluidos
|
|
1216
|
+
# Revisar exclude patterns en vitest.config.ts
|
|
1217
|
+
```
|
|
1218
|
+
</troubleshooting>
|