Skip to main content
Core

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

  1. Critical user flows — Must be tested
  2. Business logic — Should be tested
  3. UI components — Test complex ones, skip trivial ones
  4. 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

Search Framework Explorer

Search agents, skills, and standards