·Updated

End-to-End Testing Your SaaS with Playwright

Learn end-to-end testing with Playwright using real-world examples from a Next.js SaaS application. Covers test architecture, Page Object patterns, CI/CD setup, and practical strategies for reliable tests.

E2E testing with Playwright validates your entire user flow, from browser to database, catching integration bugs that unit tests miss. Use Playwright when you need cross-browser testing with auto-wait capabilities and reliable CI integration.

This guide covers the patterns we use in MakerKit's production test suite.

Tested with Playwright 1.50+, Next.js 15/16, and TypeScript 5.x.

What you'll learn:

  • Playwright setup and configuration for SaaS apps
  • Page Object pattern for maintainable tests
  • Handling authentication and email verification flows
  • CI/CD integration with GitHub Actions

Why SaaS Businesses Need E2E Testing

Testing matters for SaaS because:

  1. Customer trust: A bug in authentication or billing causes immediate churn. Automated tests catch these before production.
  2. Time savings: As a solo founder or small team, automated tests free you from manual testing.
  3. Confident deployments: Ship updates frequently without worrying about regressions.
  4. Fewer support tickets: Catch bugs early, handle fewer fires later.

Why Playwright?

Playwright stands out for a few reasons:

  • Auto-wait: Automatically waits for elements before acting, reducing flaky tests
  • Cross-browser: Test across Chromium, Firefox, and WebKit with the same code
  • Modern web support: Built-in handling for shadow DOM and web components
  • Network interception: Mock API responses and test error states
  • Isolated contexts: Each test runs in isolation, no state bleeding between tests

Getting Started with Playwright

Installation

You'll need Node.js installed. Then set up Playwright:

# Create a test directory (if needed)
mkdir e2e-tests
cd e2e-tests
# Initialize with Playwright
npm init playwright@latest
# Or install manually:
npm install -D @playwright/test
npx playwright install

The npm init playwright@latest command will:

  1. Add Playwright dependencies to package.json
  2. Create initial configuration and example tests
  3. Add a GitHub Actions workflow
  4. Install browser binaries

Project Structure

After installation:

e2e-tests/
├── tests/
│ └── example.spec.ts
├── playwright.config.ts
└── package.json

Package Scripts

Add these to your package.json:

{
"scripts": {
"test": "playwright test --max-failures=1",
"test:ui": "playwright test --ui",
"report": "playwright show-report"
}
}

Production-Ready Configuration

Start with this configuration:

// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './tests',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: 2,
workers: process.env.CI ? 1 : undefined,
use: {
baseURL: 'http://localhost:3000',
screenshot: 'only-on-failure',
trace: 'on-first-retry',
},
timeout: 120 * 1000,
expect: {
timeout: 10 * 1000,
}
});

Key settings:

  • fullyParallel: Run tests in parallel for speed
  • retries: 2: Retry failed tests twice to handle transient failures
  • workers: 1 in CI to avoid resource contention (Supabase connection limits can cause issues with parallel runs)
  • timeout: 120 * 1000: 2-minute timeout handles slow database seeding during test setup
  • trace: 'on-first-retry': Capture traces only when debugging failures

Writing Effective Tests

1. Page Object Pattern

The Page Object pattern keeps tests clean and maintainable:

export class AuthPageObject {
constructor(private readonly page: Page) {}
async signIn(params: { email: string; password: string }) {
await this.page.fill('input[name="email"]', params.email);
await this.page.fill('input[name="password"]', params.password);
await this.page.click('button[type="submit"]');
}
}

Why bother? When your button selector changes from submit-btn to submit-button, you fix it in one place instead of 30 test files. Page Objects also make tests read like documentation.

2. Robust Selectors

Selector choice is the difference between reliable and flaky tests:

// Fragile - will break when CSS changes
await page.click('.submit-button');
await page.fill('#email', email);
// Stable - dedicated test attributes
await page.click('[data-test="submit-button"]');
await page.fill('[data-test="email-input"]', email);

Selector rules:

  • Use dedicated data-test attributes
  • Avoid CSS classes (they change for styling reasons)
  • Don't rely on text content for critical selectors
  • Keep selectors simple

3. Grouping Expectations

Playwright can retry entire blocks until they pass. This handles race conditions gracefully:

async updateEmail(email: string) {
await expect(async () => {
await this.page.fill('[data-test="email-input"]', email);
const click = this.page.click('[data-test="submit-button"]');
const req = await this.page
.waitForResponse(resp => resp.url().includes('auth/user'))
.then(response => {
expect(response.status()).toBe(200);
});
return Promise.all([click, req]);
}).toPass();
}

The toPass() method retries the entire block, which handles timing issues better than individual waits.

4. Test Isolation

Each test should set up its own data:

test.describe('Account Settings', () => {
let page: Page;
let account: AccountPageObject;
test.beforeAll(async ({ browser }) => {
page = await browser.newPage();
account = new AccountPageObject(page);
await account.setup(); // Fresh test data
});
test('user can update their profile name', async () => {
const name = 'John Doe';
await account.updateName(name);
await expect(account.getProfileName()).toHaveText(name);
});
});

Isolation principles:

  • Create fresh data per test
  • Never depend on other tests' data
  • Clean up when necessary
  • Use beforeAll/beforeEach hooks appropriately

5. Email Verification Flows

Many SaaS apps require email verification. In MakerKit, we use Mailpit to capture test emails without sending real ones:

export class Mailbox {
async visitMailbox(email: string) {
const mailbox = email.split('@')[0];
const json = await this.getInviteEmail(mailbox);
const html = json.body.html;
const linkHref = parse(html).querySelector('a')?.getAttribute('href');
return this.page.goto(linkHref);
}
}

The key insight: don't wait a fixed time for emails. Poll the mailbox until the message arrives, then parse and visit the link. Adapt this to whatever email service you're using. For more on local email testing setup, see testing emails with a local email server.

Testing Authentication

Authentication is critical. Test the signup and login flows explicitly:

test.describe('Auth flow', () => {
test('complete signup flow', async ({ page }) => {
const auth = new AuthPageObject(page);
const email = auth.createRandomEmail();
await auth.signUp({
email,
password: 'password',
repeatPassword: 'password',
});
await auth.visitConfirmEmailLink(email);
await page.waitForURL('**/home');
});
});

For other tests, sign in programmatically instead of going through the UI. Faster tests mean faster feedback.

CI/CD Integration

Configuration for CI

// Key CI settings
retries: 2,
workers: process.env.CI ? 1 : undefined,
screenshot: 'only-on-failure',
trace: 'on-first-retry',

GitHub Actions Workflow

Here's a production workflow for Next.js projects:

name: E2E Tests
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
test:
name: Run E2E Tests
timeout-minutes: 15
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 2
- uses: pnpm/action-setup@v4
- uses: actions/cache@v4
with:
path: .turbo
key: ${{ runner.os }}-turbo-${{ github.sha }}
restore-keys: |
${{ runner.os }}-turbo-
- uses: actions/setup-node@v4
with:
node-version: lts/*
cache: 'pnpm'
- name: Install dependencies
run: pnpm install
- name: Store Playwright Version
run: |
PLAYWRIGHT_VERSION=$(pnpm ls @playwright/test --filter web-e2e | grep @playwright | sed 's/.*@//')
echo "PLAYWRIGHT_VERSION=$PLAYWRIGHT_VERSION" >> $GITHUB_ENV
- name: Cache Playwright Browsers
id: cache-playwright-browsers
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: playwright-browsers-${{ env.PLAYWRIGHT_VERSION }}
- name: Install Playwright Browsers
if: steps.cache-playwright-browsers.outputs.cache-hit != 'true'
run: pnpm exec ./apps/e2e/node_modules/.bin/playwright install --with-deps
- name: Build Application
run: pnpm --filter web build:test
- name: Start Server
run: pnpm --filter web start:test &
- name: Run Tests
run: pnpm run test
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: apps/e2e/playwright-report/
retention-days: 7

MakerKit's ~25 tests run in about 3 minutes with this setup. The browser caching step alone saves 30-40 seconds per run. Adjust the filter names and paths for your project structure.

Common Pitfalls

Flaky tests (the bane of E2E testing):

  • Use stable selectors (data-test attributes)
  • Use toPass() for retry blocks
  • Handle race conditions with proper waits
  • We spent two days debugging a flaky test before discovering the selector was matching a loading skeleton, not the actual element. If I had a dollar for every missing await...

State bleeding:

  • Create fresh data per test
  • Use isolated browser contexts
  • Clean up in afterEach/afterAll hooks

Slow tests:

  • Run in parallel where possible
  • Sign in programmatically instead of through UI
  • Don't over-test; focus on critical paths

Quick Recommendation

Playwright E2E testing is best for:

  • SaaS applications with authentication and payment flows
  • Teams wanting cross-browser coverage with one codebase
  • Projects needing reliable CI/CD integration

Skip E2E tests if:

  • You're building a static site with no user interactions
  • You need to test only unit-level logic (use Jest/Vitest instead)

Our pick: Start with Playwright for E2E, pair it with Vitest for unit tests. Cover auth, billing, and core features first. For billing-specific tests, see our guide on testing Stripe with Cypress.

For a quick setup that catches the most critical issues, check out smoke testing your SaaS with Playwright.

Frequently Asked Questions

How many E2E tests should I write?
Focus on critical paths: authentication, payments, and core features. Aim for 10-20 tests covering the flows that, if broken, would prevent users from using or paying for your service. You don't need 100% coverage.
Should I run Playwright tests in parallel?
Locally, yes. In CI, consider running with 1 worker if you're hitting database connection limits (common with Supabase). Parallel tests are faster but can cause flakiness with shared resources.
How do I handle flaky tests?
Use stable selectors (data-test attributes), wrap unreliable operations with toPass() for automatic retries, and ensure each test creates its own data. Most flakiness comes from timing issues or shared state.
Should I test through the UI or use API calls?
Test auth flows through the UI once to verify they work. For other tests, sign in programmatically via API to save time. A test that takes 10 seconds instead of 30 adds up across your suite.
When should I use Playwright vs Vitest?
Use Playwright for user flows that span multiple pages or require browser interaction. Use Vitest for unit tests, utility functions, and testing logic in isolation. Most projects need both.

Conclusion

E2E testing with Playwright catches bugs before users do. The patterns here, Page Objects, stable selectors, grouped expectations, and proper CI setup, will give you a test suite that actually helps rather than slowing you down.

You don't need 100% coverage. Cover the critical paths: authentication, payments, and the features users pay for. MakerKit's test suite catches 2-3 bugs per month that would have reached production. Start there and expand as your app grows.

MakerKit: A Tested SaaS Starter Kit

This guide is based on patterns from MakerKit, a battle-tested SaaS starter. If you're looking for a Next.js SaaS Starter Kit, MakerKit helps you build and launch your next SaaS quickly, with E2E tests included.