Submatter/.agents/skills/tdd-red-green/SKILL.md

343 lines
10 KiB
Markdown
Raw Normal View History

---
name: tdd-red-green
description: Test-Driven Development loop: write a failing test (Red), make it pass with minimal code (Green), then refactor (Refactor). Write tests before implementation, use seams for isolation, and avoid common TDD anti-patterns. Use when implementing features, fixing bugs, or adding tests to existing code.
---
# TDD: Red-Green-Refactor
A practical test-driven development loop adapted for Echodo's codebase. Write the test first, make it pass with minimal code, then refactor. This skill teaches you the TDD cycle, how to write effective tests, and common pitfalls to avoid.
## When to Activate
Use this skill when:
- Implementing a new feature (add tests first)
- Fixing a bug (write a test that reproduces it, then fix)
- Adding coverage to existing code
- Following Echodo's TDD convention in `role-implementer` briefs
## The Core Loop
```
┌─────────────────────────────────────────────────────────────┐
│ 1. Write failing test (Red) │
│ → Test describes expected behavior │
│ → Run test → fails (expected) │
├─────────────────────────────────────────────────────────────┤
│ 2. Write minimal code to pass (Green) │
│ → Only enough to make test pass │
│ → No "preemptive" abstractions │
│ → Run test → passes │
├─────────────────────────────────────────────────────────────┤
│ 3. Refactor (Refactor) │
│ → Clean up without changing behavior │
│ → Keep tests passing │
│ → Run full test suite │
└─────────────────────────────────────────────────────────────┘
```
## Step 1: Write Failing Test (Red)
### What Makes a Good Test?
1. **Describes behavior, not implementation** — "should create user with valid email" not "should call User.create"
2. **Single assertion per test** (or very few) — easier to diagnose failures
3. **Test name explains the scenario** — `describe('when email is invalid', () => { it('should return 400') })`
4. **Arrange-Act-Assert structure** — clear setup, action, and expectation
### Test Location
Follow Echodo conventions:
- Tests next to source: `src/index.ts` → `src/index.test.ts`
- Test file suffix: `.test.ts` (Vitest)
- Use existing test utilities in `packages/shared/test/` if available
### Example Red Test
```typescript
// apps/web/server/routers/auth.test.ts
describe('POST /api/auth/register', () => {
it('should create a user with valid email and password', async () => {
// Arrange
const payload = {
email: 'test@example.com',
password: 'securepassword123'
};
// Act
const response = await fetch('/api/auth/register', {
method: 'POST',
body: JSON.stringify(payload)
});
// Assert
expect(response.status).toBe(201);
const body = await response.json();
expect(body.user.email).toBe('test@example.com');
});
it('should return 400 when email is invalid', async () => {
// Arrange
const payload = {
email: 'not-an-email',
password: 'securepassword123'
};
// Act
const response = await fetch('/api/auth/register', {
method: 'POST',
body: JSON.stringify(payload)
});
// Assert
expect(response.status).toBe(400);
});
});
```
**Run test → fails** (because route doesn't exist yet)
### When Bug-Fixing: Start with a Failing Test
Instead of:
1. Reading the bug
2. Fixing the code
3. Running full test suite
Do this:
1. Write a test that **reproduces the bug**
2. Run test → fails (proves bug exists)
3. Fix code
4. Run test → passes (proves bug fixed)
5. Run full suite
## Step 2: Write Minimal Code (Green)
### Key Principles
1. **Minimal implementation** — only enough to pass the test
2. **No premature abstraction** — don't build "flexible" systems "just in case"
3. **YAGNI** — You Aren't Gonna Need It
4. **Trust the test** — if the test is right, the code should make it pass
### Example Green Implementation
```typescript
// Minimal route to make test pass
// apps/web/app/api/auth/register/route.ts
import { NextResponse } from 'next/server';
import { z } from 'zod';
const registerSchema = z.object({
email: z.string().email(),
password: z.string().min(8)
});
export async function POST(request: Request) {
const body = await request.json();
// Basic validation (the test checks this)
const result = registerSchema.safeParse(body);
if (!result.success) {
return NextResponse.json({ error: 'Invalid input' }, { status: 400 });
}
// Minimal DB insert (simplified for example)
const user = await createUser(result.data.email, result.data.password);
return NextResponse.json({ user }, { status: 201 });
}
// Placeholder - in real code, call actual DB
async function createUser(email: string, password: string) {
return { id: '1', email };
}
```
**Run test → passes**
## Step 3: Refactor
### When to Refactor
- Code is duplicated
- Names are unclear
- Function does too much
- Test is fragile or implementation-specific
- You need to add a new test that requires a small change
### While Refactoring
1. **Keep tests passing** — run after every change
2. **Small steps** — refactor one thing at a time
3. **No new functionality** — only clean up existing code
### Example Refactor
Before:
```typescript
// auth.ts - duplicated validation
function validateEmail(email: string) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
// Different file, same validation
function isValidEmailAddress(email: string) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
```
After (extract to shared):
```typescript
// lib/validation.ts
export const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export function isValidEmail(email: string): boolean {
return emailRegex.test(email);
}
```
## Seams: Testing Without Dependencies
A **seam** is a place where you can alter behavior without changing the code being tested.
### Types of Seams
| Seam | How to Use | Example |
|------|------------|---------|
| **Dependency Injection** | Pass dependencies as params | `function process(db)` → `process(mockDb)` |
| **Interface/Abstract class** | Swap implementations | `Database` abstract → `MockDatabase` |
| **Factory functions** | Return different instances | `createClient()` → `createMockClient()` |
| **Environment variables** | Control behavior | `if (process.env.NODE_ENV === 'test')` |
| **Configuration objects** | Pass config at runtime | `new Service({ retries: 3 })` |
### Example: Seams in Echodo
```typescript
// Without seam (hard to test)
async function createUser(email: string) {
const db = new PostgresDatabase(); // hard-coded
return db.users.insert({ email });
}
// With seam (testable)
async function createUser(email: string, db: Database = defaultDb) {
return db.users.insert({ email });
}
// In test
const mockDb = { users: { insert: vi.fn() } };
await createUser('test@example.com', mockDb);
expect(mockDb.users.insert).toHaveBeenCalledWith({ email: 'test@example.com' });
```
## Anti-Patterns to Avoid
### 1. Test After Implementation
❌ Write code → write tests
✅ Write failing test → write code → pass → refactor
### 2. Implementation-Specific Tests
❌ `expect(service.createUser).toHaveBeenCalledWith(data)`
✅ `expect(response.status).toBe(201)` (behavior, not implementation)
### 3. Brittle Tests
❌ Test checks exact string: `expect(body).toBe('{"id":"1"}')`
✅ Test checks structure: `expect(body.user.id).toBeDefined()`
### 4. Shared State Between Tests
❌ Global variable modified in one test, relied on by another
✅ Each test sets up its own state
### 5. Testing Too Much
❌ One test checks validation, DB insert, email send, response format
✅ Separate tests for each concern
### 6. No Assertions
❌ `await service.process(data)` — no expect
✅ `expect(result).toEqual(expected)`
### 7. Commented-Out Tests
❌ `// skip this test for now`
✅ Delete or fix the test
## Echodo Testing Conventions
- **Framework:** Vitest (see `packages/*/vitest.config.ts`)
- **Location:** Same directory as source, `.test.ts` suffix
- **Mocking:** Use `vi.fn()` from Vitest, mock via dependency injection
- **Database tests:** Use test database or in-memory alternative
- **Run tests:** `pnpm test` or `pnpm --filter @tasks/<package> test`
## Example: Bug Fix Flow
**Bug report:** "Users can register with empty password"
### Before (wrong approach)
1. Read bug report
2. Add validation to route
3. Run full test suite
4. Deploy
### TDD Approach (right way)
1. **Write failing test:**
```typescript
it('should reject password shorter than 8 characters', async () => {
const response = await register({ email: 'test@example.com', password: 'short' });
expect(response.status).toBe(400);
});
```
2. **Run test → fails** (shows bug exists)
3. **Fix code minimally:**
```typescript
const registerSchema = z.object({
email: z.string().email(),
password: z.string().min(8)
});
```
4. **Run test → passes** (proves fix works)
5. **Run full suite** (no regressions)
6. **Refactor** if needed
## Integration with Other Skills
- **code-review-two-axis:** Reviewer checks for TDD pattern in implementation
- **backlog-sync:** When testing markdown import/export features
- **convoy-lifecycle:** When testing convoy status transitions
- **model-routing:** When testing model resolution logic
## Running Tests in Echodo
```bash
# Run all tests
pnpm test
# Run specific package
pnpm --filter @tasks/web test
pnpm --filter @tasks/shared test
# Watch mode
pnpm --filter @tasks/web test:watch
# Coverage
pnpm test --coverage
```
## References
- **Original skill:** https://github.com/mattpocock/skills/blob/main/skills/engineering/tdd/SKILL.md
- **Kent Beck:** "Test-Driven Development: By Example"
- **Martin Fowler:** "Refactoring" - Chapter on code smells
- **Vitest docs:** https://vitest.dev/
- **Echodo test config:** `packages/*/vitest.config.ts`