| name | testability |
| description | Make features testable by design. Testing pyramid from fast (local) to slow (UI). Expose APIs securely for testing. |
| license | MIT |
| compatibility | opencode |
| metadata | {"audience":"developers","workflow":"development","trigger":"before-writing-code"} |
What I Do
Ensure every feature you build is testable from the start. This skill teaches:
- Testing pyramid (fast → slow)
- API exposure patterns for testability
- Local testing setup
- Integration with staging tests
- When to use each testing layer
Core Philosophy
"If you can't test it locally, you can't test it."
Every feature should be testable at multiple levels. Design for testability, don't bolt it on later.
Testing Pyramid
From fastest (run constantly) to slowest (run occasionally):
▲
/U\ UI Tests (E2E)
/ I \ - Browser automation
/-----\ - Run on staging only
/ API \ API/Integration Tests
/ TESTS \ - tRPC procedures
/-----------\ - Can run locally
/ UNIT \ Unit Tests
/ TESTS \ - Pure functions
/------------------\ - Fastest, run always
| Layer | Speed | Where | When to Use |
|---|
| Unit | <1s | Local | Pure logic, utils, calculations |
| API/Integration | 1-10s | Local + CI | tRPC, DB operations, business logic |
| Staging | 30s-2m | Vercel preview | Full flow verification |
| UI/E2E | 2-5m | Staging only | Critical user journeys |
Layer 1: Unit Tests (Fastest)
When to Use
- Pure functions with no side effects
- Calculations, formatting, validation
- Business logic that doesn't touch DB/APIs
Pattern
export function calculateFee(amount: number, feePercent: number): number {
return amount * (feePercent / 100);
}
import { describe, it, expect } from 'vitest';
import { calculateFee } from './calculate-fee';
describe('calculateFee', () => {
it('calculates 1% fee correctly', () => {
expect(calculateFee(1000, 1)).toBe(10);
});
it('handles zero amount', () => {
expect(calculateFee(0, 5)).toBe(0);
});
});
Running Unit Tests
cd packages/web
pnpm test
pnpm test -- --run
pnpm test:watch
pnpm test -- --run --grep "fee"
Repo note: @zero-finance/web Vitest discovers tests under packages/web/src/test/**/*.test.ts. Put new tests there (or update Vitest config) so they get picked up.
Layer 2: API/Integration Tests
When to Use
- tRPC procedures
- Database operations
- External API integrations (mocked)
- Business logic with dependencies
Pattern: Testing tRPC Procedures
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createTestContext } from '@/test/context';
import { earnRouter } from './index';
describe('earn.getBalance', () => {
let ctx: ReturnType<typeof createTestContext>;
beforeEach(() => {
ctx = createTestContext({
user: { privyDid: 'test-user-did' },
workspaceId: 'test-workspace-id',
});
});
it('returns balance for valid user', async () => {
const caller = earnRouter.createCaller(ctx);
const result = await caller.getBalance({ chainId: 8453 });
expect(result).toHaveProperty('balance');
expect(typeof result.balance).toBe('string');
});
});
Pattern: Mocking External Services
vi.mock('@privy-io/server-auth', () => ({
PrivyClient: vi.fn().mockImplementation(() => ({
getUser: vi.fn().mockResolvedValue({ id: 'test-user' }),
})),
}));
vi.mock('@/db', () => ({
db: {
query: {
userSafes: {
findFirst: vi.fn().mockResolvedValue({
safeAddress: '0x1234...',
chainId: 8453,
}),
},
},
},
}));
Test Database Setup
For tests that need a real database:
import { drizzle } from 'drizzle-orm/neon-http';
import { neon } from '@neondatabase/serverless';
export async function createTestDb() {
const sql = neon(process.env.TEST_DATABASE_URL!);
return drizzle(sql);
}
Layer 3: Staging Tests (Vercel Preview)
When to Use
- Full end-to-end flows
- Features that involve multiple services
- UI changes that need visual verification
- Flows that can't be mocked locally
Workflow
git push -u origin feat/my-feature
LATEST=$(vercel ls --scope prologe 2>/dev/null | head -1)
vercel inspect "$LATEST" --scope prologe --wait --timeout 5m
Integration with test-staging-branch Skill
Load the test-staging-branch skill for:
- Chrome automation login flow
- Gmail OTP extraction
- PR reporting
skill("test-staging-branch")
Layer 4: UI/E2E Tests (Slowest)
When to Use
- Critical user journeys only
- After all other layers pass
- For regression prevention
Playwright Tests
import { test, expect } from '@playwright/test';
test('user can view dashboard balance', async ({ page }) => {
await page.goto('/dashboard');
await expect(page.getByText('Total Balance')).toBeVisible();
await expect(page.getByTestId('balance-amount')).toBeVisible();
});
Running E2E Tests
cd packages/web
pnpm exec playwright test
pnpm exec playwright test --ui
Making Code Testable
Pattern 1: Dependency Injection
export async function getBalance() {
const safe = await db.query.userSafes.findFirst({...});
const balance = await fetch(`https://api.example.com/balance/${safe.address}`);
return balance;
}
export async function getBalance(
deps: {
getSafe: () => Promise<UserSafe>,
fetchBalance: (address: string) => Promise<string>,
}
) {
const safe = await deps.getSafe();
const balance = await deps.fetchBalance(safe.address);
return balance;
}
Pattern 2: Extract Pure Functions
export async function processTransfer(amount: number) {
const fee = amount * 0.01;
const total = amount + fee;
await db.insert(transfers).values({ amount, fee, total });
return { amount, fee, total };
}
export function calculateTransferFees(amount: number) {
const fee = amount * 0.01;
const total = amount + fee;
return { amount, fee, total };
}
export async function processTransfer(amount: number) {
const calculated = calculateTransferFees(amount);
await db.insert(transfers).values(calculated);
return calculated;
}
Pattern 3: Test IDs in UI
<div data-testid="balance-card">
<span data-testid="balance-amount">{balance}</span>
</div>
Pattern 4: API Routes for Testing
Expose internal state via API routes that are:
- Only available in development/test
- Protected in production
import { NextResponse } from 'next/server';
export async function GET() {
if (process.env.NODE_ENV === 'production') {
return NextResponse.json({ error: 'Not available' }, { status: 403 });
}
return NextResponse.json({
safesCount: await db.select().from(userSafes).count(),
});
}
Local Testing Setup
Environment Files
DATABASE_URL="postgres://test:test@localhost:5432/test_db"
PRIVY_APP_ID="test-app-id"
Test Utilities
Create reusable test helpers:
export const testUser = {
privyDid: 'did:privy:test-user',
email: 'test@example.com',
};
export const testSafe = {
address: '0x1234567890123456789012345678901234567890',
chainId: 8453,
};
export function createTestContext(overrides = {}) {
return {
user: testUser,
workspaceId: 'test-workspace',
db: mockDb,
...overrides,
};
}
Testing Checklist (Per Feature)
Before considering a feature "done":
[ ] Unit tests for pure functions
[ ] Integration tests for tRPC procedures
[ ] Mocks for external services
[ ] Test IDs in UI components
[ ] Manual test on staging (if applicable)
[ ] E2E test for critical paths only
Common Anti-Patterns
Don't: Test implementation details
expect(component.state.isLoading).toBe(false);
expect(screen.getByText('Loading...')).not.toBeVisible();
Don't: Over-mock
vi.mock('@/db');
vi.mock('@/lib/api');
vi.mock('@/hooks/use-user');
vi.mock('@/lib/external-api');
Don't: Write E2E tests for everything
test('email validation shows error', async ({ page }) => {
});
test('user can complete payment flow', async ({ page }) => {
});
Integration with Other Skills
| Scenario | Skill to Use |
|---|
| Testing fails on staging | test-staging-branch |
| Need to debug prod data | debug prod issues |
| After completing tests | skill-reinforcement |
| Need Chrome automation | chrome-devtools-mcp |
Learnings Log
Add new learnings here as they're discovered
2024-12-29: Initial skill created
- Established testing pyramid hierarchy
- Created patterns for dependency injection and pure function extraction
- Added integration with other skills
2026-01-12: Next.js 16 async route params
- Dynamic API routes now receive
params as a Promise; await params before reading slug to avoid 404s in local CLI testing.
2026-01-12: Privy user provisioning defaults
- Privy create-user rejects
wallet_index and create_direct_signer; omit defaults and only send those fields when explicitly provided.
Quick Reference
pnpm --filter @zero-finance/web test
pnpm --filter @zero-finance/web test:watch
pnpm --filter @zero-finance/web exec playwright test
pnpm typecheck
pnpm lint