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
- Registration + onboarding — can a new user sign up and get started?
- Payment flow — does checkout, upgrade, and downgrade work?
- Core feature flow — the #1 reason customers pay
- Multi-tenant isolation — does tenant A never see tenant B's data?
- 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:
| Category | Target | Why |
|---|---|---|
| Pricing/billing | 95%+ | Errors = direct revenue loss |
| Auth/permissions | 90%+ | Errors = security breach |
| Core business logic | 85%+ | Errors = unhappy customers |
| API endpoints | 80%+ | Errors = integration problems |
| UI components | 60-70% | Snapshot tests for regression |
| Utility functions | 90%+ | 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.