End-to-End Testing Your SaaS with Playwright: A Comprehensive Guide

This comprehensive article teaches end-to-end testing using Playwright, based on real-world examples from a Next.js SaaS application. You'll learn industry best practices, test architecture patterns, and practical implementation strategies.

As a SaaS founder or developer, ensuring your application works flawlessly is crucial for user satisfaction and retention. In this comprehensive guide, we'll explore how to implement robust end-to-end testing using Playwright, focusing on best practices and real-world examples from a production SaaS application.

Why SaaS Businesses Need End-to-End Testing

Before diving into the technical details, let's address why testing is crucial for SaaS businesses, especially for micro-SaaS founders and solopreneurs:

  1. Customer Trust and Retention: In the SaaS world, a single bug in critical flows like authentication or billing can lead to immediate customer churn. Automated testing helps catch these issues before they reach production.
  2. Reduced Manual Testing Time: As a solopreneur, your time is precious. Automated tests free you to focus on building features rather than repeatedly testing existing functionality.
  3. Confident Deployments: With a comprehensive test suite, you can deploy updates more frequently without fear of breaking existing functionality.
  4. Lower Support Burden: Catching bugs before they reach production means fewer support tickets to handle, which is especially important for small teams.

Why Choose Playwright?

Playwright has emerged as a leading choice for testing modern web applications, especially SaaS products, for several compelling reasons:

  1. Auto-wait Capabilities: Playwright automatically waits for elements to be ready before acting on them, reducing flaky tests and explicit waits.
  2. Cross-browser Testing: Test your application across Chromium, Firefox, and WebKit with the same code.
  3. Modern Web App Support: Built-in support for modern web features like shadow DOM and web components.
  4. Network Interception: Powerful API for mocking API responses and testing error states.
  5. Isolated Browser Contexts: Each test runs in isolation, preventing state interference between tests.

Getting Started with Playwright

Installation

To get started with Playwright, you'll need Node.js installed on your system. Then, you can set up Playwright in your project:

# Create a new directory for your tests (if needed)
mkdir e2e-tests
cd e2e-tests
# Initialize a new npm project
npm init -y
# Install Playwright and its dependencies
npm init playwright@latest
# Or if you prefer to install manually:
npm install -D @playwright/test
# Install browser binaries
npx playwright install

The npm init playwright@latest command will:

  1. Add Playwright dependencies to your package.json
  2. Create initial configuration and example test files
  3. Add example GitHub Actions workflow
  4. Install browser binaries for all supported browsers

Project Structure

After installation, your project will have this basic structure:

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

Adding Scripts to package.json

Add these useful scripts to your package.json:

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

These scripts allow you to:

  • Run tests with npm test
  • Run tests with UI mode using npm run test:ui
  • View test reports with npm run report

Setting Up Playwright for Your SaaS

Let's look at how to set up Playwright effectively. Here's a production-ready configuration:

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

Writing Effective Tests: Best Practices

1. Page Object Pattern

The Page Object Pattern is crucial for maintaining clean and maintainable tests. Here's how to implement it effectively:

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"]');
}
}

This pattern:

  • Encapsulates page-specific logic
  • Makes tests more readable
  • Reduces code duplication
  • Makes maintenance easier when UI changes

2. Writing Robust Selectors

One of the most critical aspects of reliable tests is using robust selectors. Here are the best practices:

// DON'T: Fragile selectors
await page.click('.submit-button');
await page.fill('#email', email);
// DO: Use data-test attributes
await page.click('[data-test="submit-button"]');
await page.fill('[data-test="email-input"]', email);

Selector best practices:

  • Use dedicated test attributes (data-test)
  • Avoid relying on CSS classes that might change
  • Don't use text content for critical selectors
  • Keep selectors as simple as possible

3. Grouping Expectations

Playwright provides a powerful feature for grouping expectations that should be retried together. This is especially useful for asynchronous operations:

async updateEmail(email: string) {
await expect(async () => {
// Fill the form
await this.page.fill('[data-test="email-input"]', email);
// Click submit
const click = this.page.click('[data-test="submit-button"]');
// Wait for and verify the response
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();
}

This pattern:

  • Retries the entire block until it passes
  • Handles race conditions gracefully
  • Makes tests more reliable
  • Reduces false negatives

4. Test Isolation and State Management

Test isolation is crucial for reliable test suites. Here's how to achieve it:

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(); // 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);
});
});

Key principles:

  • Each test should create its own data
  • Never rely on data from other tests
  • Clean up after tests when necessary
  • Use beforeAll/beforeEach hooks wisely

5. Handling Email Verification Flows

Many SaaS applications require email verification. In the case of Makerkit, we use InBucket to send and receive emails.

Makerkit will use the InBucket API to in the testing environment so that we can test the email verification flow without sending real emails.

export class Mailbox {
async visitMailbox(email: string) {
// Connect to test email service
const mailbox = email.split('@')[0];
const json = await this.getInviteEmail(mailbox);
// Extract and visit confirmation link
const html = json.body.html;
const linkHref = parse(html).querySelector('a')?.getAttribute('href');
return this.page.goto(linkHref);
}
}

This approach:

  • Uses a test email service
  • Handles async email delivery
  • Extracts and follows confirmation links
  • Maintains test isolation

In your case, you can try to use a similar approach based on the email testing service you're using.

Testing Authentication Flows

Authentication is critical for SaaS applications, and it's one of those flows that you should be testing thoroughly.

In general, it's recommended to only test the authentication flow in the context of specifically testing the signup and login flows.

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 most other flows, it's instead recommended to simply sign in or sign up users programmatically so to reduce the amount of time required to setup a test.

The faster the tests, the more time you have to focus on building features.

CI/CD Integration

For effective CI/CD integration:

  1. Configure Retries:
retries: process.env.CI ? 3 : 1,
workers: process.env.CI ? 1 : undefined,
  1. Handle Screenshots and Traces:
screenshot: 'only-on-failure',
trace: 'on-first-retry',
  1. Set Appropriate Timeouts:
timeout: 60 * 1000,
expect: {
timeout: 10 * 1000,
}

Github Actions CI/CD Integration

If you want to run your tests automatically on every push, you can use Github Actions.

Below is an example of a Github Actions workflow that runs Playwright tests on every push to the main branch:

name: Workflow
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
test:
name: ⚫️ Test
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's Version
run: |
PLAYWRIGHT_VERSION=$(pnpm ls @playwright/test --filter web-e2e | grep @playwright | sed 's/.*@//')
echo "Playwright's Version: $PLAYWRIGHT_VERSION"
echo "PLAYWRIGHT_VERSION=$PLAYWRIGHT_VERSION" >> $GITHUB_ENV
- name: Cache Playwright Browsers for Playwright's Version
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: Run Next.js Production Build (test env)
run: pnpm --filter web build:test
- name: Run Next.js Server
run: pnpm --filter web start:test &
- name: Run Playwright tests
run: |
pnpm run test
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: apps/e2e/playwright-report/
retention-days: 7

The Actions above work well for Next.js SaaS projects, but you may need to adjust them for other frameworks.

We create a production build of the application before running the tests, and we start the server in the background. This ensures the tests are running against a production-ready build that most resembles how the application will be deployed in production.

If you need to start a server or run other commands before running the tests, you can add them to the run step before the test step.

Common Pitfalls and Solutions

  1. Flaky Tests
    • Use stable selectors
    • Implement proper waiting strategies
    • Group related assertions
    • Handle race conditions
  2. State Management
    • Create fresh data for each test
    • Clean up after tests
    • Use isolated browser contexts
    • Don't share state between tests
  3. Performance
    • Run tests in parallel when possible
    • Use appropriate timeouts
    • Implement efficient setup/teardown

Conclusion

End-to-end testing with Playwright is an invaluable tool for SaaS businesses of all sizes. By following these best practices and patterns, you can build a reliable test suite that:

  • Catches bugs before they reach production
  • Reduces manual testing time
  • Enables confident deployments
  • Improves overall product quality

The investment in setting up proper testing pays dividends in reduced support burden, improved user satisfaction, and more time to focus on building features rather than fixing bugs.

The goal isn't to have 100% test coverage, but to cover critical paths that affect your users' ability to use and pay for your service. Start with the most important flows - authentication, payment, and core features - and expand your test coverage as your application grows.

Even simply testing the most important flows can save you a lot of time and effort, and ultimately, make you sleep better at night.

Makerkit - A tested SaaS Starter Kit ✨

This article is based on my experienced writing tests for Makerkit, a trusted SaaS Starter Kit. If you're looking for a Next.js SaaS Starter Kit, Makerkit can help you build and launch your next SaaS in days.