TL;DR
Most boilerplates skip testing because founders don't buy boilerplates to write tests — they buy them to ship fast. The Epic Stack is the clear outlier: it ships with Vitest, Playwright, and MSW fully configured. For production SaaS, a basic testing setup (critical path E2E + utility unit tests) is worth 2-3 days of setup and saves weeks of debugging.
The Testing Gap in Boilerplates
Checking major boilerplates for included test infrastructure:
| Boilerplate | Unit Tests | E2E Tests | Test Config | Mocking |
|---|---|---|---|---|
| Epic Stack | ✅ Vitest | ✅ Playwright | ✅ Full | ✅ MSW |
| T3 Stack | ❌ | ❌ | ❌ | ❌ |
| ShipFast | ❌ | ❌ | ❌ | ❌ |
| Supastarter | Minimal | ❌ | Minimal | ❌ |
| Makerkit | ✅ Vitest | ✅ Playwright | ✅ | ❌ |
| Open SaaS | ❌ | ❌ | ❌ | ❌ |
Why Boilerplates Skip Tests
Market incentives: Boilerplate buyers evaluate by features visible in the README. "Stripe billing" sells boilerplates. "100% test coverage" does not.
Boilerplate code is hard to test: Auth flows, Stripe webhooks, database operations — the core boilerplate features require mocking that's non-trivial to set up.
Tests go stale: A boilerplate test for auth that worked last year may fail when the auth library updates. Maintaining tests for a product you sell once is a business cost.
The honest reality: most commercial boilerplates skip tests to reduce maintenance burden and improve sales conversion. This means you inherit untested code.
The Epic Stack: Testing Done Right
Kent C. Dodds' Epic Stack is the gold standard for testing in boilerplates. It ships with a complete testing setup:
# Epic Stack ships with all of this configured:
npx create-epic-app@latest my-saas
# Run unit tests
npm run test
# Run E2E tests
npm run test:e2e
# Coverage report
npm run test:coverage
Vitest for Unit/Integration Tests
// app/utils/pricing.test.ts
import { describe, it, expect } from 'vitest';
import { calculateProration, getFeatures, isFeatureEnabled } from './pricing';
describe('calculateProration', () => {
it('returns 0 when downgrading mid-cycle with no remaining days', () => {
const result = calculateProration({
currentPlan: 'pro',
newPlan: 'free',
daysRemainingInCycle: 0,
});
expect(result).toBe(0);
});
it('calculates correct credit for upgrade mid-cycle', () => {
const result = calculateProration({
currentPlan: 'free',
newPlan: 'pro',
daysRemainingInCycle: 15,
cycleLength: 30,
});
expect(result).toBe(14.99); // Half of $29.99 pro price
});
});
describe('isFeatureEnabled', () => {
it('blocks pro features for free users', () => {
expect(isFeatureEnabled('advanced-analytics', 'free')).toBe(false);
});
it('allows pro features for pro users', () => {
expect(isFeatureEnabled('advanced-analytics', 'pro')).toBe(true);
});
});
Playwright for E2E Tests
// tests/e2e/auth.test.ts
import { test, expect } from '@playwright/test';
test.describe('Authentication', () => {
test('user can sign up with email and password', async ({ page }) => {
await page.goto('/sign-up');
await page.fill('[name=email]', 'test@example.com');
await page.fill('[name=password]', 'securepassword123');
await page.click('[type=submit]');
await expect(page).toHaveURL('/dashboard');
await expect(page.getByText('Welcome')).toBeVisible();
});
test('invalid credentials show error', async ({ page }) => {
await page.goto('/sign-in');
await page.fill('[name=email]', 'wrong@example.com');
await page.fill('[name=password]', 'wrongpassword');
await page.click('[type=submit]');
await expect(page.getByText('Invalid credentials')).toBeVisible();
await expect(page).toHaveURL('/sign-in');
});
});
test.describe('Billing', () => {
test('user can upgrade to pro plan', async ({ page, context }) => {
// Log in as free user
await loginAsTestUser(page, 'free');
await page.goto('/pricing');
await page.click('button:has-text("Upgrade to Pro")');
// Stripe test card
await page.frameLocator('[name=cardNumber]').fill('4242424242424242');
await page.frameLocator('[name=expiry]').fill('12/28');
await page.frameLocator('[name=cvc]').fill('123');
await page.click('[type=submit]');
await expect(page).toHaveURL('/dashboard?upgrade=success');
await expect(page.getByText('Pro')).toBeVisible();
});
});
MSW for Mocking External APIs
// tests/mocks/handlers.ts — Mock Stripe, email, and external APIs
import { http, HttpResponse } from 'msw';
export const handlers = [
// Mock Stripe webhook
http.post('/api/webhooks/stripe', async ({ request }) => {
const body = await request.json();
// Handle the event
return HttpResponse.json({ received: true });
}),
// Mock external price check
http.get('https://api.stripe.com/v1/prices/*', () => {
return HttpResponse.json({
id: 'price_test',
unit_amount: 2999,
currency: 'usd',
});
}),
];
Minimal Testing Setup for Any Boilerplate
If your boilerplate doesn't ship with tests, here's a minimal but meaningful setup:
npm install -D vitest @vitest/ui @playwright/test msw happy-dom
npx playwright install
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
environment: 'happy-dom',
globals: true,
setupFiles: ['./tests/setup.ts'],
},
});
The 20% That Prevents 80% of Bugs
Focus testing effort on:
- Business logic utilities — pricing, permissions, calculations
- Auth flow — sign-up, sign-in, protected routes
- Stripe webhook handling — subscription created, payment failed
- Critical user paths — the actions that make you money
// Test the things that break silently and cost money
describe('Stripe webhook: subscription.created', () => {
it('activates user subscription on successful payment', async () => {
const event = createStripeEvent('customer.subscription.created', {
customer: 'cus_test',
status: 'active',
metadata: { userId: 'user_123', plan: 'pro' },
});
await handleWebhook(event);
const user = await prisma.user.findUnique({ where: { id: 'user_123' } });
expect(user?.plan).toBe('pro');
expect(user?.subscriptionStatus).toBe('active');
});
});
CI Integration
# .github/workflows/test.yml
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20' }
- run: npm ci
- run: npm run test -- --reporter=verbose
- run: npx playwright install --with-deps
- run: npm run test:e2e
env:
DATABASE_URL: ${{ secrets.TEST_DATABASE_URL }}
Testing Strategy: What Actually Matters
The mistake developers make when adding tests to a SaaS: trying to cover everything. A comprehensive test suite on a boilerplate with 50 API routes and 30 components is months of work. The right approach is testing the things that are expensive when they break.
High-value tests (write these first):
- Stripe webhook handlers — payment failures and subscription changes directly affect revenue
- Authentication and session management — auth bugs cause data breaches or lockouts
- Permission checks — a permission bug lets users see each other's data
- Billing gates — a billing gate bug gives users features they haven't paid for
Medium-value tests:
- Core CRUD operations that power the primary product feature
- Email sending — confirm the right template fires at the right time
- Background jobs — especially ones that run money-related operations
Low-value tests (add later or skip):
- Static pages and marketing copy
- Component styling and layout
- Third-party API integrations (mock these in integration tests instead)
The ratio that works in practice: 1 E2E test per critical user path (signup, payment, primary feature action), unit tests for all business logic functions, and no coverage requirements on UI components.
Database Testing Without Full Mocks
A common pitfall: mocking the database in tests and discovering that the mock behavior diverged from the real database behavior. The mock tests pass; production queries fail.
The alternative — database tests against a real test database — is more reliable but requires setup:
// vitest.config.ts — use test database
export default defineConfig({
test: {
globalSetup: './tests/global-setup.ts',
setupFiles: ['./tests/setup.ts'],
},
});
// tests/global-setup.ts
import { execSync } from 'child_process';
export async function setup() {
// Run migrations against test database
execSync('DATABASE_URL=$TEST_DATABASE_URL npx prisma migrate deploy', {
stdio: 'inherit',
});
}
// tests/setup.ts — clean database between tests
import { prisma } from '../lib/db';
beforeEach(async () => {
// Truncate all tables in dependency order
await prisma.$transaction([
prisma.subscription.deleteMany(),
prisma.member.deleteMany(),
prisma.project.deleteMany(),
prisma.user.deleteMany(),
prisma.organization.deleteMany(),
]);
});
afterAll(async () => {
await prisma.$disconnect();
});
With this setup, tests run against a real PostgreSQL database (in CI, use postgres Docker service in GitHub Actions). RLS policies, indexes, and constraints are all tested — not simulated. The tests run slower than pure mocks but catch the bugs that mocks miss.
Stripe Testing Patterns
Stripe testing has two modes: Stripe's test API keys (which work against real Stripe infrastructure) and locally triggered webhooks via Stripe CLI.
# Start webhook forwarding in development
stripe listen --forward-to localhost:3000/api/webhooks/stripe
# Trigger specific events
stripe trigger payment_intent.succeeded
stripe trigger customer.subscription.created
stripe trigger customer.subscription.deleted
stripe trigger invoice.payment_failed
For automated tests, use Stripe's test event construction pattern:
// tests/utils/stripe.ts
import Stripe from 'stripe';
export function createStripeEvent(
type: string,
data: Record<string, unknown>
): Stripe.Event {
return {
id: `evt_test_${Math.random().toString(36).slice(2)}`,
object: 'event',
type: type as Stripe.Event['type'],
data: { object: data as Stripe.Event.Data['object'] },
api_version: '2024-06-20',
created: Math.floor(Date.now() / 1000),
livemode: false,
pending_webhooks: 0,
request: null,
};
}
// test: webhook handler processes subscription creation
it('activates org subscription on subscription.created', async () => {
const org = await createTestOrg();
const event = createStripeEvent('customer.subscription.created', {
customer: org.stripeCustomerId,
status: 'active',
id: 'sub_test_123',
items: { data: [{ plan: { id: 'price_pro_monthly' } }] },
});
await handleStripeWebhook(event);
const updated = await prisma.organization.findUnique({ where: { id: org.id } });
expect(updated?.subscriptionStatus).toBe('active');
expect(updated?.plan).toBe('pro');
});
This tests the exact behavior that runs in production without a live Stripe account. Add similar tests for subscription.updated, subscription.deleted, and invoice.payment_failed — these are the four webhook events that matter most for a SaaS subscription model.
Adding Tests to an Existing Boilerplate
When you start with a boilerplate that has no tests, the best path forward:
Week 1: Set up the test infrastructure (Vitest + Playwright + database setup) and write one E2E test for signup. This validates that the setup works.
Week 2: Add E2E tests for the happy path of each critical flow: login, upgrade to paid, use primary feature, cancel subscription.
Week 3: Add unit tests for business logic: permission checks, billing calculations, data transformation utilities.
After that: Test as you build. Every new feature should include a test for its happy path. Don't go back and test old code — the ROI is poor. Test new code as you write it.
The goal isn't coverage percentage — it's confidence that the most important user journeys work correctly. A SaaS with 4 critical path E2E tests and 20 business logic unit tests is in a much better position than a SaaS with 0 tests and a theoretical 80% coverage goal.
Test Isolation: The Common Source of Flaky Tests
The most common cause of flaky tests in SaaS applications: tests that share state through the database. Test A creates a user; Test B counts users and gets an unexpected result. Tests pass in isolation, fail when run together.
The solution is database cleanup between tests (shown in the database testing section above) combined with unique data per test:
// tests/factories.ts — generate unique test data
let counter = 0;
export function createTestUser(overrides = {}) {
counter++;
return {
email: `test-${counter}-${Date.now()}@example.com`,
name: `Test User ${counter}`,
...overrides,
};
}
export function createTestOrg(overrides = {}) {
counter++;
return {
name: `Test Org ${counter}`,
slug: `test-org-${counter}`,
...overrides,
};
}
Using unique values per test prevents the "duplicate key" database errors that appear when two tests try to create users with the same email. Combined with database cleanup between tests, this approach produces reliably isolated tests.
What to Test in Stripe Integrations
Stripe integration is the most important and most commonly untested part of SaaS applications. Beyond the webhook handler tests shown earlier, these scenarios need coverage:
Subscription feature gating: After subscription.created fires and the database updates, do the right features become available? Test by checking the user's plan in the database after processing the event, then verifying that the permission check returns true for a gated feature.
Payment failure handling: After invoice.payment_failed, does the user's access get appropriately restricted? This is the test that prevents "user stops paying, keeps using the product" bugs.
Trial expiration: If you have free trials, does access correctly expire when the trial ends? The customer.subscription.updated event with status: 'past_due' or status: 'canceled' should be tested explicitly.
Idempotency: Stripe may deliver the same webhook event more than once. Your handler must be idempotent — processing subscription.created twice should not create two subscriptions or send two welcome emails. Test by calling the webhook handler twice with the same event and verifying the database state is correct.
These four scenarios cover the business logic that directly affects revenue. They're worth testing even if you test nothing else.
Testing Authentication: The Hardest Part
Authentication is the most critical system to test and the hardest to get right. The scenarios that cause the most damage:
Session fixation: A user logs in, and their session ID changes to a new value. If it doesn't, a session fixation attack is possible. Most auth libraries handle this correctly, but testing it explicitly provides confidence.
Protected route access without authentication: Unauthenticated requests to protected routes must return 401/redirect to login. Test every protected route category — dashboard, API routes, admin — with an unauthenticated client.
Cross-tenant data access: When your application is multi-tenant, user A must not access user B's data even with a valid session. This requires testing that includes: create org A, create org B, log in as an org A user, attempt to access org B resources. The response should be 403.
OAuth callback security: If you support OAuth (Google, GitHub), test that the OAuth callback properly validates the state parameter. A missing state check enables CSRF attacks on the OAuth flow.
For most boilerplates, the auth library (Auth.js, Clerk) handles these correctly. But verifying they work correctly in your specific configuration — with your middleware, route structure, and session setup — is worthwhile. A broken auth configuration can fail silently if you're only testing happy paths.
Compare boilerplates by testing setup in the StarterPick directory.
Review Epic Stack — the only SaaS boilerplate that ships with complete test infrastructure.
See our guide to open-source SaaS boilerplates for the best free testing-included options.
For a full market overview that includes testing coverage in each boilerplate's scorecard, our best SaaS boilerplates guide covers every major option. If testing quality is a deciding factor, the T3 Stack review and ShipFast review each cover their testing trade-offs in detail.