Playwright E2E Testing: Page Objects and Best Practices

Build robust Playwright E2E test suites with page objects, data-testid selectors, multi-browser testing, CI integration, and accessibility assertions for Next.js applications.

E
ECOSIRE Research and Development Team
|19 de março de 20269 min de leitura2.0k Palavras|

Teste Playwright E2E: objetos de página e práticas recomendadas

Os testes unitários verificam se as funções individuais se comportam corretamente. Os testes de integração verificam se os serviços funcionam juntos. Mas nenhum dos dois detecta o que ocorre quando um usuário realmente clica em seu aplicativo: fluxos de navegação interrompidos, erros de hidratação de JavaScript, falhas de renderização dependentes de rede e bugs de renderização entre navegadores. Os testes ponta a ponta com Playwright preenchem essa lacuna e são muito mais confiáveis ​​do que seus antecessores Cypress ou Selenium.

O Playwright executa navegadores reais Chromium, Firefox e WebKit em paralelo. Ele aguarda automaticamente que os elementos fiquem visíveis e estáveis ​​antes de interagir. Ele captura capturas de tela, vídeos e rastreamentos de rede em caso de falha. E sua primeira API TypeScript torna a escrita de testes sustentáveis ​​um prazer. Este guia cobre tudo, desde configuração a objetos de página até integração de CI para aplicativos Next.js.

Principais conclusões

  • Use seletores data-testid exclusivamente — seletores CSS e seletores de texto quebram nas alterações da interface do usuário
  • O Page Object Model (POM) abstrai os seletores em classes reutilizáveis — um local de alteração por elemento da UI
  • Sempre use await page.goto() + await expect(page).toHaveURL() — nunca presuma que a navegação seja concluída de forma síncrona
  • Use sinalizadores test.describe.parallel() e --workers para execuções de CI mais rápidas
  • Grave testes com playwright codegen mas sempre refatore para objetos de página antes de confirmar
  • Capture screenshots em caso de falha via screenshot: 'only-on-failure' na configuração
  • Teste a acessibilidade junto com o comportamento com @axe-core/playwright
  • Respostas simuladas da API para chamadas de terceiros instáveis; use API real para caminhos críticos e felizes

Configuração

// 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,
  },
});

Testar estrutura de diretório

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 objeto de página

O Page Object Model é o padrão mais importante para testes E2E sustentáveis. Quando um seletor muda, você atualiza um local – não todos os testes que o utilizam.

// 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);
  }
}

Dispositivos de autenticação

// 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 || '[email protected]',
        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 || '[email protected]',
        password: process.env.TEST_USER_PASSWORD || 'testpassword123',
      },
    });

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

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

Escrevendo testes

// 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');
  });
});

Testes de páginas públicas

// 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/);
  });
});

Testes 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);
  });
});

Testes de acessibilidade

// 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');
  });
});

Integração 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

Perguntas frequentes

Quando devo usar funções data-testid vs ARIA vs seletores de texto?

Prefira data-testid como seletor principal — ele sobrevive a reprojetos de UI, alterações de cópia e refatorações. Use seletores de função ARIA (getByRole('button', { name: 'Submit' })) para testar a acessibilidade simultaneamente. Evite seletores de conteúdo de texto (getByText('Submit')) porque eles quebram quando a cópia é alterada. Nunca use seletores de classe CSS — eles são detalhes de implementação que mudam com o trabalho de estilo.

Quantos projetos (navegadores) devo executar no CI?

Para solicitações pull, execute o Chromium apenas para manter o CI rápido. Para fusões de ramificações principais, execute todos os três navegadores de desktop (Chromium, Firefox, WebKit). Para candidatos a lançamento, adicione viewports móveis. O WebKit detecta bugs de layout específicos do Safari; O Firefox detecta peculiaridades que o Chrome e o Safari escondem. A hierarquização economiza minutos de CI enquanto detecta bugs em vários navegadores antes do lançamento.

Como faço para lidar com testes instáveis causados por problemas de tempo?

A espera automática do Playwright deve resolver a maioria dos problemas de tempo - use await expect(locator).toBeVisible() em vez de page.waitForTimeout(). Se a espera automática não for suficiente, use page.waitForResponse() para estado dependente da rede, page.waitForLoadState('networkidle') para páginas pesadas ou page.waitForFunction() para condições personalizadas. Defina retries: 2 no CI para distinguir falhas genuínas de falhas de infraestrutura.

Os testes E2E devem usar o banco de dados real ou um banco de dados de teste propagado?

Use um ambiente de teste dedicado com um banco de dados de teste propagado para testes E2E. Nunca execute testes E2E em produção. Para CI, crie uma pilha Docker Compose com um novo banco de dados propagado com dados de teste. Crie e limpe dados de teste por meio de chamadas de API em ganchos beforeAll/afterAll em vez de depender de dados pré-projetados que podem sofrer desvios.

Como faço para testar downloads de arquivos no Playwright?

Use o padrão page.waitForDownload(): const [download] = await Promise.all([page.waitForEvent('download'), page.click('[data-testid="download-button"]')]); const path = await download.path(); expect(path).toBeTruthy();. Você também pode verificar download.suggestedFilename() e ler o conteúdo do arquivo para validação.


Próximas etapas

Os testes Playwright E2E são a última rede de segurança antes que os usuários vejam seu aplicativo. Investidos adequadamente — com objetos de página, seletores de teste de dados, acessórios de autenticação e integração de CI — eles capturam o que falta nos testes de unidade e dão a você a confiança para implantar às sextas-feiras.

ECOSIRE mantém 29 arquivos de teste Playwright E2E cobrindo mais de 416 casos de teste em páginas públicas, fluxos de trabalho administrativos e acessibilidade para todas as 11 localidades. Explore nossos serviços de engenharia de front-end para saber como criamos aplicativos Next.js com controle de qualidade.

E

Escrito por

ECOSIRE Research and Development Team

Construindo produtos digitais de nível empresarial na ECOSIRE. Compartilhando insights sobre integrações Odoo, automação de e-commerce e soluções de negócios com IA.

Converse no WhatsApp