Saltar al contenido principal

Pruebas Playwright E2E: objetos de página y mejores prácticas

Cree conjuntos de pruebas sólidos de Playwright E2E con objetos de página, selectores de datos de prueba, pruebas en varios navegadores, integración de CI y afirmaciones de accesibilidad para aplicaciones Next.js.

E
ECOSIRE Research and Development Team
|19 de marzo de 202610 min de lectura2.0k Palabras|

Las pruebas unitarias verifican que las funciones individuales se comporten correctamente. Las pruebas de integración verifican que los servicios funcionen juntos.

Pero ninguno detecta lo que se rompe cuando un usuario realmente hace clic en su aplicación: flujos de navegación interrumpidos, errores de hidratación de JavaScript, fallas de procesamiento dependientes de la red y errores de procesamiento entre navegadores. Las pruebas de extremo a extremo con Playwright llenan este vacío y son mucho más confiables que sus predecesores Cypress o Selenium.

Playwright ejecuta navegadores Chromium, Firefox y WebKit reales en paralelo. Espera automáticamente a que los elementos sean visibles y estables antes de interactuar. Captura capturas de pantalla, videos y rastros de red en caso de falla. Y su primera API de TypeScript hace que escribir pruebas mantenibles sea un placer. Esta guía cubre todo, desde la configuración hasta los objetos de página y la integración de CI para aplicaciones Next.js.

Conclusiones clave

  • Utilice selectores data-testid exclusivamente: los selectores CSS y los selectores de texto se interrumpen con los cambios en la interfaz de usuario.
  • El modelo de objetos de página (POM) abstrae los selectores en clases reutilizables: una ubicación de cambio por elemento de la interfaz de usuario
  • Utilice siempre await page.goto() + await expect(page).toHaveURL(); nunca asuma que la navegación se completa sincrónicamente
  • Utilice el indicador test.describe.parallel() y --workers para ejecuciones de CI más rápidas
  • Registrar pruebas con playwright codegen pero siempre refactorizar los objetos de la página antes de confirmar
  • Capture capturas de pantalla en caso de falla a través de screenshot: 'only-on-failure' en la configuración
  • Pruebe la accesibilidad junto con el comportamiento con @axe-core/playwright
  • Respuestas API simuladas para llamadas de terceros poco fiables; use API real para caminos felices críticos

Configuración

// apps/web/playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './tests',
  timeout: 30_000,
  expect: { timeout: 5_000 },
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 2 : undefined,
  reporter: [
    ['html', { outputFolder: 'playwright-report' }],
    ['json', { outputFile: 'playwright-report/results.json' }],
    ...(process.env.CI ? [['github'] as ['github']] : []),
  ],
  use: {
    baseURL: process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:3000',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
    video: 'on-first-retry',
  },
  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
    {
      name: 'firefox',
      use: { ...devices['Desktop Firefox'] },
    },
    {
      name: 'webkit',
      use: { ...devices['Desktop Safari'] },
    },
    {
      name: 'mobile-chrome',
      use: { ...devices['Pixel 7'] },
    },
    {
      name: 'mobile-safari',
      use: { ...devices['iPhone 14'] },
    },
  ],
  webServer: {
    command: 'pnpm dev',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
    timeout: 120_000,
  },
});

Estructura del directorio de prueba

tests/
  public/           # Tests for unauthenticated public pages
    homepage.spec.ts
    blog.spec.ts
    services.spec.ts
    sitemap.spec.ts
    i18n.spec.ts
    seo.spec.ts
  admin/            # Tests for authenticated admin pages
    contacts.spec.ts
    orders.spec.ts
    dashboard.spec.ts
  fixtures/
    auth.fixture.ts   # Authentication fixtures
    data.fixture.ts   # Test data factories
  pages/            # Page Object Model classes
    HomePage.ts
    BlogPage.ts
    ContactsPage.ts
    LoginPage.ts
  helpers/
    api.helper.ts    # API call helpers for test setup

Modelo de objetos de página

El modelo de objetos de página es el patrón más importante para las pruebas E2E mantenibles. Cuando cambia un selector, actualiza un lugar, no todas las pruebas que lo utilizan.

// tests/pages/LoginPage.ts
import { Page, Locator, expect } from '@playwright/test';

export class LoginPage {
  readonly page: Page;
  readonly emailInput: Locator;
  readonly passwordInput: Locator;
  readonly submitButton: Locator;
  readonly errorMessage: Locator;
  readonly forgotPasswordLink: Locator;

  constructor(page: Page) {
    this.page = page;
    // Always use data-testid selectors
    this.emailInput       = page.getByTestId('login-email');
    this.passwordInput    = page.getByTestId('login-password');
    this.submitButton     = page.getByTestId('login-submit');
    this.errorMessage     = page.getByTestId('login-error');
    this.forgotPasswordLink = page.getByTestId('forgot-password-link');
  }

  async goto() {
    await this.page.goto('/auth/login');
    await expect(this.page).toHaveURL('/auth/login');
    await expect(this.submitButton).toBeVisible();
  }

  async login(email: string, password: string) {
    await this.emailInput.fill(email);
    await this.passwordInput.fill(password);
    await this.submitButton.click();
  }

  async loginAndWaitForRedirect(email: string, password: string, expectedUrl: string) {
    await this.login(email, password);
    await expect(this.page).toHaveURL(expectedUrl, { timeout: 10_000 });
  }

  async expectError(message: string) {
    await expect(this.errorMessage).toBeVisible();
    await expect(this.errorMessage).toContainText(message);
  }
}
// tests/pages/ContactsPage.ts
import { Page, Locator, expect } from '@playwright/test';

export class ContactsPage {
  readonly page: Page;
  readonly addButton: Locator;
  readonly searchInput: Locator;
  readonly table: Locator;
  readonly dialog: Locator;
  readonly nameInput: Locator;
  readonly emailInput: Locator;
  readonly saveButton: Locator;
  readonly successToast: Locator;

  constructor(page: Page) {
    this.page        = page;
    this.addButton   = page.getByTestId('contacts-add-button');
    this.searchInput = page.getByTestId('contacts-search');
    this.table       = page.getByTestId('contacts-table');
    this.dialog      = page.getByTestId('contact-dialog');
    this.nameInput   = page.getByTestId('contact-name-input');
    this.emailInput  = page.getByTestId('contact-email-input');
    this.saveButton  = page.getByTestId('contact-save-button');
    this.successToast = page.getByTestId('toast-success');
  }

  async goto() {
    await this.page.goto('/dashboard/contacts');
    await expect(this.table).toBeVisible();
  }

  async openAddDialog() {
    await this.addButton.click();
    await expect(this.dialog).toBeVisible();
  }

  async createContact(name: string, email: string) {
    await this.openAddDialog();
    await this.nameInput.fill(name);
    await this.emailInput.fill(email);
    await this.saveButton.click();
    await expect(this.successToast).toBeVisible();
  }

  async searchFor(query: string) {
    await this.searchInput.fill(query);
    await this.page.waitForTimeout(500); // Debounce delay
  }

  async getRowCount() {
    return this.table.getByRole('row').count();
  }

  async expectContactInTable(name: string) {
    await expect(this.table).toContainText(name);
  }
}

Accesorios de autenticación

// tests/fixtures/auth.fixture.ts
import { test as base, Page } from '@playwright/test';

type AuthFixture = {
  adminPage: Page;
  userPage: Page;
};

export const test = base.extend<AuthFixture>({
  adminPage: async ({ browser }, use) => {
    const context = await browser.newContext();
    const page = await context.newPage();

    // Login via API to bypass UI (faster, more reliable)
    const response = await page.request.post('/auth/login', {
      data: {
        email: process.env.TEST_ADMIN_EMAIL || 'admin@test.com',
        password: process.env.TEST_ADMIN_PASSWORD || 'testpassword123',
      },
    });

    if (!response.ok()) {
      throw new Error(`Auth failed: ${response.status()}`);
    }

    // Cookies are set automatically on the context
    await use(page);
    await context.close();
  },

  userPage: async ({ browser }, use) => {
    const context = await browser.newContext();
    const page = await context.newPage();

    await page.request.post('/auth/login', {
      data: {
        email: process.env.TEST_USER_EMAIL || 'user@test.com',
        password: process.env.TEST_USER_PASSWORD || 'testpassword123',
      },
    });

    await use(page);
    await context.close();
  },
});

export { expect } from '@playwright/test';

Pruebas de escritura

// tests/admin/contacts.spec.ts
import { test, expect } from '../fixtures/auth.fixture';
import { ContactsPage } from '../pages/ContactsPage';

test.describe('Contacts Management', () => {
  test('admin can create, search, and delete a contact', async ({ adminPage }) => {
    const contactsPage = new ContactsPage(adminPage);
    const testName = `Test User ${Date.now()}`;
    const testEmail = `test-${Date.now()}@example.com`;

    await contactsPage.goto();

    // Create
    await contactsPage.createContact(testName, testEmail);
    await contactsPage.expectContactInTable(testName);

    // Search
    await contactsPage.searchFor(testName);
    const rowCount = await contactsPage.getRowCount();
    expect(rowCount).toBe(2); // 1 header + 1 result

    // Delete
    await adminPage.getByTestId(`contact-delete-${testEmail}`).click();
    await adminPage.getByTestId('confirm-delete').click();
    await expect(contactsPage.successToast).toBeVisible();
    await expect(contactsPage.table).not.toContainText(testName);
  });

  test('shows validation errors for invalid contact data', async ({ adminPage }) => {
    const contactsPage = new ContactsPage(adminPage);
    await contactsPage.goto();
    await contactsPage.openAddDialog();

    // Submit without filling required fields
    await contactsPage.saveButton.click();

    await expect(adminPage.getByTestId('error-name')).toContainText('required');
    await expect(adminPage.getByTestId('error-email')).toContainText('required');
  });
});

Pruebas de página pública

// tests/public/homepage.spec.ts
import { test, expect } from '@playwright/test';

test.describe('Homepage', () => {
  test('renders hero section with correct heading', async ({ page }) => {
    await page.goto('/');
    await expect(page).toHaveTitle(/ECOSIRE/);

    const hero = page.getByTestId('hero-heading');
    await expect(hero).toBeVisible();
    await expect(hero).not.toBeEmpty();
  });

  test('navigation links are all accessible', async ({ page }) => {
    await page.goto('/');

    const navLinks = page.getByRole('navigation').getByRole('link');
    const count = await navLinks.count();

    for (let i = 0; i < count; i++) {
      const link = navLinks.nth(i);
      const href = await link.getAttribute('href');
      // All nav links should have valid hrefs
      expect(href).toBeTruthy();
      expect(href).not.toBe('#');
    }
  });

  test('CTA button links to services page', async ({ page }) => {
    await page.goto('/');
    await page.getByTestId('hero-cta').click();
    await expect(page).toHaveURL(/\/services/);
  });
});

Pruebas i18n

// tests/public/i18n.spec.ts
import { test, expect } from '@playwright/test';

const testLocales = ['zh', 'es', 'ar', 'fr'];

test.describe('Internationalization', () => {
  for (const locale of testLocales) {
    test(`/${locale}/ renders with correct lang attribute`, async ({ page }) => {
      await page.goto(`/${locale}/`);

      const html = page.locator('html');
      await expect(html).toHaveAttribute('lang', locale);
    });
  }

  test('Arabic locale sets dir="rtl"', async ({ page }) => {
    await page.goto('/ar/');
    const html = page.locator('html');
    await expect(html).toHaveAttribute('dir', 'rtl');
  });

  test('language switcher changes locale', async ({ page }) => {
    await page.goto('/');
    await page.getByTestId('language-switcher').click();
    await page.getByTestId('locale-option-fr').click();
    await expect(page).toHaveURL('/fr/');
    await expect(page.locator('html')).toHaveAttribute('lang', 'fr');
  });

  test('hreflang tags are present on all pages', async ({ page }) => {
    await page.goto('/');
    const hreflangs = await page.locator('link[rel="alternate"][hreflang]').all();
    // Should have 11 locale hreflangs + x-default
    expect(hreflangs.length).toBeGreaterThanOrEqual(12);
  });
});

Pruebas de accesibilidad

// tests/public/accessibility.spec.ts
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';

test.describe('Accessibility', () => {
  const pages = ['/', '/blog', '/services', '/about', '/contact'];

  for (const path of pages) {
    test(`${path} has no WCAG 2.1 AA violations`, async ({ page }) => {
      await page.goto(path);

      const results = await new AxeBuilder({ page })
        .withTags(['wcag2a', 'wcag2aa', 'wcag21aa'])
        .analyze();

      if (results.violations.length > 0) {
        const report = results.violations.map((v) =>
          `${v.id}: ${v.description} — ${v.nodes.length} element(s)`
        ).join('\n');
        expect.fail(`Accessibility violations on ${path}:\n${report}`);
      }
    });
  }

  test('skip link is the first focusable element', async ({ page }) => {
    await page.goto('/');
    await page.keyboard.press('Tab');
    const focused = page.locator(':focus');
    await expect(focused).toHaveAttribute('href', '#main-content');
  });
});

Integración de CI

# .github/workflows/ci.yml (E2E section)
e2e-tests:
  runs-on: ubuntu-latest
  needs: [unit-tests, build]
  steps:
    - uses: actions/checkout@v4
    - uses: pnpm/action-setup@v3
    - uses: actions/setup-node@v4
      with:
        node-version: 22
        cache: 'pnpm'
    - run: pnpm install --frozen-lockfile

    - name: Install Playwright browsers
      run: cd apps/web && npx playwright install --with-deps chromium firefox

    - name: Run E2E tests
      run: cd apps/web && npx playwright test --project=chromium --project=firefox
      env:
        PLAYWRIGHT_BASE_URL: ${{ secrets.STAGING_URL }}
        TEST_ADMIN_EMAIL: ${{ secrets.TEST_ADMIN_EMAIL }}
        TEST_ADMIN_PASSWORD: ${{ secrets.TEST_ADMIN_PASSWORD }}

    - uses: actions/upload-artifact@v4
      if: failure()
      with:
        name: playwright-report
        path: apps/web/playwright-report/
        retention-days: 7

Preguntas frecuentes

¿Cuándo debo usar data-testid frente a roles ARIA frente a selectores de texto?

Prefiera data-testid como selector principal: sobrevive a los rediseños de la interfaz de usuario, los cambios de copia y las refactorizaciones. Utilice los selectores de roles ARIA (getByRole('button', { name: 'Submit' })) para probar la accesibilidad simultáneamente. Evite los selectores de contenido de texto (getByText('Submit')) porque se rompen cuando cambia la copia. Nunca utilice selectores de clases CSS: son detalles de implementación que cambian con el trabajo de estilo.

¿Cuántos proyectos (navegadores) debo ejecutar en CI?

Para solicitudes de extracción, ejecute Chromium solo para mantener la CI rápida. Para fusionar ramas principales, ejecute los tres navegadores de escritorio (Chromium, Firefox, WebKit). Para los candidatos a lanzamiento, agregue ventanas gráficas móviles. WebKit detecta errores de diseño específicos de Safari; Firefox detecta peculiaridades que tanto Chrome como Safari ocultan. La organización por niveles ahorra minutos de CI y detecta errores en varios navegadores antes del lanzamiento.

¿Cómo manejo las pruebas inestables causadas por problemas de tiempo?

La espera automática de Playwright debería solucionar la mayoría de los problemas de sincronización: utilice await expect(locator).toBeVisible() en lugar de page.waitForTimeout(). Si la espera automática no es suficiente, use page.waitForResponse() para el estado dependiente de la red, page.waitForLoadState('networkidle') para páginas pesadas o page.waitForFunction() para condiciones personalizadas. Establezca retries: 2 en CI para distinguir las fallas genuinas de las deficiencias de la infraestructura.

¿Las pruebas E2E deben utilizar la base de datos real o una base de datos de prueba inicializada?

Utilice un entorno de prueba dedicado con una base de datos de pruebas inicializada para pruebas E2E. Nunca ejecute pruebas E2E contra producción. Para CI, active una pila Docker Compose con una base de datos nueva con datos de prueba. Cree y limpie datos de prueba a través de llamadas API en ganchos beforeAll/afterAll en lugar de depender de datos predefinidos que pueden desviarse.

¿Cómo pruebo las descargas de archivos en Playwright?

Utilice el patrón page.waitForDownload(): const [download] = await Promise.all([page.waitForEvent('download'), page.click('[data-testid="download-button"]')]); const path = await download.path(); expect(path).toBeTruthy();. También puede verificar download.suggestedFilename() y leer el contenido del archivo para su validación.


Próximos pasos

Las pruebas Playwright E2E son la última red de seguridad antes de que los usuarios vean su aplicación. Si se invierten adecuadamente (con objetos de página, selectores de datos de prueba, accesorios de autenticación e integración de CI), detectan lo que las pruebas unitarias pasan por alto y le brindan la confianza para implementarlas los viernes.

ECOSIRE mantiene 29 archivos de prueba de Playwright E2E que cubren más de 416 casos de prueba en páginas públicas, flujos de trabajo de administración y accesibilidad para las 11 configuraciones regionales. Explore nuestros servicios de ingeniería frontend para saber cómo creamos aplicaciones Next.js con control de calidad.

E

Escrito por

ECOSIRE Research and Development Team

Redacción técnica

El equipo de redacción técnica de ECOSIRE escribe sobre Odoo ERP, comercio electrónico con Shopify, agentes de IA, analítica con Power BI, automatización con GoHighLevel y buenas prácticas de software empresarial. Nuestras guías ayudan a las empresas a tomar decisiones tecnológicas informadas.

ECOSIRE

Haga crecer su negocio con ECOSIRE

Soluciones empresariales en ERP, comercio electrónico, inteligencia artificial, análisis y automatización.