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:
- Customer trust: A bug in authentication or billing causes immediate churn. Automated tests catch these before production.
- Time savings: As a solo founder or small team, automated tests free you from manual testing.
- Confident deployments: Ship updates frequently without worrying about regressions.
- 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-testscd e2e-tests# Initialize with Playwrightnpm init playwright@latest# Or install manually:npm install -D @playwright/testnpx playwright installThe npm init playwright@latest command will:
- Add Playwright dependencies to package.json
- Create initial configuration and example tests
- Add a GitHub Actions workflow
- Install browser binaries
Project Structure
After installation:
e2e-tests/├── tests/│ └── example.spec.ts├── playwright.config.ts└── package.jsonPackage 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.tsimport { 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 speedretries: 2: Retry failed tests twice to handle transient failuresworkers: 1in 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 setuptrace: '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 changesawait page.click('.submit-button');await page.fill('#email', email);// Stable - dedicated test attributesawait page.click('[data-test="submit-button"]');await page.fill('[data-test="email-input"]', email);Selector rules:
- Use dedicated
data-testattributes - 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 settingsretries: 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 Testson: 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: 7MakerKit'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-testattributes) - 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?
Should I run Playwright tests in parallel?
How do I handle flaky tests?
Should I test through the UI or use API calls?
When should I use Playwright vs Vitest?
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.