Playwright E2E-Tests: Seitenobjekte und Best Practices
Unit-Tests überprüfen, ob sich einzelne Funktionen korrekt verhalten. Integrationstests überprüfen, ob Dienste zusammenarbeiten. Aber keiner erkennt, was kaputt geht, wenn ein Benutzer tatsächlich durch Ihre Anwendung klickt – unterbrochene Navigationsflüsse, JavaScript-Hydrationsfehler, netzwerkabhängige Rendering-Fehler und browserübergreifende Rendering-Fehler. End-to-End-Tests mit Playwright schließen diese Lücke und sind weitaus zuverlässiger als ihre Cypress- oder Selenium-Vorgänger.
Playwright führt parallel echte Chromium-, Firefox- und WebKit-Browser aus. Es wartet automatisch darauf, dass Elemente sichtbar und stabil sind, bevor es interagiert. Es erfasst Screenshots, Videos und Netzwerkspuren bei Fehlern. Und seine TypeScript-First-API macht das Schreiben wartbarer Tests zum Vergnügen. Dieses Handbuch deckt alles ab, von der Konfiguration über Seitenobjekte bis hin zur CI-Integration für Next.js-Anwendungen.
Wichtige Erkenntnisse
– Verwenden Sie ausschließlich
data-testid-Selektoren – CSS-Selektoren und Text-Selektoren funktionieren bei Änderungen an der Benutzeroberfläche nicht – Das Page Object Model (POM) abstrahiert Selektoren in wiederverwendbare Klassen – ein Änderungsort pro UI-Element – Verwenden Sie immerawait page.goto()+await expect(page).toHaveURL()– gehen Sie niemals davon aus, dass die Navigation synchron abgeschlossen wird – Verwenden Sie die Flagstest.describe.parallel()und--workersfür schnellere CI-Ausführungen – Zeichnen Sie Tests mitplaywright codegenauf, aber führen Sie vor dem Festschreiben immer eine Umgestaltung der Seitenobjekte durch
- Erfassen Sie Screenshots bei Fehlern über
screenshot: 'only-on-failure'in der Konfiguration- Testen Sie die Zugänglichkeit und das Verhalten mit
@axe-core/playwright– Schein-API-Antworten für unzuverlässige Aufrufe von Drittanbietern; Verwenden Sie eine echte API für kritische, glückliche Pfade
Konfiguration
// 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,
},
});
Testverzeichnisstruktur
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
Seitenobjektmodell
Das Page Object Model ist das wichtigste Muster für wartbare E2E-Tests. Wenn sich ein Selektor ändert, aktualisieren Sie eine Stelle – nicht jeden Test, der ihn verwendet.
// 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);
}
}
Authentifizierungsgeräte
// 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 schreiben
// 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');
});
});
Öffentliche Seitentests
// 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/);
});
});
i18n-Tests
// 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);
});
});
Barrierefreiheitstests
// 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-Integration
# .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
Häufig gestellte Fragen
Wann sollte ich data-testid vs. ARIA-Rollen vs. Textselektoren verwenden?
Bevorzugen Sie data-testid als primären Selektor – er übersteht Neugestaltungen der Benutzeroberfläche, Kopieränderungen und Refaktorierungen. Verwenden Sie ARIA-Rollenselektoren (getByRole('button', { name: 'Submit' })), um gleichzeitig die Barrierefreiheit zu testen. Vermeiden Sie Textinhaltsselektoren (getByText('Submit')), da diese bei Kopieränderungen kaputt gehen. Verwenden Sie niemals CSS-Klassenselektoren – es handelt sich um Implementierungsdetails, die sich mit der Styling-Arbeit ändern.
Wie viele Projekte (Browser) sollte ich in CI ausführen?
Führen Sie für Pull-Anfragen nur Chromium aus, um CI schnell zu halten. Führen Sie für die Zusammenführung von Hauptzweigen alle drei Desktop-Browser aus (Chromium, Firefox, WebKit). Fügen Sie für Release-Kandidaten mobile Ansichtsfenster hinzu. WebKit erkennt Safari-spezifische Layoutfehler; Firefox erkennt Macken, die sowohl Chrome als auch Safari verbergen. Tiering spart CI-Minuten und erkennt gleichzeitig browserübergreifende Fehler vor der Veröffentlichung.
Wie gehe ich mit fehlerhaften Tests um, die durch Zeitprobleme verursacht werden?
Die automatische Wartefunktion von Playwright sollte die meisten Zeitprobleme lösen – verwenden Sie await expect(locator).toBeVisible() anstelle von page.waitForTimeout(). Wenn das automatische Warten nicht ausreicht, verwenden Sie page.waitForResponse() für den netzwerkabhängigen Status, page.waitForLoadState('networkidle') für umfangreiche Seiten oder page.waitForFunction() für benutzerdefinierte Bedingungen. Legen Sie retries: 2 in CI fest, um echte Ausfälle von Infrastrukturfehlern zu unterscheiden.
Sollten E2E-Tests die echte Datenbank oder eine vordefinierte Testdatenbank verwenden?
Verwenden Sie für E2E-Tests eine dedizierte Staging-Umgebung mit einer vordefinierten Testdatenbank. Führen Sie niemals E2E-Tests für die Produktion durch. Erstellen Sie für CI einen Docker Compose-Stack mit einer neuen Datenbank, die mit Testdaten gesät ist. Erstellen und bereinigen Sie Testdaten über API-Aufrufe in beforeAll/afterAll-Hooks, anstatt sich auf vorab festgelegte Daten zu verlassen, die abweichen können.
Wie teste ich Dateidownloads in Playwright?
Verwenden Sie das page.waitForDownload()-Muster: const [download] = await Promise.all([page.waitForEvent('download'), page.click('[data-testid="download-button"]')]); const path = await download.path(); expect(path).toBeTruthy();. Sie können auch download.suggestedFilename() überprüfen und den Dateiinhalt zur Validierung lesen.
Nächste Schritte
Playwright-E2E-Tests sind das letzte Sicherheitsnetz, bevor Benutzer Ihre Bewerbung sehen. Bei richtiger Investition – mit Seitenobjekten, Daten-Test-ID-Selektoren, Authentifizierungs-Fixtures und CI-Integration – erkennen sie, was Unit-Tests übersehen, und geben Ihnen die Sicherheit, sie freitags bereitzustellen.
ECOSIRE verwaltet 29 Playwright E2E-Testdateien, die mehr als 416 Testfälle auf öffentlichen Seiten, Administrator-Workflows und Zugänglichkeit für alle 11 Gebietsschemas abdecken. [Entdecken Sie unsere Frontend-Engineering-Dienste] (/services), um zu erfahren, wie wir qualitätsgesteuerte Next.js-Anwendungen erstellen.
Geschrieben von
ECOSIRE Research and Development Team
Entwicklung von Enterprise-Digitalprodukten bei ECOSIRE. Einblicke in Odoo-Integrationen, E-Commerce-Automatisierung und KI-gestützte Geschäftslösungen.
Verwandte Artikel
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.