ALWAYS invoke this skill when writing or fixing tests for TypeScript.
From typescriptnpx claudepluginhub outcomeeng/claude --plugin typescriptThis skill is limited to using the following tools:
levels/level-1-unit.mdlevels/level-2-integration.mdlevels/level-3-e2e.mdSearches, retrieves, and installs Agent Skills from prompts.chat registry using MCP tools like search_skills and get_skill. Activates for finding skills, browsing catalogs, or extending Claude.
Searches prompts.chat for AI prompt templates by keyword or category, retrieves by ID with variable handling, and improves prompts via AI. Use for discovering or enhancing prompts.
Compares coding agents like Claude Code and Aider on custom YAML-defined codebase tasks using git worktrees, measuring pass rate, cost, time, and consistency.
PREREQUISITE: Run through the
/testingrouter first.This skill provides TypeScript-specific implementations for decisions made there. Do NOT skip the router—it determines WHAT to test and at WHAT level. This skill shows HOW to implement that decision in TypeScript.
Note on Analysis sections: When reading node specs, the Analysis section documents what the spec author examined. It provides context but is not binding — implementation may diverge as understanding deepens. Use it as a starting point, not a contract.
After running through the /testing router, use this mapping:
| Router Decision | TypeScript Implementation |
|---|---|
| Stage 2 → Level 1 | Vitest + temp dirs + type-safe DI |
| Stage 2 → Level 2 | Vitest + harness classes + Docker |
| Stage 2 → Level 3 | Vitest (CLI/API) or Playwright (browser) |
| Stage 3A (Pure computation) | Pure functions with explicit types |
| Stage 3B (Extract pure part) | Factor into typed pure functions + thin wrappers |
| Stage 5 Exception 1 (Failure modes) | Interface + stub class returning errors |
| Stage 5 Exception 2 (Interaction protocols) | Spy class with typed call recording |
| Stage 5 Exception 3 (Time/concurrency) | vi.useFakeTimers() or injected clock |
| Stage 5 Exception 4 (Safety) | Stub class that records but doesn't execute |
| Stage 5 Exception 6 (Observability) | Spy class capturing typed request details |
| Level | Infrastructure | Speed | Framework |
|---|---|---|---|
| 1: Unit | Node.js stdlib + temp dirs + standard dev tools | <50ms | Vitest |
| 2: Integration | Docker containers + project-specific binaries | <1s | Vitest |
| 3: E2E (CLI/API) | Network services + external APIs | <30s | Vitest |
| 3: E2E (Browser) | Chrome + real user flows | <30s | Playwright |
Standard dev tools (Level 1): git, node, npm, curl—available in CI without setup. Project-specific tools (Level 2): Docker, Hugo, Caddy, PostgreSQL—require installation.
When the router determines your code is pure computation, test it directly with full type safety.
import { describe, expect, it } from "vitest";
describe("buildLhciCommand", () => {
it("includes checksum flag when enabled", () => {
const cmd = buildLhciCommand({ checksum: true });
expect(cmd).toContain("--checksum");
});
it("preserves unicode paths", () => {
const cmd = buildLhciCommand({
source: "/tank/фото",
dest: "remote:резервная",
});
expect(cmd).toContain("/tank/фото");
expect(cmd).toContain("remote:резервная");
});
});
describe("validateConfig", () => {
it("rejects empty URL sets", () => {
const result = validateConfig({ url_sets: {} });
expect(result.ok).toBe(false);
expect(result.error).toContain("url_sets");
});
it("accepts valid config", () => {
const result = validateConfig({
site_dir: "./site",
url_sets: { critical: ["/", "/about/"] },
});
expect(result.ok).toBe(true);
});
});
Generate test data with full type inference. Never use arbitrary literals.
import { describe, expect, it } from "vitest";
type AuditResult = {
id: string;
url: string;
scores: {
performance: number;
accessibility: number;
};
};
let idCounter = 0;
function createAuditResult(overrides: Partial<AuditResult> = {}): AuditResult {
return {
id: `audit-${++idCounter}`,
url: `https://example.com/page-${idCounter}`,
scores: {
performance: 90,
accessibility: 100,
},
...overrides,
};
}
describe("analyzeResults", () => {
it("fails on low performance", () => {
const result = createAuditResult({
scores: { performance: 45, accessibility: 100 },
});
const analysis = analyzeResults([result], { minPerformance: 90 });
expect(analysis.passed).toBe(false);
});
});
Temp dirs are NOT external dependencies—use them freely at Level 1.
import { mkdtemp, rm, writeFile } from "fs/promises";
import { tmpdir } from "os";
import { join } from "path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
describe("loadConfig", () => {
let tempDir: string;
beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), "config-test-"));
});
afterEach(async () => {
await rm(tempDir, { recursive: true });
});
it("loads YAML config file", async () => {
const configPath = join(tempDir, "config.yaml");
await writeFile(
configPath,
`
site_dir: ./site
base_url: http://localhost:1313
`,
);
const config = await loadConfig(configPath);
expect(config.site_dir).toBe("./site");
expect(config.base_url).toBe("http://localhost:1313");
});
});
When the router says "extract the pure part," factor your code with explicit types.
class OrderProcessor {
constructor(private repository: OrderRepository) {}
async process(order: Order): Promise<void> {
// Validation (pure) mixed with persistence (integration)
if (!order.items.length) {
throw new ValidationError("Empty order");
}
if (order.total < 0) {
throw new ValidationError("Negative total");
}
await this.repository.save(order);
}
}
// Pure computation - test at Level 1, no doubles
type ValidationResult = { ok: true } | { ok: false; error: string };
function validateOrder(order: Order): ValidationResult {
if (!order.items.length) {
return { ok: false, error: "Empty order" };
}
if (order.total < 0) {
return { ok: false, error: "Negative total" };
}
return { ok: true };
}
// Thin wrapper - test at Level 2 with real database
class OrderProcessor {
constructor(private repository: OrderRepository) {}
async process(order: Order): Promise<void> {
const result = validateOrder(order);
if (!result.ok) {
throw new ValidationError(result.error);
}
await this.repository.save(order);
}
}
Test them separately:
// Level 1: Test validation logic exhaustively
describe("validateOrder", () => {
it("rejects empty order", () => {
const result = validateOrder({ items: [], total: 0 });
expect(result.ok).toBe(false);
});
it("rejects negative total", () => {
const result = validateOrder({ items: [item], total: -10 });
expect(result.ok).toBe(false);
});
it("accepts valid order", () => {
const result = validateOrder({ items: [item], total: 100 });
expect(result.ok).toBe(true);
});
});
// Level 2: Test persistence with real database (see Level 2 section)
When code has dependencies but you've determined Level 1 is appropriate (via router Stage 3), use type-safe DI.
// Define typed dependencies
type CommandResult = { exitCode: number; stdout: string; stderr: string };
type SyncDependencies = {
runCommand: (cmd: string, args: string[]) => Promise<CommandResult>;
getEnv: (key: string) => string | undefined;
};
async function syncToRemote(
source: string,
dest: string,
deps: SyncDependencies,
): Promise<SyncResult> {
const cmd = buildCommand(source, dest);
const result = await deps.runCommand(cmd[0], cmd.slice(1));
return {
success: result.exitCode === 0,
output: result.stdout,
};
}
// Test with controlled implementation
describe("syncToRemote", () => {
it("returns success on zero exit code", async () => {
const deps: SyncDependencies = {
runCommand: async () => ({ exitCode: 0, stdout: "Done", stderr: "" }),
getEnv: () => undefined,
};
const result = await syncToRemote("/src", "remote:dest", deps);
expect(result.success).toBe(true);
});
it("returns failure on non-zero exit code", async () => {
const deps: SyncDependencies = {
runCommand: async () => ({ exitCode: 1, stdout: "", stderr: "Error" }),
getEnv: () => undefined,
};
const result = await syncToRemote("/src", "remote:dest", deps);
expect(result.success).toBe(false);
});
});
When the router reaches Stage 5 and an exception applies, here's how to implement each in TypeScript.
Testing retry logic, error handling, circuit breakers.
type HttpClient = {
fetch(url: string): Promise<{ status: number; body: unknown }>;
};
describe("fetchWithRetry", () => {
it("retries on timeout", async () => {
let attempts = 0;
const client: HttpClient = {
async fetch(url) {
attempts++;
if (attempts < 3) {
throw new TimeoutError("Request timed out");
}
return { status: 200, body: "ok" };
},
};
const result = await fetchWithRetry("https://api.example.com", client);
expect(attempts).toBe(3);
expect(result.status).toBe(200);
});
it("stops retrying after max attempts", async () => {
const client: HttpClient = {
async fetch() {
throw new TimeoutError("Always fails");
},
};
await expect(
fetchWithRetry("https://api.example.com", client, { maxRetries: 3 }),
).rejects.toThrow(TimeoutError);
});
});
describe("CircuitBreaker", () => {
it("opens after threshold failures", async () => {
let callCount = 0;
const client: HttpClient = {
async fetch() {
callCount++;
throw new Error("Connection refused");
},
};
const breaker = new CircuitBreaker(client, { threshold: 3 });
// First 3 calls go through and fail
for (let i = 0; i < 5; i++) {
try {
await breaker.fetch("/");
} catch {}
}
expect(callCount).toBe(3); // Circuit opened after 3
expect(breaker.state).toBe("open");
});
});
Testing call sequences, saga compensation, "no extra calls."
describe("Saga", () => {
it("compensates in reverse order on failure", async () => {
const calls: string[] = [];
const steps = [
{
execute: async () => calls.push("step1-execute"),
compensate: async () => calls.push("step1-compensate"),
},
{
execute: async () => {
calls.push("step2-execute");
throw new Error("Step 2 failed");
},
compensate: async () => calls.push("step2-compensate"),
},
];
const saga = new Saga(steps);
await expect(saga.run()).rejects.toThrow();
expect(calls).toEqual([
"step1-execute",
"step2-execute",
"step2-compensate",
"step1-compensate",
]);
});
});
describe("CachingWrapper", () => {
it("does not refetch cached values", async () => {
let fetchCount = 0;
const client = {
async getUser(id: string) {
fetchCount++;
return { id, name: "Test" };
},
};
const cache = new CachingWrapper(client);
await cache.getUser("123");
await cache.getUser("123");
await cache.getUser("123");
expect(fetchCount).toBe(1);
});
});
Testing time-dependent behavior with Vitest fake timers or injected clock.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
describe("Lease", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("renews before expiry", async () => {
let renewCount = 0;
const lease = new Lease({
ttl: 30_000,
renewAt: 25_000,
onRenew: () => renewCount++,
});
// Before renewal threshold
await vi.advanceTimersByTimeAsync(24_000);
expect(renewCount).toBe(0);
// After renewal threshold
await vi.advanceTimersByTimeAsync(2_000);
expect(renewCount).toBe(1);
});
});
// Alternative: Injected clock for more control
type Clock = {
now(): number;
};
describe("TokenRefresher with injected clock", () => {
it("refreshes token before expiry", async () => {
let currentTime = 1000;
const clock: Clock = { now: () => currentTime };
let refreshed = false;
const refresher = new TokenRefresher({
expiresAt: 2000,
refreshBuffer: 100,
clock,
onRefresh: () => {
refreshed = true;
},
});
// Before refresh window
currentTime = 1899;
refresher.tick();
expect(refreshed).toBe(false);
// Inside refresh window
currentTime = 1901;
refresher.tick();
expect(refreshed).toBe(true);
});
});
Testing destructive operations without executing them.
type PaymentProvider = {
charge(amount: number, token: string): Promise<{ chargeId: string }>;
refund(chargeId: string, amount: number): Promise<{ refundId: string }>;
};
describe("OrderProcessor", () => {
it("processes refund for cancelled order", async () => {
const refunds: Array<{ chargeId: string; amount: number }> = [];
const payment: PaymentProvider = {
async charge() {
return { chargeId: "ch_123" };
},
async refund(chargeId, amount) {
refunds.push({ chargeId, amount });
return { refundId: "re_123" };
},
};
const processor = new OrderProcessor({ payment });
await processor.cancelOrder(orderWithCharge);
expect(refunds).toEqual([{ chargeId: "ch_123", amount: 99.99 }]);
});
});
type EmailService = {
send(to: string, subject: string, body: string): Promise<void>;
};
describe("OrderNotifier", () => {
it("sends shipping notification without real email", async () => {
const sentEmails: Array<{ to: string; subject: string }> = [];
const email: EmailService = {
async send(to, subject) {
sentEmails.push({ to, subject });
},
};
const notifier = new OrderNotifier({ email });
await notifier.notifyShipped(order);
expect(sentEmails).toHaveLength(1);
expect(sentEmails[0].to).toBe(order.customerEmail);
});
});
Testing request details the real system can't expose.
type HttpClient = {
post(url: string, options: { headers: Record<string, string>; body: unknown }): Promise<unknown>;
};
describe("PaymentClient", () => {
it("includes idempotency key in request", async () => {
const requests: Array<{ headers: Record<string, string> }> = [];
const http: HttpClient = {
async post(url, options) {
requests.push({ headers: options.headers });
return { id: "charge_123" };
},
};
const client = new PaymentClient({ http });
await client.charge(100, "tok_123");
expect(requests).toHaveLength(1);
expect(requests[0].headers["Idempotency-Key"]).toBeDefined();
});
});
type Database = {
query(sql: string, params: unknown[]): Promise<unknown>;
};
describe("UserRepository", () => {
it("batches inserts", async () => {
const queries: string[] = [];
const db: Database = {
async query(sql) {
queries.push(sql);
return { rowCount: 1 };
},
};
const repo = new UserRepository({ db });
await repo.bulkInsert([user1, user2, user3]);
// Should be ONE batch insert, not three
expect(queries).toHaveLength(1);
expect(queries[0]).toContain("INSERT INTO users");
});
});
When the router determines Level 2 is appropriate, use real dependencies via typed harnesses.
import { execa } from "execa";
import { cp, mkdtemp, rm } from "fs/promises";
import { tmpdir } from "os";
import { join } from "path";
type HugoHarness = {
siteDir: string;
outputDir: string;
build(args?: string[]): Promise<{ exitCode: number; stdout: string }>;
cleanup(): Promise<void>;
};
async function createHugoHarness(fixturePath?: string): Promise<HugoHarness> {
// Verify Hugo is installed
try {
await execa("hugo", ["version"]);
} catch {
throw new Error("Hugo not installed. Run: brew install hugo");
}
const siteDir = await mkdtemp(join(tmpdir(), "hugo-test-"));
const outputDir = join(siteDir, "public");
if (fixturePath) {
await cp(fixturePath, siteDir, { recursive: true });
} else {
await createMinimalSite(siteDir);
}
return {
siteDir,
outputDir,
async build(args = []) {
const result = await execa("hugo", ["--source", siteDir, ...args], {
reject: false,
});
return { exitCode: result.exitCode, stdout: result.stdout };
},
async cleanup() {
await rm(siteDir, { recursive: true, force: true });
},
};
}
import { afterAll, beforeAll, describe, expect, it } from "vitest";
describe("Hugo Integration", () => {
let harness: HugoHarness;
beforeAll(async () => {
harness = await createHugoHarness();
});
afterAll(async () => {
await harness.cleanup();
});
it("builds site successfully", async () => {
const result = await harness.build();
expect(result.exitCode).toBe(0);
});
it("creates index.html in output", async () => {
await harness.build();
const indexPath = join(harness.outputDir, "index.html");
expect(existsSync(indexPath)).toBe(true);
});
it("minifies output when flag is set", async () => {
const result = await harness.build(["--minify"]);
expect(result.exitCode).toBe(0);
// Verify minification by checking output size or content
});
});
import { Pool } from "pg";
type PostgresHarness = {
connectionString: string;
pool: Pool;
query<T>(sql: string, params?: unknown[]): Promise<T[]>;
reset(): Promise<void>;
cleanup(): Promise<void>;
};
async function createPostgresHarness(): Promise<PostgresHarness> {
const config = {
host: process.env.TEST_DB_HOST || "localhost",
port: parseInt(process.env.TEST_DB_PORT || "5432"),
database: "test_db",
user: "postgres",
password: "postgres",
};
const pool = new Pool(config);
// Verify connection
try {
await pool.query("SELECT 1");
} catch (error) {
throw new Error(
`Cannot connect to test database. Start it with: docker-compose up -d postgres`,
);
}
return {
connectionString: `postgresql://${config.user}:${config.password}@${config.host}:${config.port}/${config.database}`,
pool,
async query<T>(sql: string, params?: unknown[]): Promise<T[]> {
const result = await pool.query(sql, params);
return result.rows as T[];
},
async reset() {
await pool.query("DROP SCHEMA public CASCADE; CREATE SCHEMA public;");
},
async cleanup() {
await pool.end();
},
};
}
When the router determines Level 3 is required (real credentials, external services).
/**
* Level 3 tests require these environment variables:
*
* Required:
* LHCI_SERVER_URL - LHCI server URL
* LHCI_TOKEN - LHCI build token
*
* Where to find:
* - 1Password: "Engineering/Test Credentials"
*
* Setup:
* cp .env.test.example .env.test
* # Fill in values from 1Password
*/
type Credentials = {
lhciServerUrl: string;
lhciToken: string;
};
function loadCredentials(): Credentials | null {
const lhciServerUrl = process.env.LHCI_SERVER_URL;
const lhciToken = process.env.LHCI_TOKEN;
if (!lhciServerUrl || !lhciToken) {
return null;
}
return { lhciServerUrl, lhciToken };
}
function requireCredentials(): Credentials {
const creds = loadCredentials();
if (!creds) {
throw new Error(
"Missing required credentials. See test file for setup instructions.",
);
}
return creds;
}
import { describe, expect, it } from "vitest";
describe("LHCI E2E", () => {
const credentials = loadCredentials();
it.skipIf(!credentials)("uploads audit results", async () => {
const creds = requireCredentials();
const result = await uploadAuditResults({
serverUrl: creds.lhciServerUrl,
token: creds.lhciToken,
results: testResults,
});
expect(result.success).toBe(true);
});
});
// spx/.../tests/checkout.e2e.spec.ts (Playwright)
import { expect, test } from "@playwright/test";
test.describe("Checkout Flow", () => {
test("user can complete purchase", async ({ page }) => {
await page.goto("/products");
await page.click("[data-testid=\"add-to-cart\"]");
await page.click("[data-testid=\"checkout\"]");
await page.fill("[data-testid=\"email\"]", "test@example.com");
await page.fill("[data-testid=\"card-number\"]", "4242424242424242");
await page.click("[data-testid=\"submit\"]");
await expect(page.locator("[data-testid=\"confirmation\"]")).toBeVisible();
});
});
// vitest.config.ts
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["spx/**/*.test.ts"],
exclude: ["**/*.spec.ts"], // Playwright handles .spec.ts
testTimeout: 30000,
hookTimeout: 30000,
},
});
// playwright.config.ts
import { defineConfig } from "@playwright/test";
export default defineConfig({
testDir: "./spx",
testMatch: "**/*.e2e.spec.ts",
fullyParallel: true,
use: {
baseURL: process.env.TEST_BASE_URL || "http://localhost:3000",
},
});
Tests are co-located with specs in spx/. Level is indicated by suffix:
spx/
└── {NN}-{slug}.enabler/
└── {NN}-{slug}.outcome/
├── {slug}.outcome.md # Node spec
└── tests/
├── {name}.unit.test.ts # Level 1 (Vitest)
├── {name}.integration.test.ts # Level 2 (Vitest)
├── {name}.e2e.test.ts # Level 3, non-browser (Vitest)
└── {name}.e2e.spec.ts # Level 3, browser (Playwright)
Run by runner:
vitest spx/ # Runs *.test.ts
npx playwright test spx/ # Runs *.spec.ts
testing/
├── harnesses/
│ ├── index.ts
│ ├── hugo.ts # Hugo harness
│ ├── postgres.ts # PostgreSQL harness
│ └── factories.ts # Type-safe factories
└── fixtures/
└── values.ts # TYPICAL, EDGES collections
Import in tests:
import { createAuditResult } from "@testing/harnesses/factories";
import { createHugoHarness } from "@testing/harnesses/hugo";
| Aspect | Level 1 | Level 2 | Level 3 |
|---|---|---|---|
| Dependencies | DI with typed interfaces | Real via harness | Real via credentials |
| Data | Type-safe factories | Fixtures + harness | Test accounts |
| Speed | <50ms | <1s | <30s |
| CI | Every commit | Every commit | Nightly/pre-release |
// ❌ WRONG: Module-level mocking
vi.mock("execa", () => ({ execa: vi.fn() }));
it("runs hugo", async () => {
await buildHugo(siteDir);
expect(execa).toHaveBeenCalled(); // Proves nothing
});
// ✅ RIGHT: Dependency injection
type BuildDeps = {
runCommand: (cmd: string, args: string[]) => Promise<{ exitCode: number }>;
};
it("returns success on zero exit", async () => {
const deps: BuildDeps = {
runCommand: async () => ({ exitCode: 0 }),
};
const result = await buildHugo(siteDir, deps);
expect(result.success).toBe(true);
});
// ❌ WRONG: vi.fn() tests HOW, not WHAT
it("calls execa with args", async () => {
const deps = { execa: vi.fn().mockResolvedValue({ exitCode: 0 }) };
await buildHugo(siteDir, deps);
expect(deps.execa).toHaveBeenCalledWith("hugo", ["--minify"]);
});
// ✅ RIGHT: Test observable behavior
it("produces minified output", async () => {
const result = await buildHugo(siteDir, realDeps);
expect(result.minified).toBe(true);
});
// ❌ WRONG: Casting to any
const deps = { runCommand: vi.fn() } as any;
// ✅ RIGHT: Full type safety
const deps: BuildDeps = {
runCommand: async () => ({ exitCode: 0 }),
};
// ❌ WRONG: Testing that Zod works
it("validates with Zod schema", () => {
const schema = z.object({ url: z.string().url() });
expect(schema.parse({ url: "http://example.com" })).toBeDefined();
});
// ✅ RIGHT: Test YOUR behavior that uses validation
it("rejects invalid config with descriptive error", () => {
const result = loadConfig({ url: "not-a-url" });
expect(result.ok).toBe(false);
expect(result.error).toContain("url");
});