Back to blog
testingvitestplaywrightci-cdquality-assurancemulti-tenanttypescript

Testing Strategy for Your SaaS: From Unit Tests to End-to-End with Vitest and Playwright

By SaaS Masters17 maart 202610 min read
Testing Strategy for Your SaaS: From Unit Tests to End-to-End with Vitest and Playwright

Why testing makes the difference between growth and chaos

Your SaaS is running, customers are flowing in, and your team pushes new features daily. But with every deploy, the anxiety grows: is this going to break something? Without a solid testing strategy, every release is a gamble. And in SaaS — where downtime directly costs revenue — you can't afford that.

In this article, we'll build a complete testing strategy from unit tests to end-to-end tests, with concrete examples and tooling you can implement tomorrow.

The testing pyramid for SaaS

The classic testing pyramid applies to SaaS too, but with a few nuances:

        ╱╲
       ╱ E2E ╲         → Critical user flows (payment, onboarding)
      ╱────────╲
     ╱Integration╲      → API endpoints, database queries, external services
    ╱──────────────╲
   ╱   Unit Tests    ╲   → Business logic, helpers, validation
  ╱────────────────────╲

The rule of thumb: 70% unit tests, 20% integration tests, 10% E2E tests. But for SaaS this often shifts toward more integration tests, because you work extensively with databases, APIs, and external services.

Unit tests: protecting your business logic

Unit tests are fast, isolated, and cheap. They test individual functions without external dependencies.

What should you unit test in a SaaS?

  • Pricing calculations — errors here directly cost money
  • Permission logic — who can see and do what?
  • Data validation — input sanitization, schema checks
  • Utility functions — date formatting, slug generation, etc.

Example: testing pricing logic

// pricing.ts
export function calculateMonthlyPrice(
  basePlan: 'starter' | 'pro' | 'enterprise',
  seats: number,
  addOns: string[] = []
): number {
  const basePrices = { starter: 29, pro: 79, enterprise: 199 };
  const seatPrice = basePlan === 'starter' ? 10 : basePlan === 'pro' ? 15 : 25;
  const addOnPrices: Record<string, number> = {
    'advanced-analytics': 20,
    'priority-support': 50,
    'custom-branding': 30,
  };

  const base = basePrices[basePlan];
  const seatsTotal = Math.max(0, seats - 1) * seatPrice; // first seat free
  const addOnsTotal = addOns.reduce((sum, a) => sum + (addOnPrices[a] || 0), 0);

  return base + seatsTotal + addOnsTotal;
}
// pricing.test.ts
import { describe, it, expect } from 'vitest';
import { calculateMonthlyPrice } from './pricing';

describe('calculateMonthlyPrice', () => {
  it('calculates base price for a starter with 1 seat', () => {
    expect(calculateMonthlyPrice('starter', 1)).toBe(29);
  });

  it('correctly calculates extra seats', () => {
    expect(calculateMonthlyPrice('pro', 5)).toBe(79 + 4 * 15); // 139
  });

  it('correctly sums add-ons', () => {
    expect(calculateMonthlyPrice('starter', 1, ['advanced-analytics', 'priority-support'])).toBe(29 + 20 + 50);
  });

  it('ignores unknown add-ons', () => {
    expect(calculateMonthlyPrice('starter', 1, ['non-existent'])).toBe(29);
  });

  it('handles 0 seats correctly', () => {
    expect(calculateMonthlyPrice('pro', 0)).toBe(79);
  });
});

Tooling tip: Vitest over Jest

For modern SaaS projects with Vite or Next.js, Vitest is a better choice than Jest:

  • 20x faster startup through native ESM support
  • Compatible with Jest API (migration is minimal)
  • Built-in TypeScript support without extra config
  • Hot module replacement for watch mode

Integration tests: where the real bugs live

Most SaaS bugs don't live in isolated functions, but in the interaction between components. Integration tests cover this.

What should you integration test?

  • API endpoints — correct responses, error handling, authentication
  • Database queries — complex joins, transactions, edge cases
  • External services — payment providers, email, OAuth
  • Middleware — rate limiting, CORS, tenant isolation

Example: testing an API endpoint with a test database

// api/subscriptions.test.ts
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { createTestApp, createTestUser, cleanupTestDb } from '../test-utils';

describe('POST /api/subscriptions', () => {
  let app: ReturnType<typeof createTestApp>;
  let authToken: string;

  beforeAll(async () => {
    app = createTestApp();
    const user = await createTestUser({ plan: 'starter' });
    authToken = user.token;
  });

  afterAll(async () => {
    await cleanupTestDb();
  });

  it('successfully upgrades from starter to pro', async () => {
    const res = await app.request('/api/subscriptions/upgrade', {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${authToken}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ plan: 'pro' }),
    });

    expect(res.status).toBe(200);
    const data = await res.json();
    expect(data.subscription.plan).toBe('pro');
    expect(data.subscription.prorated).toBeDefined();
  });

  it('rejects downgrade without confirmation', async () => {
    const res = await app.request('/api/subscriptions/downgrade', {
      method: 'POST',
      headers: { Authorization: `Bearer ${authToken}` },
      body: JSON.stringify({ plan: 'starter' }),
    });

    expect(res.status).toBe(400);
    const data = await res.json();
    expect(data.error).toContain('confirmation required');
  });

  it('blocks unauthenticated requests', async () => {
    const res = await app.request('/api/subscriptions/upgrade', {
      method: 'POST',
      body: JSON.stringify({ plan: 'pro' }),
    });

    expect(res.status).toBe(401);
  });
});

Test database strategy

Never use your production database for tests. Here's the approach that works:

// test-utils.ts
import { execSync } from 'child_process';
import { PrismaClient } from '@prisma/client';

const TEST_DB_URL = process.env.TEST_DATABASE_URL
  || 'postgresql://localhost:5432/myapp_test';

export function setupTestDb() {
  execSync('npx prisma db push --force-reset', {
    env: { ...process.env, DATABASE_URL: TEST_DB_URL },
  });
  return new PrismaClient({
    datasources: { db: { url: TEST_DB_URL } },
  });
}

export async function cleanupTestDb() {
  const prisma = new PrismaClient({
    datasources: { db: { url: TEST_DB_URL } },
  });
  await prisma.$transaction([
    prisma.subscription.deleteMany(),
    prisma.user.deleteMany(),
    prisma.tenant.deleteMany(),
  ]);
  await prisma.$disconnect();
}

Mocking external services: MSW is your best friend

Mock Service Worker (MSW) intercepts HTTP requests at the network level, perfect for mocking Stripe, SendGrid, and other services:

import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';

const handlers = [
  http.post('https://api.stripe.com/v1/subscriptions', () => {
    return HttpResponse.json({
      id: 'sub_test_123',
      status: 'active',
      current_period_end: Math.floor(Date.now() / 1000) + 30 * 86400,
    });
  }),

  http.post('https://api.sendgrid.com/v3/mail/send', () => {
    return HttpResponse.json({ message: 'success' }, { status: 202 });
  }),
];

export const mockServer = setupServer(...handlers);

End-to-end tests: guarding the critical paths

E2E tests simulate real user interactions in the browser. They're slow and fragile, so use them sparingly — only for your most critical flows.

The 5 flows you should always E2E test

  1. Registration + onboarding — can a new user sign up and get started?
  2. Payment flow — does checkout, upgrade, and downgrade work?
  3. Core feature flow — the #1 reason customers pay
  4. Multi-tenant isolation — does tenant A never see tenant B's data?
  5. Inviting + collaboration — does team management work?

Example: Playwright E2E test for onboarding

// e2e/onboarding.spec.ts
import { test, expect } from '@playwright/test';

test.describe('Onboarding flow', () => {
  test('new user can create account and set up workspace', async ({ page }) => {
    await page.goto('/signup');
    await page.fill('[name="email"]', 'test@example.com');
    await page.fill('[name="password"]', 'SecureP@ss123');
    await page.fill('[name="company"]', 'Test Inc');
    await page.click('button[type="submit"]');

    await expect(page).toHaveURL('/onboarding/workspace');

    await page.fill('[name="workspaceName"]', 'My Workspace');
    await page.selectOption('[name="teamSize"]', '2-10');
    await page.click('text=Next');

    await expect(page).toHaveURL('/onboarding/plan');
    await page.click('[data-plan="pro"]');
    await page.click('text=Start free trial');

    await expect(page).toHaveURL('/dashboard');
    await expect(page.locator('h1')).toContainText('Welcome');
  });

  test('tenant isolation: user only sees own data', async ({ browser }) => {
    const contextA = await browser.newContext();
    const contextB = await browser.newContext();
    const pageA = await contextA.newPage();
    const pageB = await contextB.newPage();

    await pageA.goto('/login');
    await pageA.fill('[name="email"]', 'admin@tenant-a.com');
    await pageA.fill('[name="password"]', 'password');
    await pageA.click('button[type="submit"]');

    await pageB.goto('/login');
    await pageB.fill('[name="email"]', 'admin@tenant-b.com');
    await pageB.fill('[name="password"]', 'password');
    await pageB.click('button[type="submit"]');

    await pageA.goto('/projects');
    const projectsA = await pageA.locator('[data-testid="project-card"]').count();

    await pageB.goto('/projects');
    const projectsB = await pageB.locator('[data-testid="project-card"]').count();

    expect(projectsA).not.toBe(projectsB);

    await contextA.close();
    await contextB.close();
  });
});

CI/CD integration: automating tests

Tests are worthless if they don't run automatically. Here's a GitHub Actions workflow that covers the entire pyramid:

# .github/workflows/test.yml
name: Test Suite
on: [push, pull_request]

jobs:
  unit-and-integration:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_DB: myapp_test
          POSTGRES_PASSWORD: test
        ports: ['5432:5432']
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
      redis:
        image: redis:7
        ports: ['6379:6379']
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: 'pnpm'
      - run: pnpm install --frozen-lockfile
      - run: pnpm prisma db push
        env:
          DATABASE_URL: postgresql://postgres:test@localhost:5432/myapp_test
      - run: pnpm vitest run --reporter=verbose
        env:
          DATABASE_URL: postgresql://postgres:test@localhost:5432/myapp_test
          REDIS_URL: redis://localhost:6379

  e2e:
    runs-on: ubuntu-latest
    needs: unit-and-integration
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: 'pnpm'
      - run: pnpm install --frozen-lockfile
      - run: pnpm exec playwright install --with-deps chromium
      - run: pnpm exec playwright test
        env:
          BASE_URL: http://localhost:3000
          TEST_MODE: true

Testing strategy for multi-tenant SaaS

Multi-tenancy adds an extra layer of complexity. Here are the specific tests you need:

1. Data isolation tests

describe('Tenant data isolation', () => {
  it('query filter always includes tenantId', async () => {
    const tenantA = await createTestTenant('Tenant A');
    const tenantB = await createTestTenant('Tenant B');

    await db.project.create({
      data: { name: 'Secret Project', tenantId: tenantA.id },
    });

    const projects = await db.project.findMany({
      where: { tenantId: tenantB.id },
    });

    expect(projects).toHaveLength(0);
  });
});

2. Row Level Security (RLS) testing

If you use PostgreSQL RLS, test that your policies work correctly:

it('RLS prevents cross-tenant access at database level', async () => {
  await db.$executeRaw`SELECT set_config('app.tenant_id', ${tenantB.id}, true)`;

  const result = await db.$queryRaw`SELECT * FROM projects WHERE id = ${tenantAProject.id}`;

  expect(result).toHaveLength(0);
});

Test coverage: target numbers for SaaS

100% coverage is a myth. Focus on the right coverage:

CategoryTargetWhy
Pricing/billing95%+Errors = direct revenue loss
Auth/permissions90%+Errors = security breach
Core business logic85%+Errors = unhappy customers
API endpoints80%+Errors = integration problems
UI components60-70%Snapshot tests for regression
Utility functions90%+Easy to test, so just do it

Measuring coverage with Vitest

// vitest.config.ts
export default defineConfig({
  test: {
    coverage: {
      provider: 'v8',
      reporter: ['text', 'html', 'lcov'],
      thresholds: {
        branches: 80,
        functions: 80,
        lines: 80,
        statements: 80,
      },
      include: ['src/**/*.ts'],
      exclude: ['src/**/*.test.ts', 'src/**/*.d.ts'],
    },
  },
});

Common mistakes (and how to avoid them)

❌ Mistake 1: Only testing the happy path

Also test error scenarios: expired tokens, network timeouts, invalid input, concurrency conflicts.

❌ Mistake 2: Tests that depend on order

Every test should be able to run independently. Use beforeEach for setup, not the output of previous tests.

❌ Mistake 3: Mocking too much

If you mock everything, you're only testing your mocks. Use real databases in integration tests and only mock external services.

❌ Mistake 4: Ignoring flaky tests

A flaky test is worse than no test — it undermines trust in your entire suite. Fix or remove them immediately.

❌ Mistake 5: No tests for migrations

Test that your database migrations work both up and down, especially for schema changes that affect existing data.

Practical action plan: from zero to solid testing strategy

Week 1-2: Foundation

  • Install Vitest + Playwright
  • Configure test database (Docker Compose)
  • Write tests for pricing and auth logic

Week 3-4: Integration tests

  • Test all API endpoints (happy + error paths)
  • Set up MSW for external services
  • Add database isolation tests

Week 5-6: E2E + CI

  • Write E2E tests for top 5 flows
  • Configure GitHub Actions pipeline
  • Set coverage thresholds

Week 7+: Maintenance

  • Every new feature = ship with tests
  • Weekly flaky test review
  • Monthly coverage report evaluation

Conclusion

A solid testing strategy isn't a luxury — it's a requirement for any SaaS that wants to grow seriously. Start with unit tests for your critical business logic, build integration tests for your APIs and database, and cover your most important user flows with E2E tests.

The goal isn't 100% coverage, but 100% confidence that you can deploy safely. And that confidence? It pays for itself many times over in speed, quality, and peace of mind.


Want help setting up a testing strategy for your SaaS? At SaaS Masters, we help teams ship faster and safer. Get in touch for a free consultation.