TDD guidance for behavior-first code design.

Install

mkdir -p .claude/skills/tdd-chrisreddington && curl -L -o skill.zip "https://agentskills.codes/api/skills/download/10098" && unzip -o skill.zip -d .claude/skills/tdd-chrisreddington && rm skill.zip

Installs to .claude/skills/tdd-chrisreddington

Activation

This is the description your AI agent reads to decide when to run this skill — the better it matches your request, the more reliably it fires.

Test-Driven Development (TDD) is a tight, behaviour-first development loop:
75 chars · catalog descriptionno explicit “when” trigger
Intermediate

Key capabilities

  • Write failing tests first
  • Enforce red-green-refactor loop
  • Ensure regression safety
  • Document behavior via tests
  • Refactor code with safety net

How it works

It enforces a cycle of writing a failing test, implementing minimal code to pass, and refactoring while maintaining a green test state.

Inputs & outputs

You give it
behavior description
You get back
passing test and production code

When to use tdd

  • Writing test-first code
  • Ensuring regression safety
  • Designing modular components

About this skill

TDD Skill

Overview

Test-Driven Development (TDD) is a tight, behaviour-first development loop:

  1. Red — write a failing test that describes the next behaviour you want.
  2. Green — write the smallest amount of production code that makes the test pass.
  3. Refactor — improve the internal structure of the code (and the test) without changing observable behaviour. The just-written test is your safety net.

You repeat the loop one small behaviour at a time. Each cycle is typically seconds to a few minutes — not hours.

Why TDD matters in this codebase:

  • Regression safety — every behaviour ships with a test that prevents it from silently breaking later. This matters most for security-sensitive code (src/lib/security/), multi-tenant isolation, and rate limiting.
  • Design feedback — if a test is painful to write, the design is usually wrong (too many collaborators, hidden globals, time-coupled state). TDD surfaces those problems before the production code calcifies around them.
  • Documentation — the test file is the executable specification. New contributors read *.test.ts next to a module to understand what it actually guarantees.

TDD is not "write tests after". The discipline is the order: failing test first, then code.

Authoritative reference

All testing rules in this repo live in .github/instructions/testing.instructions.md.

That file is the single source of truth for: the test pyramid, what counts as a unit vs. E2E test, naming, mocking policy, and Playwright/Vitest specifics. This skill describes how to practise TDD on top of those rules — it does not redefine them.

The Red–Green–Refactor loop in this repo

Red — write the failing test first

Start by encoding the next behaviour as a test. The test must fail for the right reason before you write any production code. "Right reason" means the assertion fails, not that the import is missing or there's a typo.

Real example from this codebase — src/lib/security/rate-limit.test.ts:

it('allows requests up to the limit and blocks beyond it', () => {
  for (let i = 0; i < 3; i += 1) {
    expect(checkRateLimit('user-1', 3, 60_000).allowed).toBe(true);
  }
  const blocked = checkRateLimit('user-1', 3, 60_000);
  expect(blocked.allowed).toBe(false);
  expect(blocked.retryAfterMs).toBeGreaterThan(0);
});

Notice what this test does well:

  • Describes behaviour ("allows N, blocks N+1"), not implementation (no peeking at the internal map).
  • Asserts on the public return shape (allowed, retryAfterMs).
  • Uses concrete, readable numbers (3, 60_000) instead of cleverly named constants.

Another good "red" pattern — cross-user leak tests in src/test/integration/multitenancy.test.ts:

describe('multi-tenant auth/token isolation', () => {
  // ...mocks omitted for brevity
  it('passes the exact per-user token to the Octokit constructor', () => {
    getOctokitForToken(TOKEN_A);
    getOctokitForToken(TOKEN_B);
    expect(octokitConstructorSpy).toHaveBeenNthCalledWith(1, { auth: TOKEN_A });
    expect(octokitConstructorSpy).toHaveBeenNthCalledWith(2, { auth: TOKEN_B });
  });
});

Write the leak test before you trust the isolation code. If you can't make the test fail by deleting the isolation logic, the test isn't proving anything.

Green — minimum code to pass

Once red is red for the right reason, write the simplest thing that turns it green:

  • Hardcode a return value if that's all the test demands.
  • Add a single if branch rather than a whole abstraction.
  • Resist the urge to add the second feature ("while I'm here…"). The next failing test will pull it out of you naturally.

The goal of green is not "good code". The goal is "passing test, fast feedback". Quality comes in the next step.

Refactor — now improve structure

With a green bar, you can safely:

  • Extract helpers and rename for clarity.
  • Collapse duplication between the test and the implementation.
  • Tighten types, narrow visibility (export → file-local), remove dead branches.
  • Improve the test too — extract setup helpers, rename, deduplicate.

Run the test after each small refactor. If it goes red, undo the last change. The cycle is: tiny edit → test → tiny edit → test.

Do not add new behaviour during refactor. New behaviour requires a new failing test (back to red).

How TDD fits Vitest in this repo

Commands

# TDD inner loop — re-runs tests on every save. Use this while coding.
npm run test:watch

# Full run — what CI and pre-commit will do.
npm test

# Coverage — useful occasionally to find untested branches, not every cycle.
npm run test:coverage

npm run test:watch is the TDD workflow. Keep it running in a side terminal. Save a file → see the bar flip red or green within a second or two.

Project mocking conventions

The project has standardised mocks defined in src/test/setup.tsglobal.fetch, window.matchMedia, and document.adoptedStyleSheets are stubbed for every test, and vi.clearAllMocks() runs in a global beforeEach. Don't re-mock those at the file level.

Environment variables — use vi.stubEnv, not process.env.X = …. The GitHub client used to read GITHUB_TOKEN from the environment; that ambient path is now deleted, and the canonical use of vi.stubEnv in this repo is in the negative (anti-pattern) tests that pin its absence. Example from src/test/integration/multitenancy.test.ts:

it('setting GITHUB_TOKEN does not confer access without a session', async () => {
  vi.stubEnv('GITHUB_TOKEN', 'ghp_should_not_be_used_xxxxxxxxxxxxxxxx');
  requireUserContextMock.mockImplementationOnce(() => {
    throw new UnauthorizedError();
  });

  await expect(githubClient.getOctokitForRequest()).rejects.toBeInstanceOf(
    UnauthorizedError
  );
  // No Octokit should have been constructed from the env var.
  expect(octokitConstructorSpy).not.toHaveBeenCalled();
});

vi.stubEnv is auto-restored by Vitest between tests, so you don't need an afterEach to undo it. Reach for it whenever a test exercises (or, as here, deliberately disables) an env-var-driven code path.

Module mocks — use vi.mock at top of file, with the import after the mock. Example from src/test/integration/multitenancy.test.ts:

vi.mock('@github/copilot-sdk', () => {
  class CopilotClient {
    createSession = createSessionMock;
  }
  return { CopilotClient, approveAll: vi.fn() };
});

vi.mock('octokit', () => ({
  Octokit: vi.fn().mockImplementation(/* … */),
}));

// Imports come AFTER vi.mock — Vitest hoists the mock above them.
import { getOctokitForToken } from '@/lib/github/client';

For next-auth/jwt and other auth-layer modules, follow the same pattern: declare the mock factory at the top, then import the system-under-test.

Patterns from this codebase you should mimic

Leak tests — cross-user/cross-tenant assertions

In src/test/integration/multitenancy.test.ts the suite drives the system with two different tokens (TOKEN_A, TOKEN_B) and asserts that data, mocks, and sessions for user A never appear in user B's calls. When you add a feature that holds per-user state (caches, sessions, tokens, audit logs), write a leak test of this shape:

it('does not leak state between users', () => {
  doSomethingAs('user-a');
  const resultB = doSomethingAs('user-b');
  expect(resultB).not.toContain('user-a-data');
});

If you cannot make this test fail by deliberately breaking the isolation, the test isn't actually checking isolation — rewrite it until you can.

State reset — expose a test-only reset hook

Modules that hold process-global state should export a reset function intended purely for tests. Example: src/lib/security/rate-limit.ts exports __resetRateLimitState(), and its test file calls it in afterEach:

afterEach(() => {
  __resetRateLimitState();
  vi.useRealTimers();
});

Conventions:

  • Prefix with __ to signal "internal, not part of the public API".
  • Document it as test-only in TSDoc.
  • Always pair it with restoring fake timers / stubbed modules in the same hook.

This is much safer than relying on test files to construct fresh modules — it keeps tests independent without per-test module reloading.

Deterministic clocks — vi.useFakeTimers() for time-based logic

Any code with TTLs, sliding windows, retries, or session expiry must be tested with fake timers. From rate-limit.test.ts:

it('slides the window so old timestamps eventually expire', () => {
  vi.useFakeTimers();
  vi.setSystemTime(new Date('2025-01-01T00:00:00Z'));

  for (let i = 0; i < 3; i += 1) {
    expect(checkRateLimit('user-1', 3, 1_000).allowed).toBe(true);
  }
  expect(checkRateLimit('user-1', 3, 1_000).allowed).toBe(false);

  vi.advanceTimersByTime(1_500);
  expect(checkRateLimit('user-1', 3, 1_000).allowed).toBe(true);
});

Always pair vi.useFakeTimers() with vi.useRealTimers() in afterEach, and prefer vi.setSystemTime over relying on "now" being any particular value.

Anti-patterns to avoid

  • Asserting implementation details. Don't assert that an internal Map has size 1, that a private helper was called, or that a particular if branch ran. Assert on the return value and observable side effects. If you must spy on an internal call, ask whether the seam belongs at a higher level.
  • Snapshot tests for everything. Snapshots are useful for stable, intentional output (e.g. a rendered Markdown string). They are noise for object shapes that legitimately evolve — they encourage "

Content truncated.

When not to use it

  • When writing tests after production code
  • When batching multiple behaviors

Prerequisites

npm run test:watch

Limitations

  • Requires failing test first
  • No new behavior during refactor

How it compares

It mandates a strict behavior-first order that prevents production code from calcifying before tests exist.

Compared to similar skills

tdd side by side with the closest alternatives in the catalog.

SkillInstallsUpdatedSafetyDifficulty
tdd (this skill)02moReviewIntermediate
python-testing-patterns772moReviewIntermediate
dependency-upgrade265moReviewIntermediate
test-cases577moNo flagsBeginner

Try saying

Example prompts that trigger this skill in your AI assistant.

Search skills

Search the agent skills registry