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:
- 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.
- 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.
- Confident Deployments: With a comprehensive test suite, you can deploy updates more frequently without fear of breaking existing functionality.
- 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:
- Auto-wait Capabilities: Playwright automatically waits for elements to be ready before acting on them, reducing flaky tests and explicit waits.
- Cross-browser Testing: Test your application across Chromium, Firefox, and WebKit with the same code.
- Modern Web App Support: Built-in support for modern web features like shadow DOM and web components.
- Network Interception: Powerful API for mocking API responses and testing error states.
- 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-testscd e2e-tests# Initialize a new npm projectnpm init -y# Install Playwright and its dependenciesnpm init playwright@latest# Or if you prefer to install manually:npm install -D @playwright/test# Install browser binariesnpx playwright install
The npm init playwright@latest
command will:
- Add Playwright dependencies to your package.json
- Create initial configuration and example test files
- Add example GitHub Actions workflow
- 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.tsexport 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 selectorsawait page.click('.submit-button');await page.fill('#email', email);// DO: Use data-test attributesawait 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:
- Configure Retries:
retries: process.env.CI ? 3 : 1,workers: process.env.CI ? 1 : undefined,
- Handle Screenshots and Traces:
screenshot: 'only-on-failure',trace: 'on-first-retry',
- 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: Workflowon: 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
- Flaky Tests
- Use stable selectors
- Implement proper waiting strategies
- Group related assertions
- Handle race conditions
- State Management
- Create fresh data for each test
- Clean up after tests
- Use isolated browser contexts
- Don't share state between tests
- 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.