All posts
TestingMay 18, 2026·8 min read

End-to-End Testing with Playwright

Writing reliable E2E tests for Node.js APIs and web apps. Page objects, test isolation, CI integration, and how to avoid the flakiness that makes most E2E suites worthless.

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 getByRole and getByLabel over CSS selectors — they're more resilient to HTML changes
  • Wait for UI state, not arbitrary timeouts (waitForResponse, waitForURL, not page.waitForTimeout)
  • Set retries: 2 in 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/

Ready to put this into practice?

Deploy your Node.js app to production in minutes — zero YAML, automatic CI/CD, and HTTPS included.