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
- Registratie + onboarding — kan een nieuwe gebruiker zich aanmelden en starten?
- Betalingsflow — werkt checkout, upgrade, en downgrade?
- Kern-feature flow — de #1 reden waarom klanten betalen
- Multi-tenant isolatie — ziet tenant A nooit data van tenant B?
- 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:
| Categorie | Target | Waarom |
|---|---|---|
| Pricing/billing | 95%+ | Fouten = direct omzetverlies |
| Auth/permissions | 90%+ | Fouten = beveiligingslek |
| Core business logic | 85%+ | Fouten = ontevreden klanten |
| API endpoints | 80%+ | Fouten = integratieproblemen |
| UI components | 60-70% | Snapshot tests voor regressie |
| Utility functions | 90%+ | 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.