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
|2026年3月19日6 分钟阅读1.4k 字数|

Playwright E2E 测试:页面对象和最佳实践

单元测试验证各个功能是否正确运行。集成测试验证服务是否可以协同工作。但两者都没有捕捉到用户实际点击应用程序时出现的问题——导航流程中断、JavaScript 水合作用错误、依赖于网络的渲染失败以及跨浏览器渲染错误。 Playwright 的端到端测试填补了这一空白,而且它们比 Cypress 或 Selenium 的前辈可靠得多。

Playwright 并行运行真正的 Chromium、Firefox 和 WebKit 浏览器。它会在交互之前自动等待元素可见且稳定。它可以捕获故障时的屏幕截图、视频和网络痕迹。它的 TypeScript-first API 使编写可维护的测试成为一种乐趣。本指南涵盖了从配置到页面对象再到 Next.js 应用程序的 CI 集成的所有内容。

要点

  • 仅使用 data-testid 选择器 - CSS 选择器和文本选择器在 UI 更改时中断
  • 页面对象模型 (POM) 将选择器抽象为可重用的类 — 每个 UI 元素一个更改位置
  • 始终使用 await page.goto() + await expect(page).toHaveURL() — 永远不要假设导航同步完成
  • 使用 test.describe.parallel()--workers 标志来加快 CI 运行速度
  • 使用 playwright codegen 记录测试,但在提交之前始终重构到页面对象
  • 通过配置中的 screenshot: 'only-on-failure' 捕获失败时的屏幕截图
  • 使用 @axe-core/playwright 测试可访问性和行为
  • 针对不稳定的第三方调用的模拟 API 响应;使用真正的 API 实现关键的快乐路径

配置

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

测试目录结构

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

页面对象模型

页面对象模型是可维护的 E2E 测试中最重要的模式。当选择器发生更改时,您会更新一个位置,而不是每个使用它的测试。

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

身份验证装置

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

编写测试

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

公共页面测试

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

国际化测试

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

辅助功能测试

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

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

常见问题

什么时候应该使用 data-testid、ARIA 角色和文本选择器?

优先选择 data-testid 作为主要选择器——它能够经受住 UI 重新设计、复制更改和重构。使用 ARIA 角色选择器 (getByRole('button', { name: 'Submit' })) 同时测试可访问性。避免使用文本内容选择器 (getByText('Submit')),因为它们会在副本更改时中断。切勿使用 CSS 类选择器 - 它们是随样式工作而变化的实现细节。

我应该在 CI 中运行多少个项目(浏览器)?

对于拉取请求,仅运行 Chromium 以保持 CI 快速。对于主分支合并,运行所有三个桌面浏览器(Chromium、Firefox、WebKit)。对于候选版本,添加移动视口。 WebKit 捕获了 Safari 特定的布局错误; Firefox 捕捉到了 Chrome 和 Safari 都隐藏的怪癖。分层可以节省 CI 分钟时间,同时在发布前捕获跨浏览器错误。

如何处理由计时问题引起的不稳定测试?

Playwright 的自动等待应该可以处理大多数计时问题 - 使用 await expect(locator).toBeVisible() 而不是 page.waitForTimeout()。如果自动等待还不够,请使用 page.waitForResponse() 表示网络相关状态,使用 page.waitForLoadState('networkidle') 表示大页面,或使用 page.waitForFunction() 表示自定义条件。在 CI 中设置 retries: 2 以区分真正的故障和基础设施脆弱性。

E2E 测试应该使用真实数据库还是种子测试数据库?

使用带有种子测试数据库的专用暂存环境进行端到端测试。切勿针对生产运行 E2E 测试。对于 CI,使用包含测试数据的新数据库启动 Docker Compose 堆栈。通过 beforeAll/afterAll 挂钩中的 API 调用创建和清理测试数据,而不是依赖可能漂移的预先播种的数据。

如何在 Playwright 中测试文件下载?

使用 page.waitForDownload() 模式:const [download] = await Promise.all([page.waitForEvent('download'), page.click('[data-testid="download-button"]')]); const path = await download.path(); expect(path).toBeTruthy();。您还可以检查 download.suggestedFilename() 并读取文件内容进行验证。


后续步骤

Playwright E2E 测试是用户看到您的应用程序之前的最后一道安全网。通过页面对象、数据测试 ID 选择器、身份验证装置和 CI 集成进行适当的投资,它们可以捕获单元测试遗漏的内容,并让您有信心在周五进行部署。

ECOSIRE 维护 29 个 Playwright E2E 测试文件,涵盖公共页面、管理工作流程和所有 11 个区域设置的可访问性的 416 多个测试用例。 探索我们的前端工程服务 了解我们如何构建质量严格的 Next.js 应用程序。

E

作者

ECOSIRE Research and Development Team

在 ECOSIRE 构建企业级数字产品。分享关于 Odoo 集成、电商自动化和 AI 驱动商业解决方案的洞见。

通过 WhatsApp 聊天