/writing-good-tests
Use when writing or reviewing tests - covers test philosophy, condition-based waiting, mocking strategy, and test isolation
$ npx -y skills add ed3dai/ed3d-plugins --skill writing-good-tests --agent claude-codeHow it fires
How this skill gets triggered: by you, by Claude, or both.
- Fires itselfAuto-invocation. Claude auto-loads it when your prompt matches the work.
- You can call itInvoke it directly when you want it.
- Slash command
/writing-good-tests
Context preview
The summary Claude sees to decide when to auto-load this skill.
Use when writing or reviewing tests - covers test philosophy, condition-based waiting, mocking strategy, and test isolation
SKILL.md
writing-good-tests.SKILL.mdname: writing-good-tests
description: Use when writing or reviewing tests - covers test philosophy, condition-based waiting, mocking strategy, and test isolation
user-invocable: false
Writing Good Tests
Philosophy
**"Write tests. Not too many. Mostly integration."** — Kent C. Dodds
Tests verify real behavior, not implementation details. The goal is confidence that your code works, not coverage numbers.
**Core principles:** 1. Test behavior, not implementation — refactoring shouldn't break tests 2. Integration tests provide better confidence-to-cost ratio than unit tests 3. Wait for actual conditions, not arbitrary timeouts 4. Mock strategically — real dependencies when feasible, mocks for external systems 5. Don't pollute production code with test-only methods
Test Structure
Use **Arrange-Act-Assert** (or Given-When-Then):
test('user can cancel reservation', async () => {
// Arrange
const reservation = await createReservation({ userId: 'user-1', roomId: 'room-1' });
// Act
const result = await cancelReservation(reservation.id);
// Assert
expect(result.status).toBe('cancelled');
expect(await getReservation(reservation.id)).toBeNull();
});**One action per test.** Multiple assertions are fine if they verify the same behavior.
Condition-Based Waiting
Flaky tests often guess at timing. This creates race conditions where tests pass locally but fail in CI.
**Wait for conditions, not time:**
// BAD: Guessing at timing
await new Promise(r => setTimeout(r, 50));
const result = getResult();
// GOOD: Waiting for condition
await waitFor(() => getResult() !== undefined);
const result = getResult();
Generic Polling Function
async function waitFor<T>(
condition: () => T | undefined | null | false,
description: string,
timeoutMs = 5000
): Promise<T> {
const startTime = Date.now();
while (true) {
const result = condition();
if (result) return result;
if (Date.now() - startTime > timeoutMs) {
throw new Error(`Timeout waiting for ${description} after ${timeoutMs}ms`);
}
await new Promise(r => setTimeout(r, 10)); // Poll every 10ms
}
}Quick Patterns
| Scenario | Pattern | |----------|---------| | Wait for event | `waitFor(() => events.find(e => e.type === 'DONE'))` | | Wait for state | `waitFor(() => machine.state === 'ready')` | | Wait for count | `waitFor(() => items.length >= 5)` |
When Arbitrary Timeout IS Correct
Only when testing actual timing behavior (debounce, throttle, intervals):
// Testing tool that ticks every 100ms
await waitForEvent(manager, 'TOOL_STARTED'); // First: wait for condition
await new Promise(r => setTimeout(r, 200)); // Then: wait for 2 ticks
// Comment explains WHY: 200ms = 2 ticks at 100ms intervals
Mocking Strategy
> "You don't hate mocks; you hate side-effects." — J.B. Rainsberger
Mocks reveal where side-effects complicate your code. Use them strategically, not reflexively.
Don't Mock What You Don't Own
Create thin wrappers around third-party libraries. Mock YOUR wrapper, not the library.
// BAD: Mock the HTTP client directly
const mockClient = vi.mocked(httpx.Client);
// GOOD: Create your own wrapper
class RegistryClient {
constructor(private client: HttpClient) {}
async getRepos() {
return this.client.get('https://registry.example.com/v2/_catalog');
}
}
// Mock your wrapper
vi.mock('./registry-client');This simplifies tests AND improves your design.
Managed vs Unmanaged Dependencies
| Dependency Type | Example | Strategy | |-----------------|---------|----------| | **Managed** (you control it) | Your database, your file system | Use REAL instances | | **Unmanaged** (external) | Third-party APIs, SMTP, message bus | Use MOCKS |
Communications with managed dependencies are implementation details — you can refactor them freely. Communications with unmanaged dependencies are observable behavior — mocking protects against external changes.
Anti-Pattern: Testing Mock Behavior
// BAD: Testing that the mock exists
test('renders sidebar', () => {
render(<Page />);
expect(screen.getByTestId('sidebar-mock')).toBeInTheDocument();
});
// GOOD: Test real behavior
test('renders sidebar', () => {
render(<Page />);
expect(screen.getByRole('navigation')).toBeInTheDocument();
});**Gate:** Before asserting on any mock element, ask: "Am I testing real behavior or mock existence?"
Anti-Pattern: Mocking Without Understanding
// BAD: Mock breaks test logic
test('detects duplicate server', () => {
// Mock prevents config write that test depends on!
vi.mock('ToolCatalog', () => ({
discoverAndCacheTools: vi.fn().mockResolvedValue(undefined)
}));
await addServer(config);
await addServer(config); // Should throw - but won't!
});
// GOOD: Mock at correct level
test('detects duplicate server', () => {
vi.mock('MCPServerManager'); // Just mock slow server startup
await addServer(config); // Config written
await addServer(config); // Duplicate detected
});**Gate:** Before mocking, ask: "What side effects does this have? Does my test depend on them?"
Anti-Pattern: Incomplete Mocks
Mock the COMPLETE data structure as it exists in reality:
// BAD: Partial mock
const mockResponse = {
status: 'success',
data: { userId: '123' }
// Missing: metadata that downstream code uses
};
// GOOD: Mirror real API
const mockResponse = {
status: 'success',
data: { userId: '123', name: 'Alice' },
metadata: { requestId: 'req-789', timestamp: 1234567890 }
};When Mocks Become Too Complex
Warning signs:
- Mock setup longer than test logic
- Mocking everything to make test pass
- Test breaks when mock changes
> "As the number of mocks grows, the probability of testing the mock instead of the desired code goes up." — Codurance
Consider integration tests with real componen
Read more
name: writing-good-tests description: Use when writing or reviewing tests - covers test philosophy, condition-based waiting, mocking strategy, and test isolation user-invocable: false
Writing Good Tests
Philosophy
**"Write tests. Not too many. Mostly integration."** — Kent C. Dodds
Tests verify real behavior, not implementation details. The goal is confidence that your code works, not coverage numbers.
**Core principles:** 1. Test behavior, not implementation — refactoring shouldn't break tests 2. Integration tests provide better confidence-to-cost ratio than unit tests 3. Wait for actual conditions, not arbitrary timeouts 4. Mock strategically — real dependencies when feasible, mocks for external systems 5. Don't pollute production code with test-only methods
Test Structure
Use **Arrange-Act-Assert** (or Given-When-Then):
test('user can cancel reservation', async () => {
// Arrange
const reservation = await createReservation({ userId: 'user-1', roomId: 'room-1' });
// Act
const result = await cancelReservation(reservation.id);
// Assert
expect(result.status).toBe('cancelled');
expect(await getReservation(reservation.id)).toBeNull();
});**One action per test.** Multiple assertions are fine if they verify the same behavior.
Condition-Based Waiting
Flaky tests often guess at timing. This creates race conditions where tests pass locally but fail in CI.
**Wait for conditions, not time:**
// BAD: Guessing at timing await new Promise(r => setTimeout(r, 50)); const result = getResult(); // GOOD: Waiting for condition await waitFor(() => getResult() !== undefined); const result = getResult();
Generic Polling Function
async function waitFor<T>(
condition: () => T | undefined | null | false,
description: string,
timeoutMs = 5000
): Promise<T> {
const startTime = Date.now();
while (true) {
const result = condition();
if (result) return result;
if (Date.now() - startTime > timeoutMs) {
throw new Error(`Timeout waiting for ${description} after ${timeoutMs}ms`);
}
await new Promise(r => setTimeout(r, 10)); // Poll every 10ms
}
}Quick Patterns
| Scenario | Pattern | |----------|---------| | Wait for event | `waitFor(() => events.find(e => e.type === 'DONE'))` | | Wait for state | `waitFor(() => machine.state === 'ready')` | | Wait for count | `waitFor(() => items.length >= 5)` |
When Arbitrary Timeout IS Correct
Only when testing actual timing behavior (debounce, throttle, intervals):
// Testing tool that ticks every 100ms await waitForEvent(manager, 'TOOL_STARTED'); // First: wait for condition await new Promise(r => setTimeout(r, 200)); // Then: wait for 2 ticks // Comment explains WHY: 200ms = 2 ticks at 100ms intervals
Mocking Strategy
> "You don't hate mocks; you hate side-effects." — J.B. Rainsberger
Mocks reveal where side-effects complicate your code. Use them strategically, not reflexively.
Don't Mock What You Don't Own
Create thin wrappers around third-party libraries. Mock YOUR wrapper, not the library.
// BAD: Mock the HTTP client directly
const mockClient = vi.mocked(httpx.Client);
// GOOD: Create your own wrapper
class RegistryClient {
constructor(private client: HttpClient) {}
async getRepos() {
return this.client.get('https://registry.example.com/v2/_catalog');
}
}
// Mock your wrapper
vi.mock('./registry-client');This simplifies tests AND improves your design.
Managed vs Unmanaged Dependencies
| Dependency Type | Example | Strategy | |-----------------|---------|----------| | **Managed** (you control it) | Your database, your file system | Use REAL instances | | **Unmanaged** (external) | Third-party APIs, SMTP, message bus | Use MOCKS |
Communications with managed dependencies are implementation details — you can refactor them freely. Communications with unmanaged dependencies are observable behavior — mocking protects against external changes.
Anti-Pattern: Testing Mock Behavior
// BAD: Testing that the mock exists
test('renders sidebar', () => {
render(<Page />);
expect(screen.getByTestId('sidebar-mock')).toBeInTheDocument();
});
// GOOD: Test real behavior
test('renders sidebar', () => {
render(<Page />);
expect(screen.getByRole('navigation')).toBeInTheDocument();
});**Gate:** Before asserting on any mock element, ask: "Am I testing real behavior or mock existence?"
Anti-Pattern: Mocking Without Understanding
// BAD: Mock breaks test logic
test('detects duplicate server', () => {
// Mock prevents config write that test depends on!
vi.mock('ToolCatalog', () => ({
discoverAndCacheTools: vi.fn().mockResolvedValue(undefined)
}));
await addServer(config);
await addServer(config); // Should throw - but won't!
});
// GOOD: Mock at correct level
test('detects duplicate server', () => {
vi.mock('MCPServerManager'); // Just mock slow server startup
await addServer(config); // Config written
await addServer(config); // Duplicate detected
});**Gate:** Before mocking, ask: "What side effects does this have? Does my test depend on them?"
Anti-Pattern: Incomplete Mocks
Mock the COMPLETE data structure as it exists in reality:
// BAD: Partial mock
const mockResponse = {
status: 'success',
data: { userId: '123' }
// Missing: metadata that downstream code uses
};
// GOOD: Mirror real API
const mockResponse = {
status: 'success',
data: { userId: '123', name: 'Alice' },
metadata: { requestId: 'req-789', timestamp: 1234567890 }
};When Mocks Become Too Complex
Warning signs:
- Mock setup longer than test logic
- Mocking everything to make test pass
- Test breaks when mock changes
> "As the number of mocks grows, the probability of testing the mock instead of the desired code goes up." — Codurance
Consider integration tests with real componen
Showing the first part of this file.
This is my collection of plugins that I use on a day-to-day basis for getting stuff done with Claude Code. Most of these are development-oriented in some way or another, but also often end up being useful for other things.
Repo: ed3dai/ed3d-plugins
Other skills on ed3d-plugins.
- /doing-a-simple-two-stage-fanout
Use when analyzing a large corpus of text, code, or data that exceeds a single agent's effective context - orchestrates parallel Worker subagents, Critic review subagents, and a final Summarizer subagent with task tracking and failure recovery
Open skill - /using-generic-agents
Use to decide what kind of generic agent you should use
Open skill - /creating-a-plugin
Use when creating a new Claude Code plugin or setting up plugin structure - provides complete file organization, manifest format, and component definitions for commands, agents, skills, hooks, and MCP servers
Open skill - /creating-an-agent
Use when creating specialized subagents for Claude Code plugins or the Task tool - covers description writing for auto-delegation, tool selection, prompt structure, and testing agents
Open skill - /maintaining-a-marketplace
Use when creating, releasing, or maintaining a Claude Code Plugin Marketplace - covers marketplace.json schema, version management, release checklists, changelog conventions, and validation to prevent sync drift between plugin.json and marketplace.json
Open skill - /maintaining-project-context
Use when completing development phases or branches to identify and update CLAUDE.md or AGENTS.md files that may have become stale - analyzes what changed, determines affected contracts and documentation, and coordinates updates
Open skill

