Use Playwright's trace viewer to debug failing tests, make direct API calls with the request fixture, test React and Vue components in isolation with Playwright Component Testing.
Playwright is more than a browser automation tool. Its trace viewer turns opaque test failures into fully replayable recordings with DOM snapshots at every step. Its request context lets you make API calls without spinning up a browser. Its component testing mode renders React and Vue components in a real browser — faster than full E2E but more realistic than jsdom. This lesson covers the advanced Playwright capabilities that make debugging faster and testing more comprehensive.
The Playwright Trace Viewer is a visual recording of every action in a test: DOM snapshots before and after each step, network requests and responses, console logs, and screenshots.
1// playwright.config.ts — enable traces
2use: {
3 trace: 'on-first-retry', // capture trace when test is retried
4 // trace: 'on', // always capture (large files)
5 // trace: 'retain-on-failure', // keep only on failure
6},
7
8// Open the HTML report with embedded traces:
9npx playwright show-report
10
11// Or open a specific trace file:
12npx playwright show-trace test-results/home-test/trace.zip
13
14// Generate a trace file manually in a test:
15test('debug this flow', async ({ page }) => {
16 await page.context().tracing.start({
17 screenshots: true,
18 snapshots: true,
19 sources: true,
20 });
21
22 await page.goto('/checkout');
23 await page.getByRole('button', { name: 'Pay Now' }).click();
24 // ... test steps ...
25
26 await page.context().tracing.stop({
27 path: 'test-results/checkout-trace.zip',
28 });
29});
30
31// What the trace viewer shows for each action:
32// 1. Before snapshot — DOM state before the action
33// 2. After snapshot — DOM state after the action
34// 3. Network log — all requests made during the action
35// 4. Console log — JavaScript console output
36// 5. Call log — Playwright's internal call details
37// 6. Time metadata — exact timing of each step
38
39// Use the timeline scrubber to jump to any moment in the test
40// Click a network request to see full request/response headers and body
41// The diff between before/after snapshots reveals exactly what changedPlaywright's request fixture creates an API context for making HTTP calls without a browser — ideal for setup, teardown, and asserting on API state.
1import { test, expect, request } from '@playwright/test';
2
3test('API: creates user and verifies in database', async ({ request }) => {
4 // Make a direct API call
5 const createRes = await request.post('http://localhost:3001/api/users', {
6 data: { name: 'API Test User', email: `api-test-${Date.now()}@example.com`, password: 'SecurePass1!' },
7 });
8
9 expect(createRes.status()).toBe(201);
10 const user = await createRes.json();
11 expect(user.id).toBeDefined();
12
13 // Verify the user exists via a GET call
14 const getRes = await request.get(`http://localhost:3001/api/users/${user.id}`, {
15 headers: { Authorization: `Bearer ${adminToken}` },
16 });
17 expect(getRes.ok()).toBe(true);
18 const fetched = await getRes.json();
19 expect(fetched.email).toBe(user.email);
20});
21
22// Use request in a custom fixture for authenticated API calls:
23export const test = base.extend<{ apiClient: APIRequestContext }>({
24 apiClient: async ({ request }, use) => {
25 // Log in and get token
26 const loginRes = await request.post('/api/auth/login', {
27 data: { email: '[email protected]', password: 'AdminPass1!' },
28 });
29 const { accessToken } = await loginRes.json();
30
31 // Create new context with auth header
32 const authedClient = await request.newContext({
33 extraHTTPHeaders: { Authorization: `Bearer ${accessToken}` },
34 });
35 await use(authedClient);
36 await authedClient.dispose();
37 },
38});
39
40// Combining API and browser in one test:
41test('admin creates course via API, student sees it in browser', async ({ page, request }) => {
42 // Create course via API
43 const course = await request.post('/api/courses', {
44 headers: { Authorization: `Bearer ${adminToken}` },
45 data: { title: 'New API Course', price: 29.99, level: 'BEGINNER', published: true },
46 });
47 const { id } = await course.json();
48
49 // Verify it appears in the browser
50 await page.goto('/courses');
51 await expect(page.getByText('New API Course')).toBeVisible();
52
53 // Cleanup via API
54 await request.delete(`/api/courses/${id}`, {
55 headers: { Authorization: `Bearer ${adminToken}` },
56 });
57});1// Execute JavaScript in the browser context
2test('reads computed CSS property', async ({ page }) => {
3 await page.goto('/courses');
4
5 // Execute in browser, return to Node.js
6 const bgColor = await page.evaluate(() => {
7 const el = document.querySelector('.course-card');
8 return window.getComputedStyle(el!).backgroundColor;
9 });
10 expect(bgColor).toBe('rgb(255, 255, 255)');
11});
12
13// Pass arguments from Node.js to browser:
14test('sets feature flag and verifies UI change', async ({ page }) => {
15 await page.goto('/');
16 await page.evaluate((flagName) => {
17 localStorage.setItem(`feature_${flagName}`, 'true');
18 }, 'ai-assistant'); // 'ai-assistant' is passed as argument
19
20 await page.reload();
21 await expect(page.getByRole('button', { name: 'AI Assistant' })).toBeVisible();
22});
23
24// page.exposeFunction — call Node.js functions from browser:
25test('exposed function receives data from browser', async ({ page }) => {
26 const receivedData: string[] = [];
27
28 // Expose a Node.js function to the browser's window object
29 await page.exposeFunction('trackEvent', (eventName: string) => {
30 receivedData.push(eventName);
31 });
32
33 await page.goto('/');
34 // Browser code calls window.trackEvent('page_view')
35 // ... user interactions ...
36 await page.getByRole('link', { name: 'Courses' }).click();
37
38 expect(receivedData).toContain('page_view');
39 expect(receivedData).toContain('navigation_click');
40});
41
42// Waiting for browser-side conditions:
43await page.waitForFunction(() => {
44 return (window as any).__appLoaded === true;
45}, { timeout: 10_000 });
46
47// Reading from localStorage/sessionStorage:
48const token = await page.evaluate(() => localStorage.getItem('accessToken'));
49expect(token).toBeDefined();Sharding splits a Playwright test suite across multiple CI machines, dramatically reducing wall-clock time.
1# GitHub Actions — run Playwright across 4 shards in parallel
2jobs:
3 playwright:
4 name: Playwright Tests (Shard ${{ matrix.shardIndex }}/${{ matrix.shardTotal }})
5 runs-on: ubuntu-latest
6 strategy:
7 fail-fast: false
8 matrix:
9 shardIndex: [1, 2, 3, 4]
10 shardTotal: [4]
11
12 steps:
13 - uses: actions/checkout@v4
14 - uses: actions/setup-node@v4
15 with: { node-version: '20', cache: 'npm' }
16 - run: npm ci
17 - run: npx playwright install --with-deps chromium
18
19 - name: Run Playwright shard
20 run: npx playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
21
22 - uses: actions/upload-artifact@v4
23 if: always()
24 with:
25 name: playwright-blob-${{ matrix.shardIndex }}
26 path: blob-report/
27 retention-days: 1
28
29 merge-reports:
30 needs: playwright
31 runs-on: ubuntu-latest
32 if: always()
33 steps:
34 - uses: actions/checkout@v4
35 - uses: actions/setup-node@v4
36 with: { node-version: '20', cache: 'npm' }
37 - run: npm ci
38
39 - uses: actions/download-artifact@v4
40 with:
41 path: all-blob-reports
42 pattern: playwright-blob-*
43 merge-multiple: true
44
45 - name: Merge shard reports
46 run: npx playwright merge-reports --reporter html ./all-blob-reports
47
48 - uses: actions/upload-artifact@v4
49 with:
50 name: playwright-html-report
51 path: playwright-report/1// playwright.config.ts for sharding:
2reporter: [
3 process.env['CI'] ? ['blob'] : ['html'], // blob for shards, html for local
4],Playwright Component Testing renders React/Vue/Svelte components directly in a real browser — faster than full page E2E but using a real browser instead of jsdom.
1npm init playwright@latest -- --ct # initialise component testing
2# OR add to existing Playwright setup:
3npm install --save-dev @playwright/experimental-ct-react1// playwright-ct.config.ts
2import { defineConfig } from '@playwright/experimental-ct-react';
3
4export default defineConfig({
5 testDir: './src',
6 testMatch: '**/*.ct.tsx',
7 use: {
8 ctPort: 3100,
9 ctViteConfig: {
10 resolve: { alias: { '@': '/src' } },
11 },
12 },
13});
14
15// src/components/CourseCard.ct.tsx
16import { test, expect } from '@playwright/experimental-ct-react';
17import { CourseCard } from './CourseCard';
18
19const course = {
20 id: '1', title: 'TypeScript Fundamentals',
21 instructor: 'Alice', price: 49.99, level: 'BEGINNER' as const,
22};
23
24test('renders course title and price', async ({ mount }) => {
25 const component = await mount(
26 <CourseCard course={course} onEnrol={() => {}} />
27 );
28
29 await expect(component.getByRole('heading')).toHaveText('TypeScript Fundamentals');
30 await expect(component.getByText('$49.99')).toBeVisible();
31});
32
33test('calls onEnrol with course id when Enrol button is clicked', async ({ mount }) => {
34 let enroledId: string | undefined;
35 const component = await mount(
36 <CourseCard course={course} onEnrol={(id) => { enroledId = id; }} />
37 );
38
39 await component.getByRole('button', { name: 'Enrol Now' }).click();
40 expect(enroledId).toBe('1');
41});
42
43test('component matches visual snapshot', async ({ mount }) => {
44 const component = await mount(<CourseCard course={course} onEnrol={() => {}} />);
45 await expect(component).toHaveScreenshot('course-card.png');
46});A complete advanced test suite combining trace configuration for debugging, API calls for test setup, page.evaluate for feature flags, and shard-ready configuration.
1// playwright.config.ts — advanced configuration
2import { defineConfig, devices } from '@playwright/test';
3
4export default defineConfig({
5 testDir: './e2e',
6 fullyParallel: true,
7 forbidOnly: !!process.env['CI'],
8 retries: process.env['CI'] ? 2 : 0,
9 workers: process.env['CI'] ? 2 : undefined,
10 timeout: 30_000,
11
12 reporter: process.env['CI']
13 ? [['blob'], ['github']]
14 : [['html', { open: 'on-failure' }], ['list']],
15
16 use: {
17 baseURL: process.env['PLAYWRIGHT_BASE_URL'] ?? 'http://localhost:3000',
18 trace: 'on-first-retry',
19 screenshot: 'only-on-failure',
20 video: 'on-first-retry',
21 },
22
23 projects: [
24 { name: 'setup', testMatch: '**/global-setup.spec.ts' },
25 {
26 name: 'chromium',
27 use: { ...devices['Desktop Chrome'] },
28 dependencies: ['setup'],
29 },
30 ],
31
32 globalSetup: './e2e/global-setup.ts',
33});
34
35// e2e/advanced.spec.ts
36import { test, expect } from '@playwright/test';
37
38test.describe('Advanced Playwright Features', () => {
39 test('uses API to seed test data, browser to verify UI', async ({ page, request }) => {
40 // Seed a course via API
41 const adminToken = process.env['TEST_ADMIN_TOKEN'] ?? 'test-admin-token';
42 const createRes = await request.post('/api/courses', {
43 headers: { Authorization: `Bearer ${adminToken}` },
44 data: {
45 title: `Test Course ${Date.now()}`,
46 price: 29.99,
47 level: 'BEGINNER',
48 published: true,
49 description: 'A test course created by the E2E suite',
50 },
51 });
52 expect(createRes.status()).toBe(201);
53 const { id, title } = await createRes.json();
54
55 try {
56 // Verify it appears in the browser
57 await page.goto('/courses');
58 await page.waitForLoadState('networkidle');
59 await expect(page.getByText(title)).toBeVisible({ timeout: 10_000 });
60
61 // Click through to course detail
62 await page.getByText(title).click();
63 await expect(page).toHaveURL(new RegExp(`/courses/${id}`));
64 await expect(page.getByRole('heading', { name: title })).toBeVisible();
65 } finally {
66 // Cleanup via API regardless of test outcome
67 await request.delete(`/api/courses/${id}`, {
68 headers: { Authorization: `Bearer ${adminToken}` },
69 });
70 }
71 });
72
73 test('feature flag via localStorage changes UI', async ({ page }) => {
74 await page.goto('/');
75
76 // Check feature is not visible without flag
77 await expect(page.getByRole('button', { name: 'AI Assistant' })).not.toBeVisible();
78
79 // Set feature flag
80 await page.evaluate(() => {
81 localStorage.setItem('feature_ai-assistant', 'true');
82 });
83 await page.reload();
84
85 // Feature should now be visible
86 await expect(page.getByRole('button', { name: 'AI Assistant' })).toBeVisible();
87 });
88
89 test('trace is captured on retry (verify config)', async ({ page }) => {
90 // This test verifies the trace config is working
91 // In normal use, you read the trace in the HTML report after a failure
92 await page.goto('/');
93 const traceStarted = await page.evaluate(() => {
94 return typeof window !== 'undefined';
95 });
96 expect(traceStarted).toBe(true);
97 });
98
99 test('API rate limiting returns 429 with retry-after header', async ({ request }) => {
100 // Test API behaviour directly without a browser
101 const requests = Array.from({ length: 10 }, () =>
102 request.post('/api/auth/login', {
103 data: { email: '[email protected]', password: 'wrong' },
104 })
105 );
106
107 const responses = await Promise.all(requests);
108 const rateLimited = responses.find(r => r.status() === 429);
109
110 expect(rateLimited).toBeDefined();
111 const headers = rateLimited!.headers();
112 expect(headers['retry-after']).toBeDefined();
113 expect(parseInt(headers['retry-after'])).toBeGreaterThan(0);
114 });
115});The seed-and-verify test pattern uses request for fast API setup and page for browser verification, with a try/finally block ensuring cleanup even when the browser assertion fails. The feature flag test uses page.evaluate() to set localStorage before reload — this pattern tests that the app correctly reads persisted preferences. The rate-limit test uses the request fixture without a browser to fire 10 simultaneous login attempts and verify the API responds with 429 — a pure API contract test that runs in milliseconds.
The next lesson covers handling flaky E2E tests: identifying the root causes of flakiness, applying retry strategies, using test.fixme for known issues, and building a flakiness tracking system.
Your progress is saved in this browser, on this device only.