Testing standards
Testing strategy: unit, integration, E2E. TDD for core logic, coverage thresholds, CI enforcement.
Testing standards
Purpose: Defines when and how to test code, including test types, coverage targets, regression requirements, and anti-patterns to avoid.
How and when Framework tests code.
Quick reference:
- Test tools — Unit (Vitest), Integration (Vitest), E2E (Playwright)
- Always test — Auth, payments, business logic, API validation, data transforms
- Bug fixes — Regression test mandatory (TDD red-green)
- Coverage targets — Business logic 80%+, API routes 60%+, UI critical paths only
- Anti-patterns — Testing implementation details, testing third-party libs, brittle selectors
Philosophy
Test what matters. Skip what doesn't.
We don't need 100% coverage. We need confidence that critical paths work and regressions get caught.
Priorities
- Critical user flows — Must be tested
- Business logic — Should be tested
- UI components — Test complex ones, skip trivial ones
- Utilities — Test if complex, skip if obvious
Test types
| Type | Tool | Purpose | Location |
|---|---|---|---|
| Unit | Vitest | Test functions/modules in isolation | *.test.ts next to source |
| Integration | Vitest | Test API routes with mocked DB | *.test.ts next to route |
| E2E | Playwright | Test real user flows in browser | e2e/*.spec.ts |
What to test
Always test
| Category | Examples | Why |
|---|---|---|
| Auth flows | Login, logout, permission checks | Security-critical |
| Payment flows | Checkout, subscription changes | Money-critical |
| Core business logic | Access rules, pricing calculations | Revenue-critical |
| API validation | Zod schemas, error responses | Contract enforcement |
| Data transformations | Complex mappings, calculations | Easy to break |
Consider testing
| Category | When |
|---|---|
| React components | If behavior is complex |
| Hooks | If logic is non-trivial |
| Utilities | If used widely |
Skip testing
| Category | Why |
|---|---|
| Simple UI (static pages, layouts) | Visual, not behavioral |
| Config files | Validated elsewhere |
| Third-party wrappers | Library is tested |
| One-off scripts | Run once |
Regression testing requirements
For every bug fix, you MUST write a regression test. Follow the TDD red-green pattern described in the test-driven-development skill: write a test that fails without the fix, then make it pass.
Requirements by work type
| Work Type | Regression Test | Details |
|---|---|---|
| Bug fix | Mandatory | Must reproduce the bug scenario and assert correct behavior. Test type (unit/integration/e2e) matches the layer where the bug occurred. |
| Feature | Per existing guidance | Follow the "Always Test" / "Consider Testing" / "Skip Testing" tables above. |
| Chore / Tech debt | If behavior changes | Required only if the chore modifies observable behavior (e.g., refactoring logic). |
| Docs | Not required | Documentation changes don't need tests. |
Choosing the right test type
| Bug Location | Test Type | Example |
|---|---|---|
| Utility function / business logic | Unit (Vitest) | Calculation returns wrong result |
| API route / validation | Integration (Vitest) | Endpoint returns 500 instead of 400 |
| User flow / multi-page interaction | E2E (Playwright) | Form submission loses data on redirect |
Regression test naming
Include the issue ID in the test description for traceability:
// Unit test for a bug fix
it('does not crash when input is empty (PROJ-123)', () => {
expect(() => processInput('')).not.toThrow();
});
// E2E test for a bug fix
test('form retains data after validation error (PROJ-456)', async ({ page }) => {
// ...
});
Patterns
Business logic
import { describe, it, expect } from 'vitest';
import { calculatePrice } from './calculate-price';
describe('calculatePrice', () => {
it('applies discount for annual billing', () => {
const result = calculatePrice({ basePrice: 100, billingPeriod: 'annual' });
expect(result.monthlyRate).toBe(83.33);
});
it('throws for negative prices', () => {
expect(() => calculatePrice({ basePrice: -10 })).toThrow('Price must be positive');
});
});
API routes
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { POST } from './route';
import { NextRequest } from 'next/server';
vi.mock('@clerk/nextjs/server', () => ({
auth: vi.fn().mockResolvedValue({ userId: 'user_123', orgId: 'org_456' }),
}));
vi.mock('@/lib/db', () => ({
prisma: { door: { create: vi.fn() } },
}));
describe('POST /api/doors', () => {
beforeEach(() => vi.clearAllMocks());
it('creates door with valid input', async () => {
const { prisma } = await import('@/lib/db');
vi.mocked(prisma.door.create).mockResolvedValue({ id: 'door_123', name: 'Main' });
const req = new NextRequest('http://localhost/api/doors', {
method: 'POST',
body: JSON.stringify({ name: 'Main' }),
});
const res = await POST(req);
expect(res.status).toBe(200);
});
it('returns 401 without auth', async () => {
const { auth } = await import('@clerk/nextjs/server');
vi.mocked(auth).mockResolvedValue({ userId: null, orgId: null } as any);
const req = new NextRequest('http://localhost/api/doors', {
method: 'POST',
body: JSON.stringify({ name: 'Test' }),
});
const res = await POST(req);
expect(res.status).toBe(401);
});
});
E2E
import { test, expect } from '@playwright/test';
test('user can add a new door', async ({ page }) => {
await page.goto('/doors');
await page.click('button:has-text("Add Door")');
await page.fill('input[name="name"]', 'Test Door');
await page.click('button:has-text("Save")');
await expect(page.locator('text=Test Door')).toBeVisible();
});
Clerk auth mocking
Mock Clerk's auth() function in API route tests:
import { vi } from 'vitest';
// Mock authenticated user
vi.mock('@clerk/nextjs/server', () => ({
auth: vi.fn().mockResolvedValue({
userId: 'user_test123',
orgId: 'org_test456',
orgRole: 'org:admin',
}),
}));
// Test unauthenticated access
it('returns 401 without auth', async () => {
const { auth } = await import('@clerk/nextjs/server');
vi.mocked(auth).mockResolvedValue({ userId: null, orgId: null } as any);
// ... assert 401
});
// Test authenticated user with no active org (orgId gate — independent from userId gate)
it('returns 403 when user has no active organization', async () => {
const { auth } = await import('@clerk/nextjs/server');
vi.mocked(auth).mockResolvedValue({ userId: 'user_test123', orgId: null } as any);
// ... assert 403
});
// Test wrong role
it('returns 403 for non-admin', async () => {
const { auth } = await import('@clerk/nextjs/server');
vi.mocked(auth).mockResolvedValue({
userId: 'user_test123',
orgId: 'org_test456',
orgRole: 'org:member',
} as any);
// ... assert 403
});
Stripe webhook testing
Test webhook handlers with constructed events:
import Stripe from 'stripe';
function createTestEvent(type: string, data: object): Stripe.Event {
return {
id: `evt_test_${Date.now()}`,
type,
data: { object: data },
created: Math.floor(Date.now() / 1000),
livemode: false,
api_version: '2024-12-18.acacia',
} as Stripe.Event;
}
it('handles checkout.session.completed', async () => {
const event = createTestEvent('checkout.session.completed', {
id: 'cs_test_123',
customer: 'cus_test_456',
subscription: 'sub_test_789',
});
// Mock signature verification to return the test event
vi.spyOn(stripe.webhooks, 'constructEvent').mockReturnValue(event);
// ... call webhook handler, assert behavior
});
// Security rejection paths — all three gates must be tested
it('returns 400 for invalid signature', async () => {
vi.spyOn(stripe.webhooks, 'constructEvent').mockImplementation(() => {
throw new Error('No signatures found matching the expected signature for payload');
});
// ... call webhook handler, assert 400
});
it('returns 400 for stale timestamp (>5 min)', async () => {
vi.spyOn(stripe.webhooks, 'constructEvent').mockImplementation(() => {
throw new Error('Timestamp outside the tolerance zone');
});
// ... call webhook handler, assert 400
});
it('returns 200 idempotently for duplicate event ID', async () => {
const event = createTestEvent('checkout.session.completed', { id: 'cs_test_123' });
vi.spyOn(stripe.webhooks, 'constructEvent').mockReturnValue(event);
// ... call webhook handler twice with the same event ID
// assert the second call returns 200 and does not reprocess (idempotency key prevented double-processing)
});
RLS testing
Test that Row-Level Security policies work correctly:
it('prevents cross-org data access', async () => {
// Insert data as org_A
const { data } = await supabaseAsOrgA
.from('doors')
.insert({ name: 'Test Door', organization_id: 'org_A' });
// Query as org_B — should return empty
const { data: leaked } = await supabaseAsOrgB
.from('doors')
.select()
.eq('id', data[0].id);
expect(leaked).toHaveLength(0);
});
IDOR testing
RLS prevents DB-level leakage, but API routes must also reject cross-org access when a valid resource ID from org_A is submitted by org_B. Test the API layer independently:
it('returns 404 when org_B requests a resource owned by org_A', async () => {
const { auth } = await import('@clerk/nextjs/server');
// Authenticate as org_B
vi.mocked(auth).mockResolvedValue({
userId: 'user_orgB',
orgId: 'org_B',
orgRole: 'org:member',
} as any);
// Request a resource ID that belongs to org_A
const req = new NextRequest('http://localhost/api/doors/door_owned_by_orgA', {
method: 'GET',
});
const res = await GET(req, { params: { id: 'door_owned_by_orgA' } });
expect(res.status).toBe(404); // 404 is preferred — see security.md § Cross-Org Authorization Response
});
Coverage
We target confidence, not a percentage.
| Category | Expectation |
|---|---|
| Business logic | High (80%+) |
| API routes | Medium (60%+) |
| UI components | Critical paths only |
| Config/setup | None |
Anti-patterns
Testing implementation details:
// Bad: expect(component.state.isLoading).toBe(true);
// Good: expect(screen.getByRole('progressbar')).toBeVisible();
Testing third-party libraries:
// Bad: expect(z.string().parse('hello')).toBe('hello');
// Good: expect(() => CreateDoorSchema.parse({ name: '' })).toThrow();
Brittle selectors:
// Bad: await page.click('.btn-primary.mt-4');
// Good: await page.click('button:has-text("Save")');
Framework Testing Standards v2.1 -- March 2026