Terug naar blog
testingvitestplaywrightci-cdquality-assurancemulti-tenanttypescript

Teststrategie voor je SaaS: van unit tests tot end-to-end met Vitest en Playwright

Door SaaS Masters17 maart 202610 min leestijd
Teststrategie voor je SaaS: van unit tests tot end-to-end met Vitest en Playwright

Waarom testen het verschil maakt tussen groei en chaos

Je SaaS draait, klanten stromen binnen, en je team pusht dagelijks nieuwe features. Maar met elke deploy groeit de angst: gaat dit iets breken? Zonder een solide teststrategie wordt elke release een gok. En in SaaS — waar downtime direct omzet kost — kun je je dat niet veroorloven.

In dit artikel bouwen we een complete teststrategie op, van unit tests tot end-to-end tests, met concrete voorbeelden en tooling die je morgen kunt implementeren.

De testpiramide voor SaaS

De klassieke testpiramide geldt ook voor SaaS, maar met een paar nuances:

        ╱╲
       ╱ E2E ╲         → Kritieke user flows (betaling, onboarding)
      ╱────────╲
     ╱ Integratie╲      → API endpoints, database queries, externe services
    ╱──────────────╲
   ╱   Unit Tests    ╲   → Business logic, helpers, validatie
  ╱────────────────────╲

De vuistregel: 70% unit tests, 20% integratietests, 10% E2E tests. Maar voor SaaS verschuift dit vaak naar meer integratietests, omdat je veel met databases, API's en externe diensten werkt.

Unit tests: je business logic beschermen

Unit tests zijn snel, geïsoleerd en goedkoop. Ze testen individuele functies zonder externe afhankelijkheden.

Wat moet je unit-testen in een SaaS?

  • Pricing-berekeningen — fouten hier kosten direct geld
  • Permissie-logica — wie mag wat zien en doen?
  • Data-validatie — input sanitization, schema checks
  • Utility functions — datum-formatting, slug-generatie, etc.

Voorbeeld: pricing-logica testen

// 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; // eerste seat gratis
  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('berekent de basisprijs voor een starter met 1 seat', () => {
    expect(calculateMonthlyPrice('starter', 1)).toBe(29);
  });

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

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

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

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

Tooling-tip: Vitest boven Jest

Voor moderne SaaS-projecten met Vite of Next.js is Vitest een betere keuze dan Jest:

  • 20x snellere startup door native ESM-support
  • Compatibel met Jest API (migratie is minimaal)
  • Ingebouwde TypeScript-support zonder extra config
  • Hot module replacement voor watch mode

Integratietests: waar de echte bugs zitten

De meeste SaaS-bugs zitten niet in geïsoleerde functies, maar in de interactie tussen componenten. Integratietests dekken dit af.

Wat moet je integratie-testen?

  • API endpoints — correcte responses, error handling, authenticatie
  • Database queries — complexe joins, transacties, edge cases
  • Externe services — payment providers, email, OAuth
  • Middleware — rate limiting, CORS, tenant-isolatie

Voorbeeld: API endpoint testen met een testdatabase

// 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('upgrade van starter naar pro lukt', 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('weigert downgrade zonder bevestiging', 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('blokkeert requests zonder authenticatie', async () => {
    const res = await app.request('/api/subscriptions/upgrade', {
      method: 'POST',
      body: JSON.stringify({ plan: 'pro' }),
    });

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

Testdatabase-strategie

Gebruik nooit je productiedatabase voor tests. Dit is de aanpak die werkt:

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

Externe services mocken: MSW is je beste vriend

Mock Service Worker (MSW) onderschept HTTP-requests op netwerkniveau, perfect voor het mocken van Stripe, SendGrid en andere diensten:

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: de kritieke paden bewaken

E2E tests simuleren echte gebruikersinteracties in de browser. Ze zijn langzaam en fragiel, dus gebruik ze spaarzaam — alleen voor je meest kritieke flows.

De 5 flows die je altijd E2E moet testen

  1. Registratie + onboarding — kan een nieuwe gebruiker zich aanmelden en starten?
  2. Betalingsflow — werkt checkout, upgrade, en downgrade?
  3. Kern-feature flow — de #1 reden waarom klanten betalen
  4. Multi-tenant isolatie — ziet tenant A nooit data van tenant B?
  5. Uitnodigen + samenwerken — werkt team-management?

Voorbeeld: Playwright E2E test voor onboarding

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

test.describe('Onboarding flow', () => {
  test('nieuwe gebruiker kan account aanmaken en workspace instellen', 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 BV');
    await page.click('button[type="submit"]');

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

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

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

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

  test('tenant-isolatie: gebruiker ziet alleen eigen 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-integratie: tests automatiseren

Tests zijn waardeloos als ze niet automatisch draaien. Hier is een GitHub Actions workflow die de hele piramide afdekt:

# .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

Test-strategie voor multi-tenant SaaS

Multi-tenancy voegt een extra laag complexiteit toe. Hier zijn de specifieke tests die je nodig hebt:

1. Data-isolatie tests

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

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

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

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

2. Row Level Security (RLS) testen

Als je PostgreSQL RLS gebruikt, test dan dat de policies correct werken:

it('RLS voorkomt cross-tenant access op database-niveau', 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: streefcijfers voor SaaS

Honderd procent coverage is een mythe. Richt je op de juiste coverage:

CategorieTargetWaarom
Pricing/billing95%+Fouten = direct omzetverlies
Auth/permissions90%+Fouten = beveiligingslek
Core business logic85%+Fouten = ontevreden klanten
API endpoints80%+Fouten = integratieproblemen
UI components60-70%Snapshot tests voor regressie
Utility functions90%+Makkelijk te testen, dus doe het

Coverage meten met 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'],
    },
  },
});

Veelgemaakte fouten (en hoe je ze vermijdt)

❌ Fout 1: Alleen happy-path testen

Test ook foutscenario's: verlopen tokens, netwerk-timeouts, ongeldige input, concurrency conflicts.

❌ Fout 2: Tests die van volgorde afhangen

Elke test moet onafhankelijk kunnen draaien. Gebruik beforeEach voor setup, niet de output van vorige tests.

❌ Fout 3: Te veel mocken

Als je alles mockt, test je alleen je mocks. Gebruik echte databases in integratietests en mock alleen externe diensten.

❌ Fout 4: Flaky tests negeren

Een flaky test is erger dan geen test — het ondermijnt het vertrouwen in je hele suite. Fix of verwijder ze onmiddellijk.

❌ Fout 5: Geen tests voor migraties

Test dat je database-migraties zowel up als down werken, vooral bij schema-wijzigingen die bestaande data raken.

Praktisch stappenplan: van 0 naar solide teststrategie

Week 1-2: Fundament

  • Installeer Vitest + Playwright
  • Configureer testdatabase (Docker Compose)
  • Schrijf tests voor pricing en auth-logica

Week 3-4: Integratietests

  • Test alle API endpoints (happy + error paths)
  • Stel MSW in voor externe services
  • Voeg database-isolatietests toe

Week 5-6: E2E + CI

  • Schrijf E2E tests voor top-5 flows
  • Configureer GitHub Actions pipeline
  • Stel coverage-drempels in

Week 7+: Onderhoud

  • Elke nieuwe feature = tests meeleveren
  • Wekelijks flaky tests reviewen
  • Maandelijks coverage-rapport evalueren

Conclusie

Een goede teststrategie is geen luxe — het is een vereiste voor elke SaaS die serieus wil groeien. Begin met unit tests voor je kritieke business logic, bouw integratietests voor je API's en database, en dek je belangrijkste user flows af met E2E tests.

Het doel is niet 100% coverage, maar 100% vertrouwen dat je veilig kunt deployen. En dat vertrouwen? Dat betaalt zich dubbel en dwars terug in snelheid, kwaliteit en nachtrust.


Wil je hulp bij het opzetten van een teststrategie voor je SaaS? Bij SaaS Masters helpen we teams om sneller en veiliger te shippen. Neem contact op voor een vrijblijvend gesprek.