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-testidexclusivamente — 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--workerspara execuções de CI mais rápidas- Grave testes com
playwright codegenmas 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.
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.
Artigos Relacionados
AI-Powered Accounting Automation: What Works in 2026
Discover which AI accounting automation tools deliver real ROI in 2026, from bank reconciliation to predictive cash flow, with implementation strategies.
Payroll Processing: Setup, Compliance, and Automation
Complete payroll processing guide covering employee classification, federal and state withholding, payroll taxes, garnishments, automation platforms, and year-end W-2 compliance.
AI Agents for Business Automation: The 2026 Landscape
Explore how AI agents are transforming business automation in 2026, from multi-agent orchestration to practical deployment strategies for enterprise teams.