
Test Driven Development
- 56 installs
- 14 repo stars
- Updated March 2, 2026
- oakoss/agent-skills
Helps with testing & qa tasks during AI-assisted development.
About
test-driven-development is a Claude Code skill for testing & qa. It helps solo builders move faster with AI-assisted coding.
- test-driven-development
- Testing & QA
- AI-coding skill
Test Driven Development by the numbers
- 56 all-time installs (skills.sh)
- +1 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #1,185 of 2,153 Testing & QA skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/oakoss/agent-skills --skill test-driven-developmentAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 56 |
|---|---|
| repo stars | ★ 14 |
| Last updated | March 2, 2026 |
| Repository | oakoss/agent-skills ↗ |
What it does
Helps with testing & qa tasks during AI-assisted development.
Files
Test-Driven Development
Write one failing test that describes the next behavior, write the minimal code to pass it, then refactor while green. Every production change starts with a test that proves the need exists. This is the discipline within each vertical slice — not framework syntax, but when and why to write tests.
Skip for exploratory prototyping, config-only changes (environment variables, feature flags), visual/CSS-only tweaks, and one-off scripts that won't be maintained.
Quick Reference — TDD Cycle
| Phase | Action | Rule |
|---|---|---|
| RED | Write one test that fails for the right reason | No production code without a failing test |
| GREEN | Write the simplest code that makes the test pass | Resist the urge to generalize prematurely |
| REFACTOR | Improve structure while all tests stay green | Never change behavior and structure at once |
Quick Reference — Principles
| Principle | Practice |
|---|---|
| One test at a time | Complete the full red-green-refactor cycle before writing the next test |
| Behavior over implementation | Test what code does, not how it does it; assert outputs and side effects |
| Vertical slices | TDD one end-to-end behavior at a time, not one layer at a time |
| Mock at boundaries | Mock I/O, network, filesystem, clock. Never mock internal collaborators |
| Deep modules | Design small interfaces with rich internals; fewer tests cover more behavior |
| Refactor only when green | All tests pass before touching structure; refactoring under red hides new bugs |
| Design through tests | The first test IS the API design decision; awkward tests signal awkward interfaces |
| Failing test for every bug | Reproduce the bug as a failing test before writing the fix |
Planning Before Coding
| Step | Action | Output |
|---|---|---|
| 1 | Identify behaviors | List of observable behaviors the feature must exhibit |
| 2 | Order by dependency | Sequence where each test builds on prior passing tests |
| 3 | Design the interface | Function signatures, parameter types, return types |
| 4 | Identify mock boundaries | External dependencies that need test doubles |
Common Mistakes
| Mistake | Correct Pattern |
|---|---|
| Writing all tests first | One test at a time. Complete the cycle before starting the next |
| Testing private methods | Test through the public interface; private methods are implementation detail |
| Mocking internal collaborators | Only mock at I/O boundaries; trust your own code |
| Writing code before a failing test | RED first. The failing test proves the behavior is missing |
| Refactoring while tests are red | Get to GREEN first, then refactor with confidence |
| Tests coupled to call sequences | Assert on outputs and state, not on which internal methods were called |
| Skipping the refactor phase | GREEN is not done. Clean up duplication and naming before moving on |
| Hardcoding expected values | Use triangulation: add a second test case to force the general solution |
| One giant test per feature | One test per behavior — small, focused, independently meaningful |
Delegation
- Slice decomposition: If the
tracer-bulletsskill is available, use it to decompose features into vertical slices before applying TDD within each slice - Test framework syntax: Delegate framework-specific patterns to
vitest-testing,e2e-testing, orapi-testingskills - Feature planning: If the
plan-first-developmentskill is available, use it for upfront planning before entering the TDD cycle - Individual TDD cycles: Delegate each red-green-refactor cycle to a
Tasksubagent to keep context focused
References
- Test Quality — good vs bad tests, what to test, naming, triangulation, refactoring signals
- Mocking Boundaries — boundary rule, test doubles, dependency injection, anti-patterns
- Design for Testability — deep modules, interface-first design, pure functions, computation vs I/O
Design for Testability
Deep Modules
From John Ousterhout's "A Philosophy of Software Design": a deep module has a small interface relative to the functionality it provides. Deep modules are easier to test because fewer tests cover more behavior.
Shallow Module (Hard to Test)
class UserValidator {
validateEmail(email: string): boolean {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
validateName(name: string): boolean {
return name.length >= 2 && name.length <= 50;
}
validateAge(age: number): boolean {
return age >= 0 && age <= 150;
}
validateAddress(address: Address): boolean {
return !!address.street && !!address.city && !!address.zip;
}
}Four public methods, each trivial. Every internal change forces a test update. The class is just a bag of functions with no cohesion.
Deep Module (Easy to Test)
type ValidationResult = { valid: true } | { valid: false; errors: string[] };
function validateUser(input: unknown): ValidationResult {
const errors: string[] = [];
if (!isValidEmail(input.email)) errors.push('Invalid email');
if (!isValidName(input.name)) errors.push('Name must be 2-50 characters');
if (!isValidAge(input.age)) errors.push('Age must be 0-150');
if (!isValidAddress(input.address)) errors.push('Address is incomplete');
return errors.length === 0 ? { valid: true } : { valid: false, errors };
}One public function, one test surface. Internal validation helpers are private. They can change freely. Tests cover the behavior: valid input passes, invalid input returns specific errors.
describe('validateUser', () => {
it('accepts valid user input', () => {
const result = validateUser({
email: 'alice@example.com',
name: 'Alice',
age: 30,
address: { street: '123 Main', city: 'Springfield', zip: '62701' },
});
expect(result).toEqual({ valid: true });
});
it('collects all validation errors', () => {
const result = validateUser({
email: 'bad',
name: 'A',
age: -1,
address: { street: '', city: '', zip: '' },
});
expect(result.valid).toBe(false);
expect(result.errors).toHaveLength(4);
});
});Interface-First Design
The first test is an API design decision. Writing the test before the implementation forces you to design the interface from the caller's perspective.
The Process
1. Write the test call: const result = processOrder(items, discount);
2. Define the interface: What does processOrder accept? What does it return?
3. Implement to pass: Build the internals to satisfy the contractIf the test is awkward to write, the interface is awkward to use. Fix the interface before implementing.
Awkward Test = Awkward Interface
// Awkward: caller must know internal steps
const processor = new OrderProcessor();
processor.setItems(items);
processor.setDiscount(discount);
processor.validate();
processor.calculateTotals();
const result = processor.getResult();The test reveals that the caller must call five methods in the right order. The interface leaks implementation steps.
Clean Test = Clean Interface
// Clean: one call, clear contract
const result = processOrder(items, discount);
expect(result.total).toBe(180);
expect(result.discount).toBe(20);
expect(result.items).toHaveLength(3);The test reads like a specification. One function call, clear inputs, clear outputs.
Return Values Over Side Effects
Pure functions that return values are trivially testable. Functions that mutate state or trigger side effects require setup, teardown, and assertions on external state.
Hard to Test: Side Effects
let notifications: string[] = [];
function processRefund(order: Order) {
order.status = 'refunded';
order.refundedAt = new Date();
notifications.push(`Refund processed for order ${order.id}`);
db.orders.update(order);
emailService.send(order.customerEmail, 'Your refund has been processed');
}Testing requires mocking db, emailService, capturing notifications, and checking mutation of the order object. Five things to verify for one function.
Easy to Test: Return Value
type RefundResult = {
updatedOrder: Order;
notification: string;
email: { to: string; body: string };
};
function processRefund(order: Order, now: Date = new Date()): RefundResult {
return {
updatedOrder: { ...order, status: 'refunded', refundedAt: now },
notification: `Refund processed for order ${order.id}`,
email: {
to: order.customerEmail,
body: 'Your refund has been processed',
},
};
}it('produces refund result with updated status and notification', () => {
const order = { id: '123', status: 'paid', customerEmail: 'a@b.com' };
const now = new Date('2025-01-15');
const result = processRefund(order, now);
expect(result.updatedOrder.status).toBe('refunded');
expect(result.updatedOrder.refundedAt).toEqual(now);
expect(result.notification).toContain('123');
expect(result.email.to).toBe('a@b.com');
});No mocks, no setup, no teardown. The caller decides what to do with the result — persist it, send the email, or both.
Separate Computation from I/O
Structure applications as a pure computational core with thin I/O adapters at the boundaries.
┌─────────────────────────────────┐
│ I/O Boundary │ ← Thin adapter: HTTP, DB, filesystem
├─────────────────────────────────┤
│ Pure Computation │ ← Rich logic: validation, transforms,
│ │ business rules, calculations
├─────────────────────────────────┤
│ I/O Boundary │ ← Thin adapter: persist, notify, respond
└─────────────────────────────────┘Example: Order Processing
// Pure computation — testable without mocks
function buildInvoice(order: Order, taxRate: number): Invoice {
const subtotal = order.items.reduce(
(sum, item) => sum + item.price * item.qty,
0,
);
const tax = Math.round(subtotal * taxRate);
return {
orderId: order.id,
subtotal,
tax,
total: subtotal + tax,
lineItems: order.items.map((item) => ({
description: item.name,
amount: item.price * item.qty,
})),
};
}
// I/O adapter — thin, delegates to pure core
async function handleOrderRequest(
req: Request,
db: Database,
taxService: TaxService,
): Promise<Response> {
const order = await db.orders.findById(req.params.id);
const taxRate = await taxService.getRateForRegion(order.region);
const invoice = buildInvoice(order, taxRate);
await db.invoices.save(invoice);
return Response.json(invoice);
}buildInvoice is pure — test it exhaustively with simple input/output assertions. handleOrderRequest is a thin adapter — integration test it once to verify the wiring.
Applying the Pattern
| Layer | Characteristics | Testing strategy |
|---|---|---|
| I/O adapters | Thin, no business logic | Integration tests, few cases |
| Pure computation | No dependencies, deterministic | Unit tests, many cases |
| Orchestration | Wires adapters to computation | Integration tests, happy + error |
The majority of tests target the pure computation layer. Adapters are tested sparingly — they're too simple to contain bugs worth catching.
Design Heuristic
When a function is hard to test, ask: "Can I split the pure computation from the I/O?" Almost always, yes. Extract the computation, return data, and let the caller handle I/O.
Mocking Boundaries
The Boundary Rule
Mock at I/O boundaries. Never mock internal collaborators.
| Mock (external boundary) | Do not mock (internal collaborator) |
|---|---|
HTTP clients / fetch | Your own service classes |
| Database connections | Utility functions in the same codebase |
| Filesystem operations | Domain logic modules |
System clock / Date.now | Data transformation helpers |
| Email/SMS services | Validation functions |
| Third-party API SDKs | State management stores |
| Random number generators | Event emitters you own |
Internal collaborators are implementation details. Mocking them couples tests to structure, not behavior.
Test Double Taxonomy
| Double | What It Does | When to Use |
|---|---|---|
| Stub | Returns canned data, no assertions | Replace a dependency to control inputs |
| Spy | Records calls, delegates to real impl | Verify a side effect happened (email sent, event emitted) |
| Fake | Working implementation, simplified | In-memory database, local filesystem, fake clock |
| Mock | Pre-programmed expectations | When call order and exact arguments matter (rare) |
Prefer stubs and fakes. They make tests less brittle than mocks with strict call expectations.
Stub Example
import { describe, it, expect, vi } from 'vitest';
import { PricingService } from './pricing-service';
import { type ExchangeRateClient } from './exchange-rate-client';
describe('PricingService.convertPrice', () => {
it('converts USD to EUR using current rate', () => {
const rateClient: ExchangeRateClient = {
getRate: vi.fn().mockResolvedValue(0.85),
};
const service = new PricingService(rateClient);
const result = await service.convertPrice(100, 'USD', 'EUR');
expect(result).toBe(85);
});
});The stub controls the external boundary (exchange rate API) without asserting how it was called.
Fake Example
import { describe, it, expect } from 'vitest';
import { UserRepository } from './user-repository';
class InMemoryUserStore {
private users = new Map<string, User>();
async save(user: User) {
this.users.set(user.id, user);
}
async findById(id: string) {
return this.users.get(id) ?? null;
}
}
describe('UserRepository', () => {
it('retrieves a saved user', async () => {
const store = new InMemoryUserStore();
const repo = new UserRepository(store);
await repo.create({ id: '1', name: 'Alice' });
const found = await repo.findById('1');
expect(found).toEqual({ id: '1', name: 'Alice' });
});
});The fake provides a working in-memory implementation. Tests run fast and verify real behavior without a database.
Dependency Injection for Testability
Accept dependencies as parameters instead of importing them directly. This makes boundaries mockable without patching modules.
Bad: Direct Import
import { fetch } from 'undici';
export async function getUser(id: string) {
const res = await fetch(`https://api.example.com/users/${id}`);
return res.json();
}Testing requires patching the fetch module — fragile and couples tests to import structure.
Good: Parameter Injection
type HttpClient = {
get: (url: string) => Promise<Response>;
};
export async function getUser(id: string, client: HttpClient) {
const res = await client.get(`https://api.example.com/users/${id}`);
return res.json();
}Tests inject a stub client. Production code injects the real client. No module patching needed.
Good: Default Parameter
type HttpClient = {
get: (url: string) => Promise<Response>;
};
export async function getUser(
id: string,
client: HttpClient = defaultHttpClient,
) {
const res = await client.get(`https://api.example.com/users/${id}`);
return res.json();
}Default parameter keeps the call site clean while remaining testable.
Anti-Patterns
Mocking the Module Under Test
// WRONG: mocking internal methods of the thing you're testing
const service = new OrderService();
vi.spyOn(service, 'calculateTotal').mockReturnValue(100);
service.placeOrder(items);
expect(service.calculateTotal).toHaveBeenCalled();This tests that your mock was called, not that the code works. Test through the public interface instead.
Mock Chains
// WRONG: deep mock chain reveals coupling
const mockDb = {
connection: {
manager: {
query: vi.fn().mockResolvedValue([{ id: 1 }]),
},
},
};If a test requires reaching three levels deep into an object, the production code has the same coupling problem. Wrap the database behind a simple interface.
Asserting Call Counts
// WRONG: brittle — breaks if implementation caches or batches
expect(fetchSpy).toHaveBeenCalledTimes(3);Assert on the outcome, not how many times an internal dependency was invoked. The implementation may batch, cache, or deduplicate — that's its prerogative.
Over-Mocking: Everything Is a Mock
// WRONG: mocking your own utility
vi.mock('./utils/format-currency');formatCurrency is a pure function you own. Use the real implementation. The test should verify that the formatted result appears in the output, not that a formatter was called.
Test Quality
Good Tests vs Bad Tests
Good tests assert behavior through the public interface. Bad tests mirror implementation details and break on every refactor.
Bad: Testing Implementation Details
import { describe, it, expect, vi } from 'vitest';
import { OrderService } from './order-service';
describe('OrderService', () => {
it('calls validateItems and calculateTotal internally', () => {
const service = new OrderService();
const validateSpy = vi.spyOn(service as any, 'validateItems');
const calculateSpy = vi.spyOn(service as any, 'calculateTotal');
service.placeOrder([{ id: 'a', price: 10, qty: 2 }]);
expect(validateSpy).toHaveBeenCalled();
expect(calculateSpy).toHaveBeenCalledWith([{ id: 'a', price: 10, qty: 2 }]);
});
});This test breaks if internal method names change, even when behavior is identical. It tests HOW, not WHAT.
Good: Testing Through the Public Interface
import { describe, it, expect } from 'vitest';
import { OrderService } from './order-service';
describe('OrderService.placeOrder', () => {
it('returns order total for valid items', () => {
const service = new OrderService();
const result = service.placeOrder([{ id: 'a', price: 10, qty: 2 }]);
expect(result.total).toBe(20);
expect(result.status).toBe('confirmed');
});
it('rejects empty item list', () => {
const service = new OrderService();
expect(() => service.placeOrder([])).toThrow(
'Order must contain at least one item',
);
});
});Tests survive internal refactors. They describe what the caller experiences.
What to Test
| Test | Example |
|---|---|
| Public return values | expect(calculate(10, 20)).toBe(30) |
| Observable side effects | expect(db.users.count()).toBe(1) after createUser() |
| Error paths | expect(() => withdraw(-1)).toThrow("Amount must be positive") |
| Edge cases | Empty inputs, boundary values, null/undefined |
| State transitions | expect(order.status).toBe("shipped") after ship(order) |
| Skip | Why |
|---|---|
| Private methods | Tested implicitly through public interface |
| Getters/setters | Trivial; no logic to verify |
| Framework internals | Trust the framework; test your code |
| Third-party library behavior | Not your responsibility |
| Type-level constraints | TypeScript enforces these at compile time |
Test Naming
Name tests after behavior, not method names. A reader should understand what broke without reading the test body.
| Bad | Good |
|---|---|
test validateToken | rejects expired tokens |
test handleSubmit | submits form and redirects to dashboard |
test processPayment | charges the card and sends receipt email |
test calculateDiscount | applies 10% discount for orders over $100 |
test error handling | returns 404 when user does not exist |
Use describe blocks to group by feature or scenario, not by class:
describe('checkout', () => {
describe('with valid payment', () => {
it('charges the card and creates the order', () => {
// ...
});
it('sends confirmation email', () => {
// ...
});
});
describe('with expired card', () => {
it('returns payment error without creating an order', () => {
// ...
});
});
});Test Structure: Arrange-Act-Assert
Every test follows three phases. Blank lines separate them visually.
it('applies percentage discount to order total', () => {
// Arrange
const order = createOrder({ items: [{ price: 100, qty: 2 }] });
const discount = { type: 'percentage' as const, value: 10 };
// Act
const result = applyDiscount(order, discount);
// Assert
expect(result.total).toBe(180);
expect(result.savings).toBe(20);
});Keep each phase short. If arrange needs 20 lines, extract a factory function. If act needs multiple steps, the interface may need simplification.
Triangulation
When the simplest code to pass a test is a hardcoded value, add a second test case to force generalization.
// First test — could pass with `return 20`
it('calculates total for single item', () => {
expect(calculateTotal([{ price: 10, qty: 2 }])).toBe(20);
});
// Second test — forces the real implementation
it('calculates total for multiple items', () => {
expect(
calculateTotal([
{ price: 10, qty: 2 },
{ price: 5, qty: 3 },
]),
).toBe(35);
});Triangulation applies when the first GREEN pass is trivially hardcoded. If the first implementation is already general, skip the extra case.
Refactoring Signals
Functions that are hard to test reveal design problems. The fix is not a more clever test — it is a better design.
| Signal | Design Problem | Fix |
|---|---|---|
| Test needs 10+ lines of setup | Function has too many dependencies | Extract a focused function with fewer inputs |
| Must mock internal collaborators | Tight coupling between modules | Inject dependencies; split responsibilities |
| Test breaks on unrelated changes | Function does too many things | Single responsibility — split into smaller units |
| Cannot test without side effects | Logic mixed with I/O | Separate pure computation from I/O |
| Asserting on call sequences | Testing implementation, not behavior | Assert on outputs and observable state |
| Need to access private state | Public interface is incomplete | Expose behavior through public methods |