Testing Playwright Applications: From Unit Tests to Integration
Playwright has rapidly become one of the most popular end-to-end testing frameworks for modern web applications. However, a robust testing strategy for Playwright-based projects goes beyond simply writing a few browser automation scripts. To build confidence in your application, you need a layered approach that spans unit tests, component tests, and full integration tests. This tutorial walks you through the complete testing pyramid for Playwright applications, with practical examples you can apply immediately.
What Is Playwright Testing?
Playwright is a Node.js library developed by Microsoft that enables reliable browser automation across Chromium, Firefox, and WebKit. While it is best known for end-to-end (E2E) testing, the Playwright ecosystem also supports unit testing, component testing, and API testing through its built-in test runner, @playwright/test. The test runner provides fixtures, assertions, parallel execution, and HTML reporting out of the box.
A complete testing strategy for a Playwright application typically includes three layers:
- Unit tests: Test individual functions, utilities, or modules in isolation.
- Component tests: Test UI components in isolation using Playwright Component Testing.
- Integration / E2E tests: Test full user flows through the browser against a running application.
Why a Layered Testing Strategy Matters
Relying solely on E2E tests creates a slow, brittle test suite. E2E tests exercise the entire stack, which means they are more likely to fail due to unrelated issues like network latency or database state. By distributing tests across the pyramid, you gain faster feedback, easier debugging, and lower maintenance costs. Unit tests catch logic errors in milliseconds, component tests validate UI behavior without spinning up a full server, and integration tests confirm that everything works together from the user's perspective.
Setting Up Your Project
Start by initializing a new project and installing the Playwright test runner. The following commands assume a Node.js environment with npm.
mkdir playwright-testing-demo
cd playwright-testing-demo
npm init -y
npm install -D @playwright/test
npx playwright install
Next, create a configuration file at the root of your project. This file defines test directories, browser targets, and reporting options.
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
timeout: 30000,
expect: { timeout: 5000 },
fullyParallel: true,
reporter: [['html'], ['list']],
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: true,
timeout: 60000,
},
});
The webServer property is particularly useful for integration tests because it automatically starts your application before tests run and stops it afterward.
Writing Unit Tests
Unit tests validate the smallest pieces of your application logic. The Playwright test runner can handle unit tests just as well as browser tests. The key difference is that unit tests do not use the page fixture. Instead, you import and call functions directly.
Suppose you have a utility module that calculates the total price of items in a shopping cart.
// src/utils/cart.ts
export interface CartItem {
name: string;
price: number;
quantity: number;
}
export function calculateTotal(items: CartItem[]): number {
return items.reduce((sum, item) => sum + item.price * item.quantity, 0);
}
export function applyDiscount(total: number, discountPercent: number): number {
if (discountPercent < 0 || discountPercent > 100) {
throw new Error('Discount must be between 0 and 100');
}
return Number((total * (1 - discountPercent / 100)).toFixed(2));
}
You can write unit tests for these functions in a dedicated test file.
// tests/unit/cart.spec.ts
import { test, expect } from '@playwright/test';
import { calculateTotal, applyDiscount, CartItem } from '../../src/utils/cart';
test.describe('Cart utilities', () => {
const items: CartItem[] = [
{ name: 'Widget', price: 10.0, quantity: 2 },
{ name: 'Gadget', price: 25.5, quantity: 1 },
];
test('calculateTotal returns correct sum', () => {
expect(calculateTotal(items)).toBe(45.5);
});
test('calculateTotal handles empty cart', () => {
expect(calculateTotal([])).toBe(0);
});
test('applyDiscount reduces total by percentage', () => {
expect(applyDiscount(100, 20)).toBe(80);
});
test('applyDiscount throws on invalid percentage', () => {
expect(() => applyDiscount(100, 150)).toThrow();
});
});
Run only the unit tests by filtering with a directory path:
npx playwright test tests/unit
Writing Component Tests
Component testing sits between unit and E2E tests. Playwright Component Testing mounts a single component in an isolated environment and interacts with it using the same browser APIs you use in E2E tests. This is faster than full integration testing because you do not need to navigate through the entire application.
To set up component testing for a React project, install the appropriate package:
npm install -D @playwright/experimental-ct-react
Create a component test config file:
// playwright-ct.config.ts
import { defineConfig, devices } from '@playwright/experimental-ct-react';
export default defineConfig({
testDir: './tests/component',
use: {
ctPort: 3100,
trace: 'on-first-retry',
},
});
Now consider a simple Counter component:
// src/components/Counter.tsx
import { useState } from 'react';
export function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p data-testid="count">{count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
<button onClick={() => setCount(count - 1)}>Decrement</button>
</div>
);
}
Write a component test that mounts the component and verifies its behavior:
// tests/component/Counter.spec.tsx
import { test, expect } from '@playwright/experimental-ct-react';
import { Counter } from '../../src/components/Counter';
test('counter increments and decrements', async ({ mount }) => {
const component = await mount(<Counter />);
await expect(component.getByTestId('count')).toHaveText('0');
await component.getByText('Increment').click();
await component.getByText('Increment').click();
await expect(component.getByTestId('count')).toHaveText('2');
await component.getByText('Decrement').click();
await expect(component.getByTestId('count')).toHaveText('1');
});
Component tests are ideal for validating interactive UI logic, form validation, and rendering edge cases without the overhead of a full application server.
Writing Integration and E2E Tests
Integration tests, often called E2E tests in the Playwright context, simulate real user journeys through your application. These tests launch a browser, navigate to pages, and interact with the DOM as a user would. They are the most powerful tests in your suite but also the slowest, so they should focus on critical user flows.
Assume your application has a login page and a dashboard. The following test verifies the full authentication flow:
// tests/integration/auth.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Authentication flow', () => {
test('user can log in and view dashboard', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('user@example.com');
await page.getByLabel('Password').fill('securePassword123');
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page).toHaveURL(/.*dashboard/);
await expect(page.getByRole('heading', { name: 'Welcome' })).toBeVisible();
});
test('invalid credentials show error message', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('user@example.com');
await page.getByLabel('Password').fill('wrongPassword');
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page.getByText('Invalid credentials')).toBeVisible();
await expect(page).toHaveURL(/.*login/);
});
});
For tests that require authenticated state across multiple pages, use Playwright's storage state feature. This lets you log in once and reuse the session across tests, dramatically reducing execution time.
// tests/auth.setup.ts
import { test as setup, expect } from '@playwright/test';
setup('authenticate', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('user@example.com');
await page.getByLabel('Password').fill('securePassword123');
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page).toHaveURL(/.*dashboard/);
await page.context().storageState({ path: 'tests/.auth/user.json' });
});
Update your config to run the setup as a dependency:
// playwright.config.ts (projects section)
projects: [
{
name: 'setup',
testMatch: /.*\.setup\.ts/,
},
{
name: 'chromium',
dependencies: ['setup'],
use: {
...devices['Desktop Chrome'],
storageState: 'tests/.auth/user.json',
},
},
],
Testing APIs with Playwright
Playwright can also test backend APIs using the request fixture. This is useful for integration testing your API layer independently of the UI.
// tests/integration/api.spec.ts
import { test, expect } from '@playwright/test';
test('GET /api/products returns list', async ({ request }) => {
const response = await request.get('/api/products');
expect(response.ok()).toBeTruthy();
const products = await response.json();
expect(Array.isArray(products)).toBeTruthy();
expect(products.length).toBeGreaterThan(0);
});
test('POST /api/products creates a product', async ({ request }) => {
const response = await request.post('/api/products', {
data: {
name: 'Test Product',
price: 19.99,
},
});
expect(response.status()).toBe(201);
const created = await response.json();
expect(created.name).toBe('Test Product');
expect(created.id).toBeTruthy();
});
Best Practices
- Prefer user-facing attributes: Use
getByRole,getByLabel, andgetByTextinstead of brittle CSS selectors. This makes tests resilient to markup changes. - Avoid testing implementation details: Do not assert on class names or internal state. Focus on what the user sees and does.
- Use locators over page selectors: Locators are lazy and always re-query the DOM, reducing flakiness from timing issues.
- Keep tests independent: Each test should set up and tear down its own state. Avoid dependencies between tests so they can run in parallel.
- Use fixtures for shared setup: Playwright fixtures provide a clean way to share setup logic without global state pollution.
- Run tests in CI: Integrate Playwright into your CI pipeline. Use the
--shardoption to distribute tests across multiple CI runners for faster feedback. - Enable tracing for debugging: Set
trace: 'on-first-retry'so you get a full trace when a test fails, including screenshots, DOM snapshots, and network logs. - Mock external services in integration tests: Use
page.route()to intercept and mock third-party API calls, keeping tests deterministic.
Here is an example of mocking an external API response:
test('dashboard shows mocked data', async ({ page }) => {
await page.route('**/api/external/stats', (route) => {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ users: 42, revenue: 9900 }),
});
});
await page.goto('/dashboard');
await expect(page.getByText('42 users')).toBeVisible();
});
Running and Organizing Tests
Organize your test files in a way that mirrors the testing pyramid. A recommended directory structure looks like this:
tests/
unit/
cart.spec.ts
validation.spec.ts
component/
Counter.spec.tsx
LoginForm.spec.tsx
integration/
auth.spec.ts
api.spec.ts
checkout.spec.ts
auth.setup.ts
Use npm scripts to run specific test layers:
{
"scripts": {
"test:unit": "playwright test tests/unit",
"test:component": "playwright test --config=playwright-ct.config.ts",
"test:e2e": "playwright test tests/integration",
"test": "playwright test",
"test:report": "playwright show-report"
}
}
Conclusion
Testing Playwright applications effectively requires a deliberate, layered approach. Unit tests give you fast, targeted feedback on business logic. Component tests validate UI behavior in isolation without the cost of a full browser session. Integration and E2E tests confirm that your entire system works together from the user's perspective. By combining all three layers with Playwright's powerful test runner, fixtures, and tracing capabilities, you can build a test suite that is fast, reliable, and maintainable. Start with unit tests for your core logic, add component tests for interactive UI elements, and reserve integration tests for critical user journeys. This balanced strategy will give you the confidence to ship features quickly while keeping regressions to a minimum.