Why Playwright for Node.js APIs
Playwright isn't just for UIs — its request API lets you test your REST or GraphQL API with full HTTP context, including cookies, auth headers, and multi-step flows. And for full-stack Node.js apps (Next.js, Remix), it tests the complete user journey end to end.
Setup
npm install -D @playwright/test
npx playwright install chromium
# playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './tests/e2e',
fullyParallel: true,
retries: process.env.CI ? 2 : 0,
use: {
baseURL: process.env.BASE_URL || 'http://localhost:3000',
trace: 'on-first-retry',
},
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
});Testing a REST API
import { test, expect } from '@playwright/test';
test.describe('Projects API', () => {
let authToken: string;
test.beforeAll(async ({ request }) => {
const res = await request.post('/auth/login', {
data: { email: '[email protected]', password: 'testpass' }
});
const body = await res.json();
authToken = body.token;
});
test('creates a project', async ({ request }) => {
const res = await request.post('/projects', {
headers: { Authorization: `Bearer ${authToken}` },
data: { name: 'My Project', repo: 'org/repo' },
});
expect(res.status()).toBe(201);
const project = await res.json();
expect(project).toMatchObject({ name: 'My Project', status: 'active' });
});
});The page object pattern
Avoid writing raw page.click('.css-selector') in tests. Abstract page interactions into a class. When the UI changes, you update one place.
// tests/e2e/pages/login-page.ts
export class LoginPage {
constructor(private readonly page: Page) {}
async goto() { await this.page.goto('/login'); }
async login(email: string, password: string) {
await this.page.getByLabel('Email').fill(email);
await this.page.getByLabel('Password').fill(password);
await this.page.getByRole('button', { name: 'Sign in' }).click();
await this.page.waitForURL('/dashboard');
}
}
// In a test:
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('[email protected]', 'password');Test isolation
Each test should start with a clean state. In a Node.js API context, this means either resetting the test database between tests or using unique data per test run. Use test.beforeEach to seed data and test.afterEach to clean it up.
test.beforeEach(async () => {
await db.truncate(['projects', 'deployments']);
await db.seed.user({ email: '[email protected]' });
});
Avoiding flakiness
- Use
getByRoleandgetByLabelover CSS selectors — they're more resilient to HTML changes - Wait for UI state, not arbitrary timeouts (
waitForResponse,waitForURL, notpage.waitForTimeout) - Set
retries: 2in CI to handle transient flakiness, but fix persistent flakes - Run tests against a real server with a test database, not mocks
CI integration
- name: Run E2E tests
run: npx playwright test
env:
BASE_URL: http://localhost:3000
DATABASE_URL: postgresql://test:test@localhost:5432/testdb
- name: Upload test artifacts
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report/