Cobertura E2E — CultivaFlow
94.0%
47 passed
3 failed (mocking API edge cases)
3 navegadores × 50 tests
Trazas capturadas en CI
Autenticación compartida
tests/auth.setup.ts
✓ 1 passed
1.2s
✅
authenticate → guarda storageState para todos los tests
setup
1.2s
tests/auth.setup.ts
typescript
import { test as setup } from '@playwright/test';
import path from 'path';
const authFile = path.join(__dirname, '.auth/user.json');
setup('authenticate', async ({ page }) => {
// 1. Navegar al login de CultivaFlow
await page.goto('/login');
await page.getByLabel('Email').fill('admin@cultivaflow.io');
await page.getByLabel('Contraseña').fill('Cultiva2024!');
await page.getByRole('button', { name: 'Iniciar sesión' }).click();
// 2. Verificar redireccion al dashboard
await expect(page).toHaveURL('/dashboard');
await expect(page.getByText('Bienvenido de vuelta')).toBeVisible();
// 3. Persistir estado de sesion (evitar re-login en cada test)
await page.context().storageState({ path: authFile });
});
tests/login.spec.ts
✓ 8 passed
18.4s
✅
Login › login exitoso redirige a /dashboard
chrome
firefox
webkit
1.8s
✅
Login › credenciales incorrectas muestran alerta
chrome
firefox
webkit
2.1s
✅
Login › formulario vacio muestra validaciones inline
chrome
firefox
webkit
1.4s
✅
Login › OAuth Google abre popup y completa auth
chrome
firefox
webkit
3.2s
✅
Login › sesion persiste tras refresh (localStorage)
chrome
firefox
webkit
2.6s
tests/pages/login.page.ts — Page Object
typescript
import { Page, Locator, expect } from '@playwright/test';
export class LoginPage {
readonly emailInput: Locator;
readonly passwordInput: Locator;
readonly submitButton: Locator;
readonly errorAlert: Locator;
readonly googleButton: Locator;
constructor(private page: Page) {
this.emailInput = page.getByLabel('Email');
this.passwordInput = page.getByLabel('Contraseña');
this.submitButton = page.getByRole('button', { name: 'Iniciar sesión' });
this.errorAlert = page.getByRole('alert');
this.googleButton = page.getByRole('button', { name: /Continuar con Google/i });
}
async goto() { await this.page.goto('/login'); }
async login(email: string, password: string) {
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.submitButton.click();
}
async expectError(msg: string) {
await expect(this.errorAlert).toContainText(msg);
}
async expectValidationErrors() {
await expect(this.page.getByText('El email es requerido')).toBeVisible();
await expect(this.page.getByText('La contraseña es requerida')).toBeVisible();
}
}
tests/dashboard.spec.ts — API Mocking
✓ 12 passed
31.7s
✅
Dashboard › muestra listado de campañas con datos reales
chromefirefoxwebkit
2.3s
✅
Dashboard › filtro por estado "Activa" reduce resultados
chromefirefoxwebkit
1.9s
✅
Dashboard › API mock › error 500 muestra "No se pudieron cargar campañas"
chromefirefoxwebkit
1.6s
✅
Dashboard › API mock › array vacio muestra empty state + CTA crear
chromefirefoxwebkit
1.4s
✅
Dashboard › paginacion → pagina 2 carga siguientes 10 campañas
chromefirefoxwebkit
2.8s
✅
Dashboard › mock plan Enterprise muestra features premium
chromefirefoxwebkit
2.1s
tests/dashboard.spec.ts — Mocking de API
typescript
import { test, expect } from '@playwright/test';
import { DashboardPage } from './pages/dashboard.page';
import { mockCampaigns } from './fixtures/campaigns';
test.describe('Dashboard — API mocking', () => {
test('error 500 muestra estado de error con botón Reintentar', async ({ page }) => {
// Interceptar API antes de navegar
await page.route('**/api/campaigns', (route) => route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({ error: 'Internal server error' }),
}));
await page.goto('/dashboard');
await expect(page.getByText('No se pudieron cargar las campañas')).toBeVisible();
await expect(page.getByRole('button', { name: 'Reintentar' })).toBeVisible();
});
test('array vacío muestra empty state + CTA crear campaña', async ({ page }) => {
await page.route('**/api/campaigns', (route) => route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ data: [], total: 0 }),
}));
await page.goto('/dashboard');
await expect(page.getByText('Todavía no tienes campañas')).toBeVisible();
await expect(page.getByRole('link', { name: 'Crear primera campaña' })).toBeVisible();
});
test('plan Enterprise desbloquea features avanzadas', async ({ page }) => {
// Modificar respuesta existente (patrón pass-through)
await page.route('**/api/user/me', async (route) => {
const response = await route.fetch();
const json = await response.json();
json.plan = 'enterprise';
await route.fulfill({ response, json });
});
await page.goto('/dashboard');
await expect(page.getByText('Plan Enterprise')).toBeVisible();
await expect(page.getByTestId('analytics-advanced')).toBeVisible();
});
});
tests/campaigns.spec.ts — Form multi-step
✓ 11 passed
✗ 3 failed
47.2s
✅
Crear campaña › step 1 Brief: todos los campos requeridos
chromefirefoxwebkit
3.1s
✅
Crear campaña › step 2 Audiencia: segmentos seleccionables
chromefirefoxwebkit
2.7s
✅
Crear campaña › step 3 Presupuesto: slider + input sincronizan
chromefirefoxwebkit
2.4s
✅
Crear campaña › step 4 Revisar: resumen correcto antes de publicar
chromefirefoxwebkit
4.2s
❌
Crear campaña › validacion presupuesto negativo → webkit timeout
chromefirefoxwebkit
10.0s
❌
Crear campaña › navegar Atrás conserva datos del step anterior → flaky
chromefirefoxwebkit
8.3s
❌
Crear campaña › submit API timeout muestra error + opción guardar borrador
chromefirefoxwebkit
10.0s
Regresión Visual y Accesibilidad
Dashboard — WCAG 2.1 AA completo
WCAG2AA
0 violations
Login — contraste, labels, keyboard-nav
WCAG2A
0 violations
Crear campaña — form roles, aria-required
WCAG2AA
0 violations
Configuración — modal foco atrapado correctamente
WCAG2AA
0 violations
Snapshot visual — campaigns list (fullPage)
screenshot
diff 0.3%
tests/a11y.spec.ts — Accesibilidad con axe-core
typescript
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
const pages = ['/login', '/dashboard', '/campaigns/new', '/settings'];
for (const route of pages) {
test(`no hay violaciones WCAG2AA en ${route}`, async ({ page }) => {
await page.goto(route);
await page.waitForLoadState('networkidle');
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'best-practice'])
.exclude('#third-party-widget') // widget externo sin control
.analyze();
expect(results.violations).toEqual([]);
});
}
test('regresion visual — lista de campañas (fullPage)', async ({ page }) => {
await page.goto('/dashboard');
await page.waitForSelector('[data-testid="campaigns-grid"]');
await expect(page).toHaveScreenshot('campaigns-list.png', {
fullPage: true,
maxDiffPixelRatio: 0.01,
mask: [page.getByTestId('date-dynamic')], // enmascarar fechas variables
});
});
GitHub Actions — Matriz CI/CD
.github/workflows/e2e.yml
yaml
name: E2E Tests — CultivaFlow
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
e2e:
runs-on: ubuntu-latest
strategy:
matrix:
browser: [chromium, firefox, webkit]
fail-fast: false # continuar en otros browsers si uno falla
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20, cache: npm }
- run: npm ci
- run: npx playwright install --with-deps ${{ matrix.browser }}
- name: Run E2E tests
run: npx playwright test --project=${{ matrix.browser }}
env:
BASE_URL: https://app-staging.cultivaflow.io
SUPABASE_URL: ${{ secrets.SUPABASE_URL }}
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report-${{ matrix.browser }}
path: playwright-report/
retention-days: 30
Resultado última ejecucion CI — branch: feature/campaigns-v2
Archivo
Chromium
Firefox
WebKit
auth.setup.ts
✓ 1/1
✓ 1/1
✓ 1/1
login.spec.ts
✓ 5/5
✓ 5/5
✓ 5/5
dashboard.spec.ts
✓ 6/6
✓ 6/6
✓ 6/6
campaigns.spec.ts
✗ 6/7 — API timeout
✗ 6/7 — flaky back-nav
✗ 6/7 — slider timeout
account.spec.ts
✓ 5/5
✓ 5/5
✓ 5/5
visual.spec.ts
✓ 3/3
✓ 3/3
✓ 3/3
a11y.spec.ts
✓ 5/5
✓ 5/5
✓ 5/5
playwright.config.ts — Configuracion completa
typescript
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
fullyParallel: true, // paralelismo maximo
forbidOnly: !!process.env.CI, // falla si test.only en CI
retries: process.env.CI ? 2 : 0, // reintentos en CI para tests flaky
workers: process.env.CI ? 1 : undefined,
reporter: [
['html', { open: 'never' }],
['junit', { outputFile: 'test-results/junit.xml' }],
['list'],
],
use: {
baseURL: process.env.BASE_URL || 'http://localhost:3000',
trace: 'on-first-retry', // trazas para debug en CI
screenshot: 'only-on-failure', // capturas solo en fallos
video: 'retain-on-failure', // video en fallos
locale: 'es-ES',
timezoneId: 'Europe/Madrid',
},
projects: [
// Auth setup — se ejecuta primero y una sola vez
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
{
name: 'chromium',
use: { ...devices['Desktop Chrome'], storageState: 'tests/.auth/user.json' },
dependencies: ['setup'],
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'], storageState: 'tests/.auth/user.json' },
dependencies: ['setup'],
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'], storageState: 'tests/.auth/user.json' },
dependencies: ['setup'],
},
{
name: 'mobile-chrome',
use: { ...devices['Pixel 5'], storageState: 'tests/.auth/user.json' },
dependencies: ['setup'],
},
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
});