Modh
AboutServicesWorkPlaybookResourcesBook a call
Book a call
Agent Skills/Backend / Infrastructure/Testing Strategy
Backend / Infrastructureintermediate7 min

test-tube Testing Strategy

Create tests following Vitest and Playwright patterns. Use when writing unit tests, integration tests, mocking Supabase, testing server actions, or running test suites. Enforces __tests__ directories, repository mocking, and test data cleanup.

Install this skill

git submodule add https://github.com/modh-labs/playbook.git .playbook

One submodule installs all 17 skills. Reference .playbook/skills/testing/SKILL.md from your AGENTS.md.


Testing Patterns Skill

When This Skill Activates

  • Writing or modifying test files (*.test.ts, *.test.tsx)
  • Creating mocks for database clients, repositories, or services
  • Discussing testing strategy or coverage
  • Running test suites

Commands

bun test          # Watch mode (re-run on changes)
bun test:ci       # Single run (CI mode)
bun test:ui       # Interactive UI
bun test:e2e      # Playwright headed mode
bun ci            # Full pipeline (lint + typecheck + tests)

Test Organization

Tests use __tests__/ directories colocated next to source code:

app/orders/actions/
  process-order.ts
  __tests__/
    process-order.test.ts
    cancel-order.test.ts

Naming Convention

TypePatternExample
Unit test*.test.tsorders.test.ts
Integration test*-integration.test.tsorders-integration.test.ts
E2E test*.e2e.test.tscheckout.e2e.test.ts

4 Testing Patterns

Pattern 1: Unit Test Repository (Mock Database Client)

Test repository logic in isolation with a mocked database client:

import { describe, it, expect, beforeEach } from "vitest";
import { createMockSupabaseClient } from "@/test/mocks/supabase.mock";
import { createOrder } from "../orders.repository";

describe("orders.repository", () => {
  let mockDb: ReturnType<typeof createMockSupabaseClient>;

  beforeEach(() => {
    mockDb = createMockSupabaseClient();
  });

  it("should create an order", async () => {
    mockDb._chain.insert.mockReturnValue(mockDb._chain);
    mockDb._chain.select.mockReturnValue(mockDb._chain);
    mockDb._chain.single.mockResolvedValue({
      data: { id: "order_123", amount: 1000 },
      error: null,
    });

    const result = await createOrder(mockDb as any, {
      item_id: "item_456",
      amount: 1000,
    });

    expect(result.amount).toBe(1000);
    expect(mockDb.from).toHaveBeenCalledWith("orders");
  });
});

Pattern 2: Integration Test Repository (Real Database)

Test against a real database for RLS, constraints, and indexes:

describe("orders.repository (integration)", () => {
  let supabase: Awaited<ReturnType<typeof createServiceRoleClient>>;
  let testOrgId: string;

  beforeEach(async () => {
    supabase = await createServiceRoleClient();
    testOrgId = `test_org_${Date.now()}`;
  });

  afterEach(async () => {
    // Always clean up test data
    await supabase.from("orders").delete().eq("organization_id", testOrgId);
  });

  it("should create and retrieve orders", async () => {
    const order = await createOrder(supabase, {
      organization_id: testOrgId,
      amount: 1000,
    });
    expect(order.id).toBeDefined();
  });
});

Pattern 3: Unit Test Server Action (Mock Repositories)

Mock at the repository boundary -- never mock internal functions:

import { vi } from "vitest";
import * as ordersRepository from "@/lib/repositories/orders.repository";

vi.mock("@/lib/repositories/orders.repository", () => ({
  updateOrderStatus: vi.fn(),
}));

describe("updateOrderStatusAction", () => {
  beforeEach(() => {
    vi.clearAllMocks();
  });

  it("should update order status", async () => {
    vi.spyOn(ordersRepository, "updateOrderStatus").mockResolvedValue(undefined);

    const result = await updateOrderStatusAction({
      orderId: "order_123",
      status: "shipped",
    });
    expect(result.success).toBe(true);
  });
});

Pattern 4: Integration Test Server Action (Real Repositories)

Full flow with actual database:

describe("updateOrderStatusAction (integration)", () => {
  // Setup real test data in beforeEach
  // Clean up in afterEach
  // Test the full action -> repository -> database flow
});

Core Rules

DO

RuleWhy
Use __tests__/ directory next to source codeEasy to find tests for any file
Mock at repository boundary for action testsTests business logic, not DB details
Use createMockSupabaseClient from test helpersConsistent mock behavior across tests
Clean up test data in afterEachPrevents flaky tests from leftover data
Use unique test IDs (test_org_${Date.now()})Parallel test isolation
beforeEach with vi.clearAllMocks()Fresh mocks for each test
Mock env vars before dynamic importsEnvironment-dependent code loads correctly

DON'T

RuleWhy
Use database client directly in testsUse repositories instead
Mock internal functionsMock at boundaries (repositories, external APIs)
Mix unit and integration tests in same describeDifferent setup/teardown needs
Skip cleanupCauses flaky tests in CI
Hardcode test dataUse unique IDs or faker for isolation

Mocking Patterns

Universal Server Action Test Template

Follow this exact structure for every server action test file. The key insight: hoist mocks before vi.mock calls, and import the action AFTER all mocks are registered.

import { beforeEach, describe, expect, it, vi } from "vitest";

// 1. Hoist mocks (these run BEFORE vi.mock factory functions)
const mockAuth = vi.hoisted(() =>
  vi.fn(() => Promise.resolve({
    userId: "user_test_123",
    orgId: "org_test_456",
    orgRole: "org:admin",
  } as Record<string, unknown>)),
);
const mockRepository = vi.hoisted(() => vi.fn());

// 2. Mock dependencies (order matters)
vi.mock("@/lib/auth", () => ({ auth: mockAuth }));
vi.mock("@/lib/traced-action", () => ({
  tracedAction: (_name: string, fn: unknown) => fn,
}));
vi.mock("@/lib/captures/domain", () => ({
  captureDomainException: vi.fn(),
}));
vi.mock("@/lib/logger", () => ({
  createModuleLogger: () => ({
    info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(),
  }),
}));
vi.mock("next/cache", () => ({
  revalidatePath: vi.fn(),
  revalidateTag: vi.fn(),
}));

// 3. Import AFTER mocks (dynamic resolution picks up mocked modules)
import { actionUnderTest } from "../action-file";

// 4. Structured test body
describe("actionUnderTest", () => {
  beforeEach(() => { vi.clearAllMocks(); });

  describe("auth", () => {
    it("returns error when unauthorized", async () => {
      mockAuth.mockResolvedValueOnce({ userId: null, orgId: null });
      const result = await actionUnderTest({ id: "123" });
      expect(result.error).toBeDefined();
    });
  });

  describe("validation", () => {
    it("returns error for invalid input", async () => {
      const result = await actionUnderTest({ id: "" });
      expect(result.error).toBeDefined();
    });
  });

  describe("happy path", () => {
    it("succeeds with valid input", async () => {
      mockRepository.mockResolvedValueOnce({ id: "123", status: "active" });
      const result = await actionUnderTest({ id: "123" });
      expect(result.success).toBe(true);
    });
  });

  describe("error handling", () => {
    it("captures domain exception on DB error", async () => {
      mockRepository.mockRejectedValueOnce(new Error("DB timeout"));
      const result = await actionUnderTest({ id: "123" });
      expect(result.success).toBe(false);
    });
  });
});

Key rules:

  • Use as Record<string, unknown> on auth mock returns to avoid type conflicts with auth provider types
  • Always mock tracedAction to pass through the function (no tracing in tests)
  • Always mock domain capture functions (prevent Sentry calls in tests)
  • Every test file has 4 describe blocks: auth, validation, happy path, error handling

Mock Database Client (Supabase)

See references/mock-patterns.md for the full mock implementation.

import { createMockSupabaseClient } from "@/test/mocks/supabase.mock";

const mockDb = createMockSupabaseClient();

// Mock a query chain: from() -> select() -> eq() -> single()
mockDb._chain.select.mockReturnValue(mockDb._chain);
mockDb._chain.eq.mockReturnValue(mockDb._chain);
mockDb._chain.single.mockResolvedValue({
  data: { id: "123", name: "Test" },
  error: null,
});

Mock External APIs

vi.mock("@/lib/external/payment-api", () => ({
  chargeCustomer: vi.fn(),
  refundPayment: vi.fn(),
}));

Mock Environment Variables

beforeEach(() => {
  vi.stubEnv("API_KEY", "test_key_123");
  vi.stubEnv("WEBHOOK_SECRET", "test_secret");
});

afterEach(() => {
  vi.unstubAllEnvs();
});

Common Mock Mistakes

MistakeFix
Importing action before vi.mockUse vi.hoisted() + import after mocks
Missing tracedAction mockTracing wrapper calls error tracker on errors
Missing captureException mockDomain capture tries to call error tracker
Auth mock returns typed objectUse as Record<string, unknown> to avoid type conflicts
Not clearing mocks between testsAlways vi.clearAllMocks() in beforeEach

Playwright E2E Patterns

For the full guide on semantic locators, accessible UI patterns, flaky test elimination, and Page Object fixtures, see the e2e-testability skill.

Key Rules (Summary)

  1. Use getByRole first — not CSS selectors or data-testid
  2. Wait for data presence, not loading-state absence
  3. No page.waitForTimeout() — use web-first assertions
  4. Extract repeated setup into Page Object fixtures

Page Object Pattern

// tests/pages/checkout.page.ts
export class CheckoutPage {
  constructor(private page: Page) {}

  async fillShippingAddress(address: Address) {
    await this.page.getByLabel('Street').fill(address.street);
    await this.page.getByLabel('City').fill(address.city);
  }

  async submitOrder() {
    await this.page.getByRole('button', { name: 'Submit Order' }).click();
    await this.page.waitForURL(/\/confirmation/);
  }
}

E2E Test Structure

import { test, expect } from "@playwright/test";
import { CheckoutPage } from "./pages/checkout.page";

test("user can complete checkout", async ({ page }) => {
  const checkout = new CheckoutPage(page);

  await page.goto("/checkout");
  await checkout.fillShippingAddress({ street: "123 Main", city: "Portland" });
  await checkout.submitOrder();

  await expect(page).toHaveURL(/\/confirmation/);
  await expect(page.getByRole('heading', { name: /Order Confirmed/ })).toBeVisible();
});

Quick Reference

PatternMock WhatTest What
Unit repoDatabase clientQuery building, error handling
Integration repoNothing (real DB)RLS, constraints, data flow
Unit actionRepository functionsBusiness logic, validation
Integration actionNothing (real DB)Full flow end-to-end

Detailed References

  • Database client mock implementation: references/mock-patterns.md

Related Skills

search-code

Code Quality Audit

Systematic audit and remediation of a route or module to production-grade quality. Use when auditing for SOLID violations, missing error handling, type safety gaps, dead code, duplicated patterns, or insufficient test coverage. Also use when remediating code to gold-standard clean architecture.

Backend / Infrastructure
heart-pulse

Cron Monitor Check-Ins

A cron monitor is only as real as the check-ins that feed it. Vercel's Sentry integration auto-registers a monitor per cron that EXPECTS check-ins; if the route never calls captureCheckIn, the monitor reports "missed check-in" on every tick forever — a permanent phantom outage that trains your team to ignore alerts. Use when adding/auditing any scheduled job (Vercel cron, GitHub Action schedule, k8s CronJob) wired to a heartbeat monitor (Sentry Crons, Cronitor, Healthchecks.io, Better Stack).

Backend / Infrastructure
fingerprint

Dedup Key Completeness

A de-duplication, idempotency, or uniqueness guard must key on every dimension that makes two records legitimately distinct. A missing dimension silently collapses separate records into one (a lost write with no error). Use when writing an upsert, an onConflict target, a unique constraint, a "have we seen this already?" time window, or any "skip if it exists" guard — and when a bug report says "my second booking/order/ticket disappeared or merged into the first."

Backend / Infrastructure
←Back to Agent Skills