📦 deps(thirdparty): update snapshots
This commit is contained in:
@@ -0,0 +1,357 @@
|
||||
---
|
||||
name: tdd
|
||||
description: This skill should be used when implementing features with TDD, writing tests first, or refactoring with test coverage. Applies disciplined Red-Green-Refactor cycles with TypeScript/Bun and Rust tooling.
|
||||
metadata:
|
||||
version: "2.1.0"
|
||||
---
|
||||
|
||||
# Test-Driven Development
|
||||
|
||||
Write tests first, implement minimal code to pass, refactor systematically.
|
||||
|
||||
<when_to_use>
|
||||
|
||||
- New features with TDD methodology
|
||||
- Complex business logic requiring coverage
|
||||
- Critical paths: auth, payments, data integrity
|
||||
- Bug fixes: reproduce with test, fix, verify
|
||||
- Refactoring: ensure behavior preservation
|
||||
- API design: tests define the interface
|
||||
|
||||
NOT for: exploratory coding, UI prototypes, static config, trivial glue code
|
||||
|
||||
</when_to_use>
|
||||
|
||||
<stages>
|
||||
|
||||
Load the **maintain-tasks** skill for stage tracking. Advance through RED-GREEN-REFACTOR cycle.
|
||||
|
||||
| Stage | Trigger | activeForm |
|
||||
|-------|---------|------------|
|
||||
| Red | Session start / cycle restart | "Writing failing test" |
|
||||
| Green | Test written and failing | "Implementing code" |
|
||||
| Refactor | Tests passing | "Refactoring code" |
|
||||
| Verify | Refactor complete | "Verifying implementation" |
|
||||
|
||||
Task format:
|
||||
|
||||
```text
|
||||
- Write failing test for { feature }
|
||||
- Implement { feature } to pass tests
|
||||
- Refactor { aspect }
|
||||
- Verify { what's being checked }
|
||||
```
|
||||
|
||||
Workflow:
|
||||
- Start: Create "Red" stage `in_progress`
|
||||
- Transition: Mark current `completed`, add next `in_progress`
|
||||
- After each stage: Run tests before advancing
|
||||
- Multiple cycles: Return to "Red" for next feature
|
||||
|
||||
Edge cases:
|
||||
- Good existing tests: Start at "Refactor" after confirming pass
|
||||
- Bug fix: Start at "Red" with failing test reproducing bug
|
||||
- No regression: Tests must continue passing through all stages
|
||||
|
||||
</stages>
|
||||
|
||||
<cycle>
|
||||
|
||||
```
|
||||
RED --> GREEN --> REFACTOR --> RED --> ...
|
||||
| | |
|
||||
Test Impl Improve
|
||||
Fails Passes Quality
|
||||
```
|
||||
|
||||
Each cycle: 5-15 min. Longer = step too large, decompose.
|
||||
|
||||
Philosophy:
|
||||
- Red-Green-Refactor as primary workflow
|
||||
- Test quality over quantity - behavior, not implementation
|
||||
- Incremental progress - small focused cycles
|
||||
- Type safety throughout - tests as type-safe as production
|
||||
|
||||
</cycle>
|
||||
|
||||
<red_phase>
|
||||
|
||||
Write tests defining desired behavior before implementation exists.
|
||||
|
||||
Guidelines:
|
||||
- 3-5 related tests fully specifying one feature
|
||||
- Type system makes invalid states unrepresentable
|
||||
- Each test = one specific behavior
|
||||
- Run tests, verify fail for right reason
|
||||
- Descriptive names forming sentences
|
||||
|
||||
TypeScript:
|
||||
|
||||
```typescript
|
||||
import { describe, test, expect } from 'bun:test'
|
||||
|
||||
describe('UserAuthentication', () => {
|
||||
test('authenticates with valid credentials', async () => {
|
||||
const result = await authenticate({ email: 'user@example.com', password: 'SecurePass123!' })
|
||||
expect(result).toMatchObject({ type: 'success', user: expect.objectContaining({ email: 'user@example.com' }) })
|
||||
})
|
||||
|
||||
test('rejects invalid credentials', async () => {
|
||||
const result = await authenticate({ email: 'wrong@example.com', password: 'wrong' })
|
||||
expect(result).toMatchObject({ type: 'error', code: 'INVALID_CREDENTIALS' })
|
||||
})
|
||||
|
||||
test.todo('implements rate limiting after failed attempts')
|
||||
})
|
||||
```
|
||||
|
||||
Rust:
|
||||
|
||||
```rust
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn authenticates_with_valid_credentials() {
|
||||
let creds = Credentials { email: "user@example.com".into(), password: "SecurePass123!".into() };
|
||||
assert!(matches!(authenticate(&creds), Ok(AuthResult::Success { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_credentials() {
|
||||
let creds = Credentials { email: "wrong@example.com".into(), password: "wrong".into() };
|
||||
assert!(matches!(authenticate(&creds), Err(AuthError::InvalidCredentials)));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Commit: `test: add failing tests for [feature]`
|
||||
|
||||
Transition: Mark "Red" `completed`, create "Green" `in_progress`
|
||||
|
||||
</red_phase>
|
||||
|
||||
<green_phase>
|
||||
|
||||
Implement minimum code to make tests pass.
|
||||
|
||||
Guidelines:
|
||||
- Focus on passing tests, not perfect code
|
||||
- Explicit types where aids clarity
|
||||
- Straightforward solutions first
|
||||
- Hardcode if passes test - refactor generalizes
|
||||
- Run tests frequently
|
||||
|
||||
TypeScript:
|
||||
|
||||
```typescript
|
||||
type AuthResult = { type: 'success'; user: User } | { type: 'error'; code: string }
|
||||
|
||||
async function authenticate(creds: { email: string; password: string }): Promise<AuthResult> {
|
||||
if (!creds.password) return { type: 'error', code: 'MISSING_PASSWORD' }
|
||||
const user = await findUserByEmail(creds.email)
|
||||
if (!user) return { type: 'error', code: 'INVALID_CREDENTIALS' }
|
||||
const match = await comparePassword(creds.password, user.passwordHash)
|
||||
if (!match) return { type: 'error', code: 'INVALID_CREDENTIALS' }
|
||||
return { type: 'success', user }
|
||||
}
|
||||
```
|
||||
|
||||
Rust:
|
||||
|
||||
```rust
|
||||
pub fn authenticate(creds: &Credentials) -> Result<AuthResult, AuthError> {
|
||||
if creds.password.is_empty() { return Err(AuthError::MissingPassword); }
|
||||
let user = find_user_by_email(&creds.email).ok_or(AuthError::InvalidCredentials)?;
|
||||
if !compare_password(&creds.password, &user.password_hash) {
|
||||
return Err(AuthError::InvalidCredentials);
|
||||
}
|
||||
Ok(AuthResult::Success { user })
|
||||
}
|
||||
```
|
||||
|
||||
Verify: `bun test` / `cargo test`
|
||||
|
||||
Commit: `feat: implement [feature] to pass tests`
|
||||
|
||||
Transition: Mark "Green" `completed`, create "Refactor" `in_progress`
|
||||
|
||||
</green_phase>
|
||||
|
||||
<refactor_phase>
|
||||
|
||||
Enhance code quality without changing behavior. Tests must continue passing.
|
||||
|
||||
Guidelines:
|
||||
- Extract common patterns into well-named functions
|
||||
- Apply SOLID principles where appropriate
|
||||
- Improve types: discriminated unions, branded types
|
||||
- No test behavior changes
|
||||
- Run tests after each step
|
||||
|
||||
TypeScript:
|
||||
|
||||
```typescript
|
||||
// Extract validation
|
||||
function validateCredentials(creds: { email: string; password: string }): AuthResult | null {
|
||||
if (!creds.password) return { type: 'error', code: 'MISSING_PASSWORD' }
|
||||
if (!isValidEmail(creds.email)) return { type: 'error', code: 'INVALID_EMAIL' }
|
||||
return null
|
||||
}
|
||||
|
||||
// Branded types for safety
|
||||
type Email = string & { readonly __brand: 'Email' }
|
||||
```
|
||||
|
||||
Rust:
|
||||
|
||||
```rust
|
||||
// Extract validation
|
||||
fn validate_credentials(creds: &Credentials) -> Result<(), AuthError> {
|
||||
if creds.password.is_empty() { return Err(AuthError::MissingPassword); }
|
||||
if !is_valid_email(&creds.email) { return Err(AuthError::InvalidEmail); }
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Newtype for safety
|
||||
pub struct Email(String);
|
||||
```
|
||||
|
||||
Verify: `bun test` / `cargo test`
|
||||
|
||||
Commit: `refactor: [improvement description]`
|
||||
|
||||
Transition: Mark "Refactor" `completed`, create "Verify" `in_progress`
|
||||
|
||||
Final: Run full suite. Mark "Verify" `completed` when all checks pass.
|
||||
|
||||
</refactor_phase>
|
||||
|
||||
<organization>
|
||||
|
||||
Follow project conventions, defaulting to:
|
||||
|
||||
TypeScript/Bun:
|
||||
|
||||
```
|
||||
src/{module}/{name}.ts # Implementation
|
||||
src/{module}/{name}.test.ts # Unit tests colocated
|
||||
src/{module}/__fixtures__/ # Test data
|
||||
tests/integration/ # Integration tests
|
||||
tests/e2e/ # End-to-end tests
|
||||
```
|
||||
|
||||
Rust:
|
||||
|
||||
```
|
||||
src/{module}/mod.rs # #[cfg(test)] mod tests { ... }
|
||||
tests/integration/ # Integration tests
|
||||
tests/fixtures/ # Test data
|
||||
```
|
||||
|
||||
</organization>
|
||||
|
||||
<quality>
|
||||
|
||||
| Metric | Target |
|
||||
|--------|--------|
|
||||
| Line coverage | >=80% (90% critical paths) |
|
||||
| Mutation score | >=75% |
|
||||
| Unit test time | <5s |
|
||||
|
||||
Test characteristics:
|
||||
- Single clear assertion per test
|
||||
- No execution order dependencies
|
||||
- Descriptive names forming sentences
|
||||
- Behavior focus, not implementation
|
||||
|
||||
Smells to avoid:
|
||||
- Setup longer than test
|
||||
- Multiple unrelated assertions
|
||||
- Coupling to implementation details
|
||||
- Flaky tests
|
||||
|
||||
See [quality-metrics.md](references/quality-metrics.md) for coverage and mutation testing details.
|
||||
|
||||
</quality>
|
||||
|
||||
<bug_fixes>
|
||||
|
||||
TDD workflow for bugs:
|
||||
|
||||
1. Write failing test reproducing bug (Start "Red" `in_progress`)
|
||||
2. Verify fails for right reason
|
||||
3. Fix with minimal code (Transition to "Green")
|
||||
4. Verify passes, all others still pass
|
||||
5. Refactor if needed (Transition to "Refactor" or skip to "Verify")
|
||||
6. Commit: `fix: [bug description] with test coverage`
|
||||
|
||||
Example:
|
||||
|
||||
```typescript
|
||||
// 1. Failing test
|
||||
test('handles division by zero gracefully', () => {
|
||||
expect(divide(10, 0)).toMatchObject({ type: 'error', code: 'DIVISION_BY_ZERO' })
|
||||
})
|
||||
|
||||
// 3. Fix
|
||||
function divide(a: number, b: number): Result {
|
||||
if (b === 0) return { type: 'error', code: 'DIVISION_BY_ZERO' }
|
||||
return { type: 'success', value: a / b }
|
||||
}
|
||||
```
|
||||
|
||||
</bug_fixes>
|
||||
|
||||
<rules>
|
||||
|
||||
ALWAYS:
|
||||
- Track progress with Tasks (load **maintain-tasks** skill)
|
||||
- Write tests before implementation (RED first)
|
||||
- Run tests after each stage
|
||||
- Verify tests fail for right reason in RED
|
||||
- Keep cycles 5-15 min max
|
||||
- Descriptive test names forming sentences
|
||||
- Test behavior, not implementation
|
||||
- Each test = one reason to fail
|
||||
|
||||
NEVER:
|
||||
- Skip to implementation without tests
|
||||
- Change test behavior during refactoring
|
||||
- Test implementation details or private methods
|
||||
- Allow tests to depend on execution order
|
||||
- Write flaky tests
|
||||
- Mark stage complete without running tests
|
||||
- Multiple unrelated assertions per test
|
||||
|
||||
</rules>
|
||||
|
||||
<quick_reference>
|
||||
|
||||
```bash
|
||||
# TypeScript/Bun
|
||||
bun test # Run all tests
|
||||
bun test --watch # Watch mode
|
||||
bun test --coverage # Coverage report
|
||||
bun test --only # Run only .only tests
|
||||
bun x stryker run # Mutation testing
|
||||
|
||||
# Rust
|
||||
cargo test # Run all tests
|
||||
cargo test --test NAME # Specific integration test
|
||||
cargo tarpaulin # Coverage report
|
||||
cargo mutants # Mutation testing
|
||||
cargo test -- --nocapture # Show println! output
|
||||
```
|
||||
|
||||
</quick_reference>
|
||||
|
||||
<references>
|
||||
|
||||
- [test-patterns.md](references/test-patterns.md) - Discriminated unions, builders, mocking, parameterized tests, async patterns for TypeScript and Rust
|
||||
- [quality-metrics.md](references/quality-metrics.md) - Coverage analysis, mutation testing setup, CI integration, thresholds
|
||||
- [feature-implementation.md](examples/feature-implementation.md) - Full TDD session walkthrough
|
||||
- [bug-fix.md](examples/bug-fix.md) - TDD workflow for bug fixes
|
||||
|
||||
</references>
|
||||
@@ -0,0 +1,420 @@
|
||||
# TDD Session: Bug Fix
|
||||
|
||||
TDD workflow for fixing a production bug with test reproduction first.
|
||||
|
||||
## Bug Report
|
||||
|
||||
**Issue**: Division by zero crashes calculator service
|
||||
**Reporter**: User reported 500 error when calculating percentage with zero total
|
||||
**Stack Trace**:
|
||||
|
||||
```
|
||||
Error: Division by zero
|
||||
at calculatePercentage (calculator.ts:45)
|
||||
at handler (api/stats.ts:23)
|
||||
```
|
||||
|
||||
**Priority**: High — causes service crash
|
||||
|
||||
## TDD Workflow
|
||||
|
||||
### Task Setup
|
||||
|
||||
```text
|
||||
1. Write failing test reproducing bug [in_progress]
|
||||
2. Fix bug with minimal code [pending]
|
||||
3. Refactor if needed [pending]
|
||||
4. Verify fix [pending]
|
||||
```
|
||||
|
||||
## RED Phase: Reproduce Bug (4 min)
|
||||
|
||||
Confidence: `▓▓░░░` → Can reproduce, know the fix
|
||||
|
||||
First, understand the current implementation:
|
||||
|
||||
**Read**: `src/calculator/percentage.ts`
|
||||
|
||||
```typescript
|
||||
export function calculatePercentage(value: number, total: number): number {
|
||||
return (value / total) * 100
|
||||
}
|
||||
```
|
||||
|
||||
Issue clear: No validation for zero total.
|
||||
|
||||
**Write failing test**: `src/calculator/percentage.test.ts`
|
||||
|
||||
```typescript
|
||||
import { describe, test, expect } from 'bun:test'
|
||||
import { calculatePercentage } from './percentage'
|
||||
|
||||
describe('calculatePercentage', () => {
|
||||
// Existing tests
|
||||
test('calculates percentage correctly', () => {
|
||||
expect(calculatePercentage(25, 100)).toBe(25)
|
||||
expect(calculatePercentage(50, 200)).toBe(25)
|
||||
})
|
||||
|
||||
// NEW: Bug reproduction test
|
||||
test('handles division by zero gracefully', () => {
|
||||
const result = calculatePercentage(10, 0)
|
||||
|
||||
expect(result).toBe(0)
|
||||
// OR throw error approach:
|
||||
// expect(() => calculatePercentage(10, 0)).toThrow('Total cannot be zero')
|
||||
})
|
||||
|
||||
test('handles zero value with valid total', () => {
|
||||
expect(calculatePercentage(0, 100)).toBe(0)
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
**Run test**: `bun test percentage.test.ts`
|
||||
|
||||
```
|
||||
calculatePercentage
|
||||
✓ calculates percentage correctly
|
||||
✗ handles division by zero gracefully
|
||||
Expected: 0
|
||||
Received: Infinity
|
||||
✓ handles zero value with valid total
|
||||
|
||||
1 failing
|
||||
```
|
||||
|
||||
Bug reproduced successfully — test fails as expected.
|
||||
|
||||
**Commit**: `test: add failing test for division by zero bug`
|
||||
|
||||
**Task Update**:
|
||||
|
||||
```text
|
||||
1. Write failing test reproducing bug [completed]
|
||||
2. Fix bug with minimal code [in_progress]
|
||||
3. Refactor if needed [pending]
|
||||
4. Verify fix [pending]
|
||||
```
|
||||
|
||||
## GREEN Phase: Fix Bug (3 min)
|
||||
|
||||
Confidence: `▓▓▓▓░` → Know exact fix needed
|
||||
|
||||
**Update**: `src/calculator/percentage.ts`
|
||||
|
||||
```typescript
|
||||
export function calculatePercentage(value: number, total: number): number {
|
||||
// Fix: Handle division by zero
|
||||
if (total === 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
return (value / total) * 100
|
||||
}
|
||||
```
|
||||
|
||||
**Run tests**: `bun test percentage.test.ts`
|
||||
|
||||
```
|
||||
calculatePercentage
|
||||
✓ calculates percentage correctly
|
||||
✓ handles division by zero gracefully
|
||||
✓ handles zero value with valid total
|
||||
|
||||
3 passing
|
||||
```
|
||||
|
||||
All tests pass — bug fixed.
|
||||
|
||||
**Verify no regressions**: `bun test`
|
||||
|
||||
```
|
||||
All test suites passing (23 tests total)
|
||||
```
|
||||
|
||||
No other tests broken — safe fix.
|
||||
|
||||
**Commit**: `fix: handle division by zero in percentage calculation`
|
||||
|
||||
**Task Update**:
|
||||
|
||||
```text
|
||||
1. Write failing test reproducing bug [completed]
|
||||
2. Fix bug with minimal code [completed]
|
||||
3. Refactor if needed [in_progress]
|
||||
4. Verify fix [pending]
|
||||
```
|
||||
|
||||
## REFACTOR Phase: Consider Improvements (5 min)
|
||||
|
||||
Current fix works but could be more robust. Consider:
|
||||
|
||||
1. Should negative totals be handled?
|
||||
2. Should we return error instead of 0?
|
||||
3. Type safety improvements?
|
||||
|
||||
**Decision**: Add input validation, use Result type for better error handling.
|
||||
|
||||
**Refactored**: `src/calculator/percentage.ts`
|
||||
|
||||
```typescript
|
||||
type PercentageResult =
|
||||
| { type: 'success'; value: number }
|
||||
| { type: 'error'; code: 'ZERO_TOTAL' | 'NEGATIVE_TOTAL' }
|
||||
|
||||
export function calculatePercentage(
|
||||
value: number,
|
||||
total: number
|
||||
): PercentageResult {
|
||||
// Validate total
|
||||
if (total === 0) {
|
||||
return { type: 'error', code: 'ZERO_TOTAL' }
|
||||
}
|
||||
|
||||
if (total < 0) {
|
||||
return { type: 'error', code: 'NEGATIVE_TOTAL' }
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'success',
|
||||
value: (value / total) * 100,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Update tests** to match new signature:
|
||||
|
||||
```typescript
|
||||
describe('calculatePercentage', () => {
|
||||
test('calculates percentage correctly', () => {
|
||||
const result1 = calculatePercentage(25, 100)
|
||||
const result2 = calculatePercentage(50, 200)
|
||||
|
||||
expect(result1).toEqual({ type: 'success', value: 25 })
|
||||
expect(result2).toEqual({ type: 'success', value: 25 })
|
||||
})
|
||||
|
||||
test('returns error for division by zero', () => {
|
||||
const result = calculatePercentage(10, 0)
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'error',
|
||||
code: 'ZERO_TOTAL',
|
||||
})
|
||||
})
|
||||
|
||||
test('returns error for negative total', () => {
|
||||
const result = calculatePercentage(10, -100)
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'error',
|
||||
code: 'NEGATIVE_TOTAL',
|
||||
})
|
||||
})
|
||||
|
||||
test('handles zero value with valid total', () => {
|
||||
const result = calculatePercentage(0, 100)
|
||||
|
||||
expect(result).toEqual({ type: 'success', value: 0 })
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
**Run tests**: `bun test percentage.test.ts`
|
||||
|
||||
```
|
||||
calculatePercentage
|
||||
✓ calculates percentage correctly
|
||||
✓ returns error for division by zero
|
||||
✓ returns error for negative total
|
||||
✓ handles zero value with valid total
|
||||
|
||||
4 passing
|
||||
```
|
||||
|
||||
**Update usage** in `api/stats.ts`:
|
||||
|
||||
```typescript
|
||||
// Before
|
||||
const percentage = calculatePercentage(completed, total)
|
||||
return { percentage }
|
||||
|
||||
// After
|
||||
const result = calculatePercentage(completed, total)
|
||||
if (result.type === 'error') {
|
||||
return { error: 'Invalid calculation', code: result.code }
|
||||
}
|
||||
return { percentage: result.value }
|
||||
```
|
||||
|
||||
**Run all tests**: `bun test`
|
||||
|
||||
```
|
||||
All tests passing (24 tests total)
|
||||
```
|
||||
|
||||
No regressions — refactor successful.
|
||||
|
||||
**Commit**: `refactor: use Result type for percentage calculation errors`
|
||||
|
||||
**Task Update**:
|
||||
|
||||
```text
|
||||
1. Write failing test reproducing bug [completed]
|
||||
2. Fix bug with minimal code [completed]
|
||||
3. Refactor if needed [completed]
|
||||
4. Verify fix [in_progress]
|
||||
```
|
||||
|
||||
## VERIFY Phase: Comprehensive Validation (4 min)
|
||||
|
||||
Run full test suite with coverage:
|
||||
|
||||
```bash
|
||||
bun test --coverage
|
||||
```
|
||||
|
||||
```
|
||||
Coverage Summary:
|
||||
percentage.ts: 100% statements, 100% branches
|
||||
|
||||
All tests passing (24 total)
|
||||
```
|
||||
|
||||
Run mutation testing on updated code:
|
||||
|
||||
```bash
|
||||
bun x stryker run --mutate "src/calculator/percentage.ts"
|
||||
```
|
||||
|
||||
```
|
||||
Mutation Score: 100%
|
||||
All mutants killed
|
||||
|
||||
Mutations tested:
|
||||
✓ === → !== (killed by "returns error for division by zero")
|
||||
✓ < → <= (killed by "returns error for negative total")
|
||||
✓ / → * (killed by "calculates percentage correctly")
|
||||
✓ 0 → 1 (killed by "handles zero value with valid total")
|
||||
```
|
||||
|
||||
Perfect mutation score — tests thoroughly verify behavior.
|
||||
|
||||
**Integration test** to verify API fix:
|
||||
|
||||
```typescript
|
||||
describe('GET /api/stats', () => {
|
||||
test('handles zero total gracefully', async () => {
|
||||
const response = await request(app)
|
||||
.get('/api/stats')
|
||||
.query({ completed: 10, total: 0 })
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.body).toMatchObject({
|
||||
error: 'Invalid calculation',
|
||||
code: 'ZERO_TOTAL',
|
||||
})
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
**Run integration tests**: `bun test tests/integration/`
|
||||
|
||||
```
|
||||
API Integration Tests
|
||||
✓ handles zero total gracefully
|
||||
|
||||
All integration tests passing
|
||||
```
|
||||
|
||||
**Task Update**:
|
||||
|
||||
```text
|
||||
1. Write failing test reproducing bug [completed]
|
||||
2. Fix bug with minimal code [completed]
|
||||
3. Refactor if needed [completed]
|
||||
4. Verify fix [completed]
|
||||
```
|
||||
|
||||
## Session Summary
|
||||
|
||||
**Duration**: 16 minutes total
|
||||
- RED: 4 min
|
||||
- GREEN: 3 min
|
||||
- REFACTOR: 5 min
|
||||
- VERIFY: 4 min
|
||||
|
||||
**Bug**: Division by zero crash
|
||||
**Fix**: Added validation with Result type
|
||||
**Tests**: 4 new tests + 1 integration test
|
||||
**Coverage**: 100% on changed code
|
||||
**Mutation Score**: 100%
|
||||
|
||||
**Improvements beyond minimal fix**:
|
||||
- Used discriminated union for error handling
|
||||
- Added negative total validation
|
||||
- Updated API to handle error results
|
||||
- Added integration test
|
||||
|
||||
**Production deployment**:
|
||||
- All tests passing
|
||||
- No regressions detected
|
||||
- Error handling verified
|
||||
- Ready to deploy
|
||||
|
||||
## Key TDD Bug Fix Principles
|
||||
|
||||
1. **RED first**: Always reproduce bug with failing test before fixing
|
||||
2. **Minimal GREEN**: Fix the immediate issue first
|
||||
3. **Refactor for robustness**: Improve error handling and edge cases
|
||||
4. **Verify thoroughly**: Run full suite + mutation tests + integration tests
|
||||
5. **Document in test**: Test name describes the bug being fixed
|
||||
|
||||
## Anti-patterns Avoided
|
||||
|
||||
Avoided jumping straight to fix without test:
|
||||
|
||||
```typescript
|
||||
// ❌ Wrong approach
|
||||
// 1. See bug report
|
||||
// 2. Add if (total === 0) return 0
|
||||
// 3. Deploy and hope
|
||||
|
||||
// ✓ Correct TDD approach
|
||||
// 1. Write failing test reproducing bug
|
||||
// 2. Verify test fails
|
||||
// 3. Add minimal fix
|
||||
// 4. Verify test passes
|
||||
// 5. Refactor for robustness
|
||||
// 6. Verify with mutation testing
|
||||
```
|
||||
|
||||
Avoided over-engineering initial fix:
|
||||
|
||||
```typescript
|
||||
// ❌ Too complex for first fix
|
||||
if (total === 0 || total < 0 || !isFinite(total) || isNaN(total)) {
|
||||
throw new ValidationError(...)
|
||||
}
|
||||
|
||||
// ✓ Minimal fix first (GREEN phase)
|
||||
if (total === 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
// ✓ Then refactor with proper error handling (REFACTOR phase)
|
||||
if (total === 0) {
|
||||
return { type: 'error', code: 'ZERO_TOTAL' }
|
||||
}
|
||||
```
|
||||
|
||||
## Commit History
|
||||
|
||||
```
|
||||
test: add failing test for division by zero bug
|
||||
fix: handle division by zero in percentage calculation
|
||||
refactor: use Result type for percentage calculation errors
|
||||
```
|
||||
|
||||
Clean, focused commits showing TDD progression.
|
||||
@@ -0,0 +1,686 @@
|
||||
# TDD Session: Feature Implementation
|
||||
|
||||
Complete TDD session implementing user authentication feature from scratch.
|
||||
|
||||
## Session Setup
|
||||
|
||||
**Feature**: User authentication with email/password
|
||||
**Tech Stack**: TypeScript, Bun, discriminated unions for results
|
||||
**Starting Point**: No existing code
|
||||
**Duration**: ~45 minutes (3 RED-GREEN-REFACTOR cycles)
|
||||
|
||||
## Task State Tracking
|
||||
|
||||
Initial todos:
|
||||
|
||||
```text
|
||||
1. Write failing test for user authentication [in_progress]
|
||||
2. Implement authentication to pass tests [pending]
|
||||
3. Refactor authentication code [pending]
|
||||
4. Verify implementation [pending]
|
||||
```
|
||||
|
||||
## Cycle 1: Basic Authentication
|
||||
|
||||
### RED Phase (5 min)
|
||||
|
||||
Starting confidence: `▓░░░░` → Writing tests to define interface
|
||||
|
||||
**Created**: `src/auth/authenticate.test.ts`
|
||||
|
||||
```typescript
|
||||
import { describe, test, expect } from 'bun:test'
|
||||
import { authenticate } from './authenticate'
|
||||
|
||||
describe('authenticate', () => {
|
||||
const validCreds = {
|
||||
email: 'user@example.com',
|
||||
password: 'ValidPass123!',
|
||||
} as const
|
||||
|
||||
test('returns success result with valid credentials', async () => {
|
||||
const result = await authenticate(validCreds)
|
||||
|
||||
expect(result.type).toBe('success')
|
||||
if (result.type === 'success') {
|
||||
expect(result.user.email).toBe(validCreds.email)
|
||||
}
|
||||
})
|
||||
|
||||
test('returns error result with invalid credentials', async () => {
|
||||
const result = await authenticate({
|
||||
email: 'wrong@example.com',
|
||||
password: 'wrong',
|
||||
})
|
||||
|
||||
expect(result.type).toBe('error')
|
||||
if (result.type === 'error') {
|
||||
expect(result.code).toBe('INVALID_CREDENTIALS')
|
||||
}
|
||||
})
|
||||
|
||||
test('returns error result with empty password', async () => {
|
||||
const result = await authenticate({
|
||||
email: 'user@example.com',
|
||||
password: '',
|
||||
})
|
||||
|
||||
expect(result.type).toBe('error')
|
||||
if (result.type === 'error') {
|
||||
expect(result.code).toBe('MISSING_PASSWORD')
|
||||
}
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
**Run tests**: `bun test`
|
||||
|
||||
```
|
||||
authenticate
|
||||
✗ returns success result with valid credentials
|
||||
Error: Cannot find module "./authenticate"
|
||||
✗ returns error result with invalid credentials
|
||||
✗ returns error result with empty password
|
||||
|
||||
3 failing
|
||||
```
|
||||
|
||||
Tests fail as expected — no implementation exists yet.
|
||||
|
||||
**Commit**: `test: add failing tests for user authentication`
|
||||
|
||||
**Task Update**:
|
||||
|
||||
```text
|
||||
1. Write failing test for user authentication [completed]
|
||||
2. Implement authentication to pass tests [in_progress]
|
||||
3. Refactor authentication code [pending]
|
||||
4. Verify implementation [pending]
|
||||
```
|
||||
|
||||
### GREEN Phase (8 min)
|
||||
|
||||
Confidence: `▓▓░░░` → Implementing minimal solution
|
||||
|
||||
**Created**: `src/auth/authenticate.ts`
|
||||
|
||||
```typescript
|
||||
type User = {
|
||||
id: string
|
||||
email: string
|
||||
passwordHash: string
|
||||
}
|
||||
|
||||
type AuthSuccess = {
|
||||
type: 'success'
|
||||
user: User
|
||||
}
|
||||
|
||||
type AuthError = {
|
||||
type: 'error'
|
||||
code: 'INVALID_CREDENTIALS' | 'MISSING_PASSWORD'
|
||||
}
|
||||
|
||||
type AuthResult = AuthSuccess | AuthError
|
||||
|
||||
// Minimal mock database
|
||||
const users: User[] = [
|
||||
{
|
||||
id: '1',
|
||||
email: 'user@example.com',
|
||||
passwordHash: '$2a$10$hashedValidPass123!',
|
||||
},
|
||||
]
|
||||
|
||||
async function findUserByEmail(email: string): Promise<User | undefined> {
|
||||
return users.find(u => u.email === email)
|
||||
}
|
||||
|
||||
async function comparePassword(password: string, hash: string): Promise<boolean> {
|
||||
// Simplified for testing - in production use bcrypt
|
||||
return password === 'ValidPass123!' && hash === '$2a$10$hashedValidPass123!'
|
||||
}
|
||||
|
||||
export async function authenticate(credentials: {
|
||||
email: string
|
||||
password: string
|
||||
}): Promise<AuthResult> {
|
||||
// Check password not empty
|
||||
if (!credentials.password) {
|
||||
return { type: 'error', code: 'MISSING_PASSWORD' }
|
||||
}
|
||||
|
||||
// Find user
|
||||
const user = await findUserByEmail(credentials.email)
|
||||
if (!user) {
|
||||
return { type: 'error', code: 'INVALID_CREDENTIALS' }
|
||||
}
|
||||
|
||||
// Verify password
|
||||
const passwordMatch = await comparePassword(credentials.password, user.passwordHash)
|
||||
if (!passwordMatch) {
|
||||
return { type: 'error', code: 'INVALID_CREDENTIALS' }
|
||||
}
|
||||
|
||||
return { type: 'success', user }
|
||||
}
|
||||
```
|
||||
|
||||
**Run tests**: `bun test`
|
||||
|
||||
```
|
||||
authenticate
|
||||
✓ returns success result with valid credentials
|
||||
✓ returns error result with invalid credentials
|
||||
✓ returns error result with empty password
|
||||
|
||||
3 passing
|
||||
```
|
||||
|
||||
All tests pass — implementation complete.
|
||||
|
||||
**Commit**: `feat: implement user authentication to pass tests`
|
||||
|
||||
**Task Update**:
|
||||
|
||||
```text
|
||||
1. Write failing test for user authentication [completed]
|
||||
2. Implement authentication to pass tests [completed]
|
||||
3. Refactor authentication code [in_progress]
|
||||
4. Verify implementation [pending]
|
||||
```
|
||||
|
||||
### REFACTOR Phase (7 min)
|
||||
|
||||
Confidence: `▓▓▓░░` → Improving structure and types
|
||||
|
||||
Observations:
|
||||
- Types scattered throughout file
|
||||
- Password comparison hardcoded
|
||||
- No clear separation of concerns
|
||||
|
||||
**Refactored**: `src/auth/authenticate.ts`
|
||||
|
||||
```typescript
|
||||
// Extract types to top
|
||||
type User = {
|
||||
id: string
|
||||
email: string
|
||||
passwordHash: string
|
||||
}
|
||||
|
||||
type AuthSuccess = {
|
||||
type: 'success'
|
||||
user: User
|
||||
}
|
||||
|
||||
type AuthError = {
|
||||
type: 'error'
|
||||
code: 'INVALID_CREDENTIALS' | 'MISSING_PASSWORD' | 'INVALID_EMAIL'
|
||||
}
|
||||
|
||||
type AuthResult = AuthSuccess | AuthError
|
||||
|
||||
// Extract validation
|
||||
function validateCredentials(credentials: {
|
||||
email: string
|
||||
password: string
|
||||
}): AuthError | null {
|
||||
if (!credentials.password) {
|
||||
return { type: 'error', code: 'MISSING_PASSWORD' }
|
||||
}
|
||||
if (!credentials.email.includes('@')) {
|
||||
return { type: 'error', code: 'INVALID_EMAIL' }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Mock database (unchanged)
|
||||
const users: User[] = [
|
||||
{
|
||||
id: '1',
|
||||
email: 'user@example.com',
|
||||
passwordHash: '$2a$10$hashedValidPass123!',
|
||||
},
|
||||
]
|
||||
|
||||
async function findUserByEmail(email: string): Promise<User | undefined> {
|
||||
return users.find(u => u.email === email)
|
||||
}
|
||||
|
||||
// Extract password verification
|
||||
async function verifyPassword(password: string, hash: string): Promise<boolean> {
|
||||
// Simplified for testing - in production use bcrypt
|
||||
return password === 'ValidPass123!' && hash === '$2a$10$hashedValidPass123!'
|
||||
}
|
||||
|
||||
// Cleaner main function
|
||||
export async function authenticate(credentials: {
|
||||
email: string
|
||||
password: string
|
||||
}): Promise<AuthResult> {
|
||||
// Validate input
|
||||
const validationError = validateCredentials(credentials)
|
||||
if (validationError) {
|
||||
return validationError
|
||||
}
|
||||
|
||||
// Find user
|
||||
const user = await findUserByEmail(credentials.email)
|
||||
if (!user) {
|
||||
return { type: 'error', code: 'INVALID_CREDENTIALS' }
|
||||
}
|
||||
|
||||
// Verify password
|
||||
const isValid = await verifyPassword(credentials.password, user.passwordHash)
|
||||
if (!isValid) {
|
||||
return { type: 'error', code: 'INVALID_CREDENTIALS' }
|
||||
}
|
||||
|
||||
return { type: 'success', user }
|
||||
}
|
||||
```
|
||||
|
||||
**Run tests**: `bun test`
|
||||
|
||||
```
|
||||
authenticate
|
||||
✓ returns success result with valid credentials
|
||||
✓ returns error result with invalid credentials
|
||||
✓ returns error result with empty password
|
||||
|
||||
3 passing
|
||||
```
|
||||
|
||||
Tests still pass — refactoring successful.
|
||||
|
||||
**Commit**: `refactor: extract validation and password verification`
|
||||
|
||||
**Task Update**:
|
||||
|
||||
```text
|
||||
1. Write failing test for user authentication [completed]
|
||||
2. Implement authentication to pass tests [completed]
|
||||
3. Refactor authentication code [completed]
|
||||
4. Verify implementation [in_progress]
|
||||
```
|
||||
|
||||
### VERIFY Phase (3 min)
|
||||
|
||||
Run full test suite with coverage:
|
||||
|
||||
```bash
|
||||
bun test --coverage
|
||||
```
|
||||
|
||||
```
|
||||
authenticate
|
||||
✓ returns success result with valid credentials
|
||||
✓ returns error result with invalid credentials
|
||||
✓ returns error result with empty password
|
||||
|
||||
Coverage:
|
||||
File | % Stmts | % Branch | % Funcs | % Lines
|
||||
------------------|---------|----------|---------|--------
|
||||
authenticate.ts | 95.45 | 100 | 100 | 95.45
|
||||
|
||||
3 passing
|
||||
```
|
||||
|
||||
Coverage ≥80% — quality standards met.
|
||||
|
||||
**Task Update**:
|
||||
|
||||
```text
|
||||
1. Write failing test for user authentication [completed]
|
||||
2. Implement authentication to pass tests [completed]
|
||||
3. Refactor authentication code [completed]
|
||||
4. Verify implementation [completed]
|
||||
```
|
||||
|
||||
## Cycle 2: Email Validation
|
||||
|
||||
Starting new cycle for email validation edge cases.
|
||||
|
||||
**Task Update**:
|
||||
|
||||
```text
|
||||
1. Write failing test for email validation [in_progress]
|
||||
2. Implement email validation to pass tests [pending]
|
||||
3. Refactor email validation [pending]
|
||||
4. Verify implementation [pending]
|
||||
```
|
||||
|
||||
### RED Phase (4 min)
|
||||
|
||||
Add tests for email validation edge cases:
|
||||
|
||||
```typescript
|
||||
describe('authenticate - email validation', () => {
|
||||
test('returns error for invalid email format', async () => {
|
||||
const result = await authenticate({
|
||||
email: 'not-an-email',
|
||||
password: 'ValidPass123!',
|
||||
})
|
||||
|
||||
expect(result.type).toBe('error')
|
||||
if (result.type === 'error') {
|
||||
expect(result.code).toBe('INVALID_EMAIL')
|
||||
}
|
||||
})
|
||||
|
||||
test('returns error for empty email', async () => {
|
||||
const result = await authenticate({
|
||||
email: '',
|
||||
password: 'ValidPass123!',
|
||||
})
|
||||
|
||||
expect(result.type).toBe('error')
|
||||
if (result.type === 'error') {
|
||||
expect(result.code).toBe('INVALID_EMAIL')
|
||||
}
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
**Run tests**: `bun test`
|
||||
|
||||
```
|
||||
authenticate - email validation
|
||||
✓ returns error for invalid email format # Already passes!
|
||||
✗ returns error for empty email
|
||||
Expected code: 'INVALID_EMAIL'
|
||||
Received code: 'MISSING_PASSWORD'
|
||||
|
||||
1 failing
|
||||
```
|
||||
|
||||
One test passes (basic email check exists), one fails.
|
||||
|
||||
**Commit**: `test: add email validation edge case tests`
|
||||
|
||||
### GREEN Phase (3 min)
|
||||
|
||||
Update validation to handle empty email:
|
||||
|
||||
```typescript
|
||||
function validateCredentials(credentials: {
|
||||
email: string
|
||||
password: string
|
||||
}): AuthError | null {
|
||||
if (!credentials.email) {
|
||||
return { type: 'error', code: 'INVALID_EMAIL' }
|
||||
}
|
||||
if (!credentials.password) {
|
||||
return { type: 'error', code: 'MISSING_PASSWORD' }
|
||||
}
|
||||
if (!credentials.email.includes('@')) {
|
||||
return { type: 'error', code: 'INVALID_EMAIL' }
|
||||
}
|
||||
return null
|
||||
}
|
||||
```
|
||||
|
||||
**Run tests**: `bun test`
|
||||
|
||||
```
|
||||
authenticate - email validation
|
||||
✓ returns error for invalid email format
|
||||
✓ returns error for empty email
|
||||
|
||||
All tests passing (5 total)
|
||||
```
|
||||
|
||||
**Commit**: `feat: validate empty email addresses`
|
||||
|
||||
### REFACTOR Phase (4 min)
|
||||
|
||||
Extract email validation to dedicated function:
|
||||
|
||||
```typescript
|
||||
function isValidEmail(email: string): boolean {
|
||||
return email.length > 0 && email.includes('@')
|
||||
}
|
||||
|
||||
function validateCredentials(credentials: {
|
||||
email: string
|
||||
password: string
|
||||
}): AuthError | null {
|
||||
if (!isValidEmail(credentials.email)) {
|
||||
return { type: 'error', code: 'INVALID_EMAIL' }
|
||||
}
|
||||
if (!credentials.password) {
|
||||
return { type: 'error', code: 'MISSING_PASSWORD' }
|
||||
}
|
||||
return null
|
||||
}
|
||||
```
|
||||
|
||||
**Run tests**: `bun test` — All passing
|
||||
|
||||
**Commit**: `refactor: extract email validation function`
|
||||
|
||||
### VERIFY Phase (2 min)
|
||||
|
||||
```bash
|
||||
bun test --coverage
|
||||
```
|
||||
|
||||
Coverage: 96.2% — excellent.
|
||||
|
||||
## Cycle 3: Rate Limiting
|
||||
|
||||
Implementing rate limiting for failed authentication attempts.
|
||||
|
||||
### RED Phase (6 min)
|
||||
|
||||
Add tests for rate limiting:
|
||||
|
||||
```typescript
|
||||
describe('authenticate - rate limiting', () => {
|
||||
test('allows authentication after successful login', async () => {
|
||||
const validCreds = {
|
||||
email: 'user@example.com',
|
||||
password: 'ValidPass123!',
|
||||
}
|
||||
|
||||
const result1 = await authenticate(validCreds)
|
||||
const result2 = await authenticate(validCreds)
|
||||
|
||||
expect(result1.type).toBe('success')
|
||||
expect(result2.type).toBe('success')
|
||||
})
|
||||
|
||||
test('blocks authentication after 3 failed attempts', async () => {
|
||||
const invalidCreds = {
|
||||
email: 'user@example.com',
|
||||
password: 'wrong',
|
||||
}
|
||||
|
||||
// 3 failed attempts
|
||||
await authenticate(invalidCreds)
|
||||
await authenticate(invalidCreds)
|
||||
await authenticate(invalidCreds)
|
||||
|
||||
// 4th attempt should be rate limited
|
||||
const result = await authenticate(invalidCreds)
|
||||
|
||||
expect(result.type).toBe('error')
|
||||
if (result.type === 'error') {
|
||||
expect(result.code).toBe('RATE_LIMITED')
|
||||
}
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
**Run tests**: `bun test` — Rate limit tests fail as expected
|
||||
|
||||
**Commit**: `test: add rate limiting tests`
|
||||
|
||||
### GREEN Phase (10 min)
|
||||
|
||||
Implement basic rate limiting:
|
||||
|
||||
```typescript
|
||||
type AuthError = {
|
||||
type: 'error'
|
||||
code: 'INVALID_CREDENTIALS' | 'MISSING_PASSWORD' | 'INVALID_EMAIL' | 'RATE_LIMITED'
|
||||
}
|
||||
|
||||
// Track failed attempts
|
||||
const failedAttempts = new Map<string, number>()
|
||||
|
||||
function incrementFailedAttempts(email: string): void {
|
||||
const current = failedAttempts.get(email) || 0
|
||||
failedAttempts.set(email, current + 1)
|
||||
}
|
||||
|
||||
function resetFailedAttempts(email: string): void {
|
||||
failedAttempts.delete(email)
|
||||
}
|
||||
|
||||
function isRateLimited(email: string): boolean {
|
||||
const attempts = failedAttempts.get(email) || 0
|
||||
return attempts >= 3
|
||||
}
|
||||
|
||||
export async function authenticate(credentials: {
|
||||
email: string
|
||||
password: string
|
||||
}): Promise<AuthResult> {
|
||||
// Check rate limiting first
|
||||
if (isRateLimited(credentials.email)) {
|
||||
return { type: 'error', code: 'RATE_LIMITED' }
|
||||
}
|
||||
|
||||
// Validate input
|
||||
const validationError = validateCredentials(credentials)
|
||||
if (validationError) {
|
||||
return validationError
|
||||
}
|
||||
|
||||
// Find user
|
||||
const user = await findUserByEmail(credentials.email)
|
||||
if (!user) {
|
||||
incrementFailedAttempts(credentials.email)
|
||||
return { type: 'error', code: 'INVALID_CREDENTIALS' }
|
||||
}
|
||||
|
||||
// Verify password
|
||||
const isValid = await verifyPassword(credentials.password, user.passwordHash)
|
||||
if (!isValid) {
|
||||
incrementFailedAttempts(credentials.email)
|
||||
return { type: 'error', code: 'INVALID_CREDENTIALS' }
|
||||
}
|
||||
|
||||
// Reset on success
|
||||
resetFailedAttempts(credentials.email)
|
||||
return { type: 'success', user }
|
||||
}
|
||||
```
|
||||
|
||||
**Run tests**: `bun test` — All 7 tests passing
|
||||
|
||||
**Commit**: `feat: implement rate limiting for failed authentication`
|
||||
|
||||
### REFACTOR Phase (6 min)
|
||||
|
||||
Extract rate limiting to separate module for testability:
|
||||
|
||||
**Created**: `src/auth/rate-limiter.ts`
|
||||
|
||||
```typescript
|
||||
export class RateLimiter {
|
||||
private attempts = new Map<string, number>()
|
||||
|
||||
constructor(private maxAttempts: number = 3) {}
|
||||
|
||||
increment(key: string): void {
|
||||
const current = this.attempts.get(key) || 0
|
||||
this.attempts.set(key, current + 1)
|
||||
}
|
||||
|
||||
reset(key: string): void {
|
||||
this.attempts.delete(key)
|
||||
}
|
||||
|
||||
isLimited(key: string): boolean {
|
||||
const attempts = this.attempts.get(key) || 0
|
||||
return attempts >= this.maxAttempts
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Update `authenticate.ts` to use class:
|
||||
|
||||
```typescript
|
||||
import { RateLimiter } from './rate-limiter'
|
||||
|
||||
const rateLimiter = new RateLimiter(3)
|
||||
|
||||
export async function authenticate(credentials: {
|
||||
email: string
|
||||
password: string
|
||||
}): Promise<AuthResult> {
|
||||
// Check rate limiting first
|
||||
if (rateLimiter.isLimited(credentials.email)) {
|
||||
return { type: 'error', code: 'RATE_LIMITED' }
|
||||
}
|
||||
|
||||
// ... rest unchanged ...
|
||||
|
||||
// On failure
|
||||
if (!isValid) {
|
||||
rateLimiter.increment(credentials.email)
|
||||
return { type: 'error', code: 'INVALID_CREDENTIALS' }
|
||||
}
|
||||
|
||||
// On success
|
||||
rateLimiter.reset(credentials.email)
|
||||
return { type: 'success', user }
|
||||
}
|
||||
```
|
||||
|
||||
**Run tests**: `bun test` — All passing
|
||||
|
||||
**Commit**: `refactor: extract rate limiter to separate class`
|
||||
|
||||
### VERIFY Phase (5 min)
|
||||
|
||||
Final verification with mutation testing:
|
||||
|
||||
```bash
|
||||
bun test --coverage
|
||||
bun x stryker run
|
||||
```
|
||||
|
||||
Results:
|
||||
- Coverage: 94.8%
|
||||
- Mutation score: 78.3%
|
||||
- All tests passing
|
||||
|
||||
**Task**: All completed
|
||||
|
||||
## Session Summary
|
||||
|
||||
Duration: 45 minutes
|
||||
Cycles: 3 complete RED-GREEN-REFACTOR cycles
|
||||
Tests: 7 tests, all passing
|
||||
Coverage: 94.8% line coverage
|
||||
Mutation: 78.3% mutation score
|
||||
|
||||
Features implemented:
|
||||
1. Basic authentication with email/password
|
||||
2. Email validation
|
||||
3. Rate limiting for failed attempts
|
||||
|
||||
Code quality:
|
||||
- All types explicit
|
||||
- Functions single-purpose
|
||||
- Tests cover happy path and edge cases
|
||||
- Mutation testing verifies test quality
|
||||
|
||||
Next steps:
|
||||
- Add integration tests with real database
|
||||
- Implement actual bcrypt password hashing
|
||||
- Add time-based rate limit expiration
|
||||
@@ -0,0 +1,587 @@
|
||||
# Test Quality Metrics
|
||||
|
||||
Comprehensive guide to measuring and improving test quality through coverage and mutation testing.
|
||||
|
||||
## Coverage Metrics
|
||||
|
||||
### Line Coverage
|
||||
|
||||
Percentage of code lines executed during test runs.
|
||||
|
||||
**Target**: ≥80% overall, ≥90% for critical paths
|
||||
|
||||
**TypeScript/Bun**:
|
||||
|
||||
```bash
|
||||
bun test --coverage
|
||||
|
||||
# Output
|
||||
Coverage Summary:
|
||||
Statements : 85.2% ( 1420/1667 )
|
||||
Branches : 78.5% ( 314/400 )
|
||||
Functions : 82.1% ( 156/190 )
|
||||
Lines : 85.2% ( 1420/1667 )
|
||||
```
|
||||
|
||||
**Rust**:
|
||||
|
||||
```bash
|
||||
# Using cargo-tarpaulin
|
||||
cargo tarpaulin --out Html --output-dir coverage/
|
||||
|
||||
# Using cargo-llvm-cov
|
||||
cargo llvm-cov --html
|
||||
```
|
||||
|
||||
### Branch Coverage
|
||||
|
||||
Percentage of decision branches (if/else, switch, ternary) executed.
|
||||
|
||||
**Target**: ≥75%
|
||||
|
||||
Example showing uncovered branch:
|
||||
|
||||
```typescript
|
||||
function divide(a: number, b: number): number {
|
||||
if (b === 0) { // Branch covered
|
||||
throw new Error('Division by zero')
|
||||
}
|
||||
return a / b // Branch covered
|
||||
}
|
||||
|
||||
// Test only covers success path
|
||||
test('divides numbers', () => {
|
||||
expect(divide(10, 2)).toBe(5)
|
||||
})
|
||||
|
||||
// Coverage: 50% branches (only success branch covered)
|
||||
```
|
||||
|
||||
Fix with both branches:
|
||||
|
||||
```typescript
|
||||
test('divides numbers', () => {
|
||||
expect(divide(10, 2)).toBe(5)
|
||||
})
|
||||
|
||||
test('throws on division by zero', () => {
|
||||
expect(() => divide(10, 0)).toThrow('Division by zero')
|
||||
})
|
||||
|
||||
// Coverage: 100% branches
|
||||
```
|
||||
|
||||
### Function Coverage
|
||||
|
||||
Percentage of functions called during tests.
|
||||
|
||||
**Target**: ≥80%
|
||||
|
||||
Uncovered functions often indicate:
|
||||
- Dead code that should be removed
|
||||
- Missing test cases
|
||||
- Helper functions only used in uncovered paths
|
||||
|
||||
### Interpreting Coverage
|
||||
|
||||
High coverage ≠ high quality. Coverage shows what's tested, not how well.
|
||||
|
||||
**Example of misleading coverage**:
|
||||
|
||||
```typescript
|
||||
function processPayment(amount: number): Result {
|
||||
if (amount <= 0) {
|
||||
return { type: 'error', code: 'INVALID_AMOUNT' }
|
||||
}
|
||||
|
||||
const result = chargeCard(amount)
|
||||
return { type: 'success', transactionId: result.id }
|
||||
}
|
||||
|
||||
// Bad test with 100% coverage
|
||||
test('processes payment', () => {
|
||||
processPayment(100)
|
||||
processPayment(-10)
|
||||
})
|
||||
|
||||
// No assertions! 100% coverage but 0% verification
|
||||
```
|
||||
|
||||
Coverage shows code was executed, not that it was verified correct.
|
||||
|
||||
## Mutation Testing
|
||||
|
||||
Mutation testing verifies test quality by introducing small bugs and checking if tests catch them.
|
||||
|
||||
### How It Works
|
||||
|
||||
1. **Mutant Generation**: Tool mutates source code (e.g., `===` → `!==`, `+` → `-`)
|
||||
2. **Test Execution**: Run tests against each mutant
|
||||
3. **Classification**:
|
||||
- **Killed**: Test fails (good — test caught the bug)
|
||||
- **Survived**: Test passes (bad — test missed the bug)
|
||||
- **Timeout**: Mutant caused infinite loop
|
||||
- **No Coverage**: Line not executed by tests
|
||||
|
||||
### Mutation Score
|
||||
|
||||
```
|
||||
Mutation Score = (Killed Mutants / Total Mutants) × 100%
|
||||
```
|
||||
|
||||
**Target**: ≥75%
|
||||
|
||||
### TypeScript Mutation Testing
|
||||
|
||||
Using Stryker:
|
||||
|
||||
**Install**:
|
||||
|
||||
```bash
|
||||
bun add -d @stryker-mutator/core @stryker-mutator/typescript-checker
|
||||
```
|
||||
|
||||
**Configuration** (`stryker.conf.json`):
|
||||
|
||||
```json
|
||||
{
|
||||
"mutator": "typescript",
|
||||
"packageManager": "bun",
|
||||
"reporters": ["html", "clear-text", "progress"],
|
||||
"testRunner": "bun",
|
||||
"coverageAnalysis": "perTest",
|
||||
"mutate": [
|
||||
"src/**/*.ts",
|
||||
"!src/**/*.test.ts",
|
||||
"!src/**/*.spec.ts"
|
||||
],
|
||||
"thresholds": {
|
||||
"high": 80,
|
||||
"low": 60,
|
||||
"break": 50
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Run**:
|
||||
|
||||
```bash
|
||||
bun x stryker run
|
||||
|
||||
# Output
|
||||
Mutation testing complete:
|
||||
Killed: 78
|
||||
Survived: 12
|
||||
Timeout: 2
|
||||
No Coverage: 8
|
||||
Mutation Score: 78.0%
|
||||
```
|
||||
|
||||
**Common Mutations**:
|
||||
|
||||
| Original | Mutant | Catches |
|
||||
|----------|--------|---------|
|
||||
| `===` | `!==` | Equality assertions |
|
||||
| `>` | `>=` | Boundary tests |
|
||||
| `+` | `-` | Arithmetic verification |
|
||||
| `&&` | `||` | Logic tests |
|
||||
| `true` | `false` | Boolean verification |
|
||||
| `0` | `1` | Zero handling |
|
||||
| `return x` | `return undefined` | Return value tests |
|
||||
|
||||
**Example Analysis**:
|
||||
|
||||
```typescript
|
||||
function calculateDiscount(price: number, isPremium: boolean): number {
|
||||
if (isPremium) {
|
||||
return price * 0.8 // 20% discount
|
||||
}
|
||||
return price
|
||||
}
|
||||
|
||||
// Weak test
|
||||
test('calculates discount', () => {
|
||||
calculateDiscount(100, true)
|
||||
calculateDiscount(100, false)
|
||||
})
|
||||
|
||||
// Mutation: 0.8 → 0.9
|
||||
// Status: Survived (no assertion)
|
||||
```
|
||||
|
||||
Fix with assertions:
|
||||
|
||||
```typescript
|
||||
test('applies 20% discount for premium users', () => {
|
||||
expect(calculateDiscount(100, true)).toBe(80)
|
||||
})
|
||||
|
||||
test('no discount for regular users', () => {
|
||||
expect(calculateDiscount(100, false)).toBe(100)
|
||||
})
|
||||
|
||||
// Mutation: 0.8 → 0.9
|
||||
// Status: Killed (test fails with 90 !== 80)
|
||||
```
|
||||
|
||||
### Rust Mutation Testing
|
||||
|
||||
Using `cargo-mutants`:
|
||||
|
||||
**Install**:
|
||||
|
||||
```bash
|
||||
cargo install cargo-mutants
|
||||
```
|
||||
|
||||
**Run**:
|
||||
|
||||
```bash
|
||||
cargo mutants
|
||||
|
||||
# Output
|
||||
Mutation testing results:
|
||||
caught: 45
|
||||
missed: 5
|
||||
timeout: 1
|
||||
unviable: 2
|
||||
score: 90.0%
|
||||
```
|
||||
|
||||
**Common Mutations**:
|
||||
|
||||
| Original | Mutant | Catches |
|
||||
|----------|--------|---------|
|
||||
| `==` | `!=` | Equality tests |
|
||||
| `>` | `>=` | Boundary tests |
|
||||
| `&&` | `||` | Logic tests |
|
||||
| `Some(x)` | `None` | Option handling |
|
||||
| `Ok(x)` | `Err(...)` | Result handling |
|
||||
| `+` | `-` | Arithmetic verification |
|
||||
|
||||
**Example**:
|
||||
|
||||
```rust
|
||||
fn calculate_discount(price: i32, is_premium: bool) -> i32 {
|
||||
if is_premium {
|
||||
price * 80 / 100 // 20% discount
|
||||
} else {
|
||||
price
|
||||
}
|
||||
}
|
||||
|
||||
// Weak test
|
||||
#[test]
|
||||
fn test_discount() {
|
||||
calculate_discount(100, true);
|
||||
calculate_discount(100, false);
|
||||
}
|
||||
|
||||
// Mutation: 80 → 90
|
||||
// Status: missed (no assertion)
|
||||
```
|
||||
|
||||
Fix:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn applies_discount_for_premium() {
|
||||
assert_eq!(calculate_discount(100, true), 80);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_discount_for_regular() {
|
||||
assert_eq!(calculate_discount(100, false), 100);
|
||||
}
|
||||
|
||||
// Mutation: 80 → 90
|
||||
// Status: caught (assertion fails)
|
||||
```
|
||||
|
||||
## Quality Standards Matrix
|
||||
|
||||
| Metric | Minimum | Good | Excellent |
|
||||
|--------|---------|------|-----------|
|
||||
| Line Coverage | 70% | 80% | 90% |
|
||||
| Branch Coverage | 65% | 75% | 85% |
|
||||
| Function Coverage | 75% | 85% | 95% |
|
||||
| Mutation Score | 60% | 75% | 85% |
|
||||
| Test Execution Time | <10s | <5s | <2s |
|
||||
|
||||
## Improving Test Quality
|
||||
|
||||
### Weak Assertion Detection
|
||||
|
||||
**Problem**: Tests execute code but don't verify results
|
||||
|
||||
```typescript
|
||||
// ❌ Weak - no verification
|
||||
test('processes order', () => {
|
||||
processOrder({ items: [item1, item2] })
|
||||
})
|
||||
```
|
||||
|
||||
**Solution**:
|
||||
|
||||
```typescript
|
||||
// ✓ Strong - verifies result
|
||||
test('processes order', () => {
|
||||
const result = processOrder({ items: [item1, item2] })
|
||||
expect(result.type).toBe('success')
|
||||
expect(result.total).toBe(150)
|
||||
})
|
||||
```
|
||||
|
||||
### Missing Edge Cases
|
||||
|
||||
Use mutation testing to find gaps:
|
||||
|
||||
```typescript
|
||||
function validateAge(age: number): boolean {
|
||||
return age >= 18 // Mutant: >= → >
|
||||
}
|
||||
|
||||
// Current test
|
||||
test('validates age', () => {
|
||||
expect(validateAge(20)).toBe(true)
|
||||
expect(validateAge(16)).toBe(false)
|
||||
})
|
||||
|
||||
// Mutation survived: >= → >
|
||||
// Missing: boundary test for exactly 18
|
||||
```
|
||||
|
||||
Add boundary test:
|
||||
|
||||
```typescript
|
||||
test('accepts exactly 18', () => {
|
||||
expect(validateAge(18)).toBe(true)
|
||||
})
|
||||
|
||||
// Now mutation is caught
|
||||
```
|
||||
|
||||
### Test Redundancy
|
||||
|
||||
Multiple tests verifying same thing:
|
||||
|
||||
```typescript
|
||||
// Redundant tests
|
||||
test('validates positive number', () => {
|
||||
expect(isPositive(5)).toBe(true)
|
||||
})
|
||||
|
||||
test('validates another positive number', () => {
|
||||
expect(isPositive(10)).toBe(true)
|
||||
})
|
||||
|
||||
test('validates yet another positive number', () => {
|
||||
expect(isPositive(100)).toBe(true)
|
||||
})
|
||||
```
|
||||
|
||||
Consolidate:
|
||||
|
||||
```typescript
|
||||
test.each([5, 10, 100])('validates positive number %i', (num) => {
|
||||
expect(isPositive(num)).toBe(true)
|
||||
})
|
||||
```
|
||||
|
||||
## Continuous Quality Monitoring
|
||||
|
||||
### CI/CD Integration
|
||||
|
||||
**TypeScript**:
|
||||
|
||||
```yaml
|
||||
# .github/workflows/test.yml
|
||||
- name: Run tests with coverage
|
||||
run: bun test --coverage
|
||||
|
||||
- name: Check coverage thresholds
|
||||
run: |
|
||||
coverage=$(bun test --coverage --json | jq '.coverage.total.statements.pct')
|
||||
if (( $(echo "$coverage < 80" | bc -l) )); then
|
||||
echo "Coverage $coverage% below 80% threshold"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Run mutation testing
|
||||
run: bun x stryker run
|
||||
# Fail if mutation score < 75%
|
||||
```
|
||||
|
||||
**Rust**:
|
||||
|
||||
```yaml
|
||||
# .github/workflows/test.yml
|
||||
- name: Run tests with coverage
|
||||
run: cargo tarpaulin --fail-under 80
|
||||
|
||||
- name: Run mutation testing
|
||||
run: cargo mutants
|
||||
continue-on-error: true # Warning only initially
|
||||
```
|
||||
|
||||
### Tracking Over Time
|
||||
|
||||
Monitor trends:
|
||||
|
||||
```bash
|
||||
# Generate coverage badge
|
||||
coverage=$(bun test --coverage --json | jq '.coverage.total.statements.pct')
|
||||
echo "Coverage: $coverage%" > coverage.txt
|
||||
|
||||
# Track mutation score
|
||||
mutation=$(bun x stryker run --json | jq '.mutationScore')
|
||||
echo "Mutation Score: $mutation%" > mutation.txt
|
||||
```
|
||||
|
||||
## Advanced Techniques
|
||||
|
||||
### Differential Coverage
|
||||
|
||||
Only measure coverage on changed code:
|
||||
|
||||
```bash
|
||||
# Get changed files
|
||||
git diff --name-only main... > changed.txt
|
||||
|
||||
# Run coverage on changed files
|
||||
bun test --coverage --changed-files changed.txt
|
||||
```
|
||||
|
||||
### Coverage Ratcheting
|
||||
|
||||
Prevent coverage from decreasing:
|
||||
|
||||
```bash
|
||||
# Save current coverage
|
||||
current=$(bun test --coverage --json | jq '.coverage.total.statements.pct')
|
||||
echo "$current" > .baseline-coverage
|
||||
|
||||
# On future runs, compare
|
||||
baseline=$(cat .baseline-coverage)
|
||||
if (( $(echo "$current < $baseline" | bc -l) )); then
|
||||
echo "Coverage decreased from $baseline% to $current%"
|
||||
exit 1
|
||||
fi
|
||||
```
|
||||
|
||||
### Mutation Testing Optimization
|
||||
|
||||
Run only on changed code:
|
||||
|
||||
```bash
|
||||
# Stryker incremental mode
|
||||
bun x stryker run --incremental
|
||||
|
||||
# cargo-mutants on specific files
|
||||
cargo mutants --file src/auth/mod.rs
|
||||
```
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
### Pitfall 1: Chasing 100% Coverage
|
||||
|
||||
**Problem**: Diminishing returns past 90%, testing trivial code
|
||||
|
||||
```typescript
|
||||
// Trivial getter - not worth testing
|
||||
class User {
|
||||
get email(): string {
|
||||
return this._email
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Solution**: Focus on behavior, not line count. Exclude trivial code from coverage requirements.
|
||||
|
||||
### Pitfall 2: Gaming Metrics
|
||||
|
||||
**Problem**: Tests that execute code without verification
|
||||
|
||||
```typescript
|
||||
// ❌ High coverage, zero value
|
||||
test('calls all functions', () => {
|
||||
func1()
|
||||
func2()
|
||||
func3()
|
||||
})
|
||||
```
|
||||
|
||||
**Solution**: Use mutation testing to catch weak assertions.
|
||||
|
||||
### Pitfall 3: Slow Mutation Testing
|
||||
|
||||
**Problem**: Full mutation testing takes hours
|
||||
|
||||
**Solution**: Run incrementally or in CI only:
|
||||
|
||||
```bash
|
||||
# Local: Quick feedback on changed files
|
||||
bun x stryker run --mutate "src/auth/**/*.ts"
|
||||
|
||||
# CI: Full suite
|
||||
bun x stryker run
|
||||
```
|
||||
|
||||
## Quality Metrics Dashboard
|
||||
|
||||
Example report format:
|
||||
|
||||
```
|
||||
Test Quality Report
|
||||
===================
|
||||
|
||||
Coverage:
|
||||
Statements: 85.2% ░░░░░░░░▓▓
|
||||
Branches: 78.5% ░░░░░░░▓▓▓
|
||||
Functions: 82.1% ░░░░░░░░▓▓
|
||||
|
||||
Mutation Testing:
|
||||
Score: 78.0% ░░░░░░░▓▓▓
|
||||
Killed: 78
|
||||
Survived: 12
|
||||
No Cov: 8
|
||||
|
||||
Performance:
|
||||
Unit Tests: 2.3s ✓
|
||||
Total: 8.7s ✓
|
||||
|
||||
Status: ✓ All thresholds met
|
||||
```
|
||||
|
||||
## Actionable Improvement Plan
|
||||
|
||||
1. **Week 1**: Establish baseline
|
||||
- Run coverage analysis
|
||||
- Run mutation testing
|
||||
- Document current state
|
||||
|
||||
2. **Week 2-3**: Fix critical gaps
|
||||
- Add tests for uncovered critical paths
|
||||
- Fix survived mutants in high-risk code
|
||||
- Target 80% coverage, 75% mutation score
|
||||
|
||||
3. **Week 4**: Automate
|
||||
- Add CI coverage checks
|
||||
- Set up coverage ratcheting
|
||||
- Schedule weekly mutation testing
|
||||
|
||||
4. **Ongoing**: Maintain
|
||||
- Review coverage on each PR
|
||||
- Run mutation testing monthly
|
||||
- Gradually raise thresholds
|
||||
|
||||
## Resources
|
||||
|
||||
TypeScript:
|
||||
- [Stryker Documentation](https://stryker-mutator.io)
|
||||
- [Bun Test Coverage](https://bun.sh/docs/cli/test#coverage)
|
||||
|
||||
Rust:
|
||||
- [cargo-tarpaulin](https://github.com/xd009642/tarpaulin)
|
||||
- [cargo-llvm-cov](https://github.com/taiki-e/cargo-llvm-cov)
|
||||
- [cargo-mutants](https://github.com/sourcefrog/cargo-mutants)
|
||||
@@ -0,0 +1,804 @@
|
||||
# Test Patterns Reference
|
||||
|
||||
Comprehensive test patterns for TypeScript/Bun and Rust.
|
||||
|
||||
## TypeScript/Bun Patterns
|
||||
|
||||
### Basic Test Structure
|
||||
|
||||
```typescript
|
||||
import { describe, test, expect } from 'bun:test'
|
||||
|
||||
describe('Module or Feature Name', () => {
|
||||
test('describes specific behavior', () => {
|
||||
// Arrange
|
||||
const input = createTestInput()
|
||||
|
||||
// Act
|
||||
const result = functionUnderTest(input)
|
||||
|
||||
// Assert
|
||||
expect(result).toBe(expected)
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### Discriminated Unions for Test Scenarios
|
||||
|
||||
Use discriminated unions to make test scenarios type-safe:
|
||||
|
||||
```typescript
|
||||
type TestScenario =
|
||||
| { type: 'success'; input: ValidInput; expected: Output }
|
||||
| { type: 'error'; input: InvalidInput; expectedError: ErrorCode }
|
||||
| { type: 'edge-case'; input: EdgeInput; expected: Output }
|
||||
|
||||
test.each<TestScenario>([
|
||||
{
|
||||
type: 'success',
|
||||
input: { value: 100 },
|
||||
expected: { result: 100 },
|
||||
},
|
||||
{
|
||||
type: 'error',
|
||||
input: { value: -1 },
|
||||
expectedError: 'NEGATIVE_VALUE',
|
||||
},
|
||||
{
|
||||
type: 'edge-case',
|
||||
input: { value: 0 },
|
||||
expected: { result: 0 },
|
||||
},
|
||||
])('handles $type scenario', async (scenario) => {
|
||||
const result = await processValue(scenario.input)
|
||||
|
||||
if (scenario.type === 'success' || scenario.type === 'edge-case') {
|
||||
expect(result).toEqual(scenario.expected)
|
||||
} else {
|
||||
expect(result.error).toBe(scenario.expectedError)
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### Type-Safe Test Builders
|
||||
|
||||
Create fluent builders for complex test data:
|
||||
|
||||
```typescript
|
||||
class UserBuilder {
|
||||
private data: Partial<User> = {
|
||||
id: 'test-id',
|
||||
email: 'test@example.com',
|
||||
role: 'user',
|
||||
createdAt: new Date('2024-01-01'),
|
||||
}
|
||||
|
||||
withId(id: string): this {
|
||||
this.data.id = id
|
||||
return this
|
||||
}
|
||||
|
||||
withEmail(email: string): this {
|
||||
this.data.email = email
|
||||
return this
|
||||
}
|
||||
|
||||
withRole(role: UserRole): this {
|
||||
this.data.role = role
|
||||
return this
|
||||
}
|
||||
|
||||
asAdmin(): this {
|
||||
return this.withRole('admin')
|
||||
}
|
||||
|
||||
build(): User {
|
||||
return this.data as User
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
const adminUser = new UserBuilder()
|
||||
.withEmail('admin@example.com')
|
||||
.asAdmin()
|
||||
.build()
|
||||
```
|
||||
|
||||
Generic builder for flexibility:
|
||||
|
||||
```typescript
|
||||
class Builder<T> {
|
||||
constructor(private defaults: T) {}
|
||||
|
||||
with<K extends keyof T>(key: K, value: T[K]): this {
|
||||
this.defaults = { ...this.defaults, [key]: value }
|
||||
return this
|
||||
}
|
||||
|
||||
build(): T {
|
||||
return { ...this.defaults }
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
const userBuilder = new Builder<User>({
|
||||
id: 'test-id',
|
||||
email: 'test@example.com',
|
||||
role: 'user',
|
||||
})
|
||||
|
||||
const admin = userBuilder.with('role', 'admin').build()
|
||||
```
|
||||
|
||||
### Const Assertions for Test Data
|
||||
|
||||
Type-safe test data with const assertions:
|
||||
|
||||
```typescript
|
||||
const validInputs = [
|
||||
{ input: 'hello', expected: 'HELLO' },
|
||||
{ input: 'world', expected: 'WORLD' },
|
||||
{ input: '', expected: '' },
|
||||
] as const
|
||||
|
||||
test.each(validInputs)(
|
||||
'transforms $input to $expected',
|
||||
({ input, expected }) => {
|
||||
expect(transform(input)).toBe(expected)
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
### Async Testing Patterns
|
||||
|
||||
Promise rejection:
|
||||
|
||||
```typescript
|
||||
test('rejects with error for invalid input', async () => {
|
||||
const promise = fetchUser('invalid-id')
|
||||
|
||||
await expect(promise).rejects.toThrow(UserNotFoundError)
|
||||
await expect(promise).rejects.toThrow('User not found')
|
||||
})
|
||||
```
|
||||
|
||||
Async/await with error handling:
|
||||
|
||||
```typescript
|
||||
test('handles async errors gracefully', async () => {
|
||||
const result = await processData('invalid').catch(err => ({
|
||||
error: err.message,
|
||||
}))
|
||||
|
||||
expect(result.error).toBe('Invalid data')
|
||||
})
|
||||
```
|
||||
|
||||
Timeout handling:
|
||||
|
||||
```typescript
|
||||
test('times out slow operations', async () => {
|
||||
const promise = slowOperation()
|
||||
|
||||
await expect(
|
||||
Promise.race([
|
||||
promise,
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Timeout')), 100)
|
||||
),
|
||||
])
|
||||
).rejects.toThrow('Timeout')
|
||||
})
|
||||
```
|
||||
|
||||
### Mocking with Bun
|
||||
|
||||
Module mocking:
|
||||
|
||||
```typescript
|
||||
import { mock } from 'bun:test'
|
||||
|
||||
// Mock entire module
|
||||
mock.module('./database', () => ({
|
||||
query: mock(() => Promise.resolve({ rows: [] })),
|
||||
connect: mock(() => Promise.resolve()),
|
||||
}))
|
||||
|
||||
// Use in test
|
||||
test('handles database errors', async () => {
|
||||
const { query } = await import('./database')
|
||||
|
||||
query.mockImplementationOnce(() => Promise.reject(new Error('DB Error')))
|
||||
|
||||
const result = await fetchUsers()
|
||||
expect(result.error).toBe('DB Error')
|
||||
})
|
||||
```
|
||||
|
||||
Function mocking:
|
||||
|
||||
```typescript
|
||||
const mockFetch = mock(async (url: string) => ({
|
||||
ok: true,
|
||||
json: async () => ({ data: 'test' }),
|
||||
}))
|
||||
|
||||
test('fetches data successfully', async () => {
|
||||
const result = await fetchData('https://api.example.com', mockFetch)
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith('https://api.example.com')
|
||||
expect(result.data).toBe('test')
|
||||
})
|
||||
```
|
||||
|
||||
### Snapshot Testing
|
||||
|
||||
Simple snapshots:
|
||||
|
||||
```typescript
|
||||
test('serializes user correctly', () => {
|
||||
const user = new UserBuilder().build()
|
||||
|
||||
expect(JSON.stringify(user, null, 2)).toMatchSnapshot()
|
||||
})
|
||||
```
|
||||
|
||||
Inline snapshots:
|
||||
|
||||
```typescript
|
||||
test('formats error message', () => {
|
||||
const error = new ValidationError('Invalid email')
|
||||
|
||||
expect(error.message).toMatchInlineSnapshot(`"Invalid email"`)
|
||||
})
|
||||
```
|
||||
|
||||
### Parameterized Tests
|
||||
|
||||
Basic parameterization:
|
||||
|
||||
```typescript
|
||||
test.each([
|
||||
[1, 1],
|
||||
[2, 4],
|
||||
[3, 9],
|
||||
[4, 16],
|
||||
])('square(%i) returns %i', (input, expected) => {
|
||||
expect(square(input)).toBe(expected)
|
||||
})
|
||||
```
|
||||
|
||||
Object-based parameterization:
|
||||
|
||||
```typescript
|
||||
test.each([
|
||||
{ input: 5, expected: 25, description: 'positive number' },
|
||||
{ input: -3, expected: 9, description: 'negative number' },
|
||||
{ input: 0, expected: 0, description: 'zero' },
|
||||
])('square($input) for $description', ({ input, expected }) => {
|
||||
expect(square(input)).toBe(expected)
|
||||
})
|
||||
```
|
||||
|
||||
### Focused Testing
|
||||
|
||||
Run specific tests:
|
||||
|
||||
```typescript
|
||||
// Only run this test
|
||||
test.only('current feature under development', () => {
|
||||
// Fast feedback during active development
|
||||
})
|
||||
|
||||
// Skip slow tests during TDD
|
||||
test.skip('slow integration test', () => {
|
||||
// Run in CI but not during rapid TDD cycles
|
||||
})
|
||||
|
||||
// Mark test as work in progress
|
||||
test.todo('implement rate limiting')
|
||||
```
|
||||
|
||||
### Parallel Test Execution
|
||||
|
||||
Run independent tests in parallel:
|
||||
|
||||
```typescript
|
||||
describe.concurrent('Independent Operations', () => {
|
||||
test('operation 1', async () => {
|
||||
const result = await independentOp1()
|
||||
expect(result).toBeDefined()
|
||||
})
|
||||
|
||||
test('operation 2', async () => {
|
||||
const result = await independentOp2()
|
||||
expect(result).toBeDefined()
|
||||
})
|
||||
|
||||
test('operation 3', async () => {
|
||||
const result = await independentOp3()
|
||||
expect(result).toBeDefined()
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### Error Testing Patterns
|
||||
|
||||
Exception testing:
|
||||
|
||||
```typescript
|
||||
test('throws error for invalid input', () => {
|
||||
expect(() => processData(null)).toThrow(ValidationError)
|
||||
expect(() => processData(null)).toThrow('Input cannot be null')
|
||||
})
|
||||
```
|
||||
|
||||
Error result testing:
|
||||
|
||||
```typescript
|
||||
test('returns error result for invalid input', () => {
|
||||
const result = processData(null)
|
||||
|
||||
expect(result.type).toBe('error')
|
||||
if (result.type === 'error') {
|
||||
expect(result.code).toBe('INVALID_INPUT')
|
||||
expect(result.message).toContain('null')
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## Rust Patterns
|
||||
|
||||
### Basic Test Structure
|
||||
|
||||
```rust
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_basic_functionality() {
|
||||
// Arrange
|
||||
let input = setup_test_input();
|
||||
|
||||
// Act
|
||||
let result = function_under_test(input);
|
||||
|
||||
// Assert
|
||||
assert_eq!(result, expected);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Property-Based Testing
|
||||
|
||||
Using `proptest`:
|
||||
|
||||
```rust
|
||||
use proptest::prelude::*;
|
||||
|
||||
proptest! {
|
||||
#[test]
|
||||
fn password_hash_is_deterministic(password in "[a-zA-Z0-9]{8,32}") {
|
||||
let hash1 = hash_password(&password);
|
||||
let hash2 = hash_password(&password);
|
||||
prop_assert_eq!(hash1, hash2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn email_validation_never_panics(email in ".*") {
|
||||
let result = validate_email(&email);
|
||||
// Should always return Ok or Err, never panic
|
||||
prop_assert!(result.is_ok() || result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_then_serialize_is_identity(value in 0..1000) {
|
||||
let serialized = serialize_value(value);
|
||||
let parsed = parse_value(&serialized).unwrap();
|
||||
prop_assert_eq!(value, parsed);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Test Builders
|
||||
|
||||
Rust builder pattern:
|
||||
|
||||
```rust
|
||||
#[derive(Default)]
|
||||
struct UserBuilder {
|
||||
id: Option<String>,
|
||||
email: Option<String>,
|
||||
role: Option<Role>,
|
||||
}
|
||||
|
||||
impl UserBuilder {
|
||||
fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
fn with_id(mut self, id: impl Into<String>) -> Self {
|
||||
self.id = Some(id.into());
|
||||
self
|
||||
}
|
||||
|
||||
fn with_email(mut self, email: impl Into<String>) -> Self {
|
||||
self.email = Some(email.into());
|
||||
self
|
||||
}
|
||||
|
||||
fn with_role(mut self, role: Role) -> Self {
|
||||
self.role = Some(role);
|
||||
self
|
||||
}
|
||||
|
||||
fn as_admin(self) -> Self {
|
||||
self.with_role(Role::Admin)
|
||||
}
|
||||
|
||||
fn build(self) -> User {
|
||||
User {
|
||||
id: self.id.unwrap_or_else(|| "test-id".to_string()),
|
||||
email: self.email.unwrap_or_else(|| "test@example.com".to_string()),
|
||||
role: self.role.unwrap_or(Role::User),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
#[test]
|
||||
fn test_admin_permissions() {
|
||||
let admin = UserBuilder::new()
|
||||
.with_email("admin@example.com")
|
||||
.as_admin()
|
||||
.build();
|
||||
|
||||
assert!(has_admin_access(&admin));
|
||||
}
|
||||
```
|
||||
|
||||
### Async Testing
|
||||
|
||||
Using `tokio::test`:
|
||||
|
||||
```rust
|
||||
#[tokio::test]
|
||||
async fn authenticates_user_async() {
|
||||
let credentials = Credentials {
|
||||
email: "user@example.com".to_string(),
|
||||
password: "password".to_string(),
|
||||
};
|
||||
|
||||
let result = authenticate_async(&credentials).await;
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[should_panic(expected = "timeout")]
|
||||
async fn times_out_slow_operations() {
|
||||
tokio::time::timeout(
|
||||
Duration::from_millis(100),
|
||||
very_slow_operation()
|
||||
).await.expect("timeout");
|
||||
}
|
||||
```
|
||||
|
||||
### Result Testing
|
||||
|
||||
Testing `Result` types:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn returns_error_for_invalid_input() {
|
||||
let result = process_data(None);
|
||||
|
||||
assert!(result.is_err());
|
||||
assert!(matches!(result, Err(ProcessError::InvalidInput)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_success_for_valid_input() {
|
||||
let result = process_data(Some("valid"));
|
||||
|
||||
assert!(result.is_ok());
|
||||
let value = result.unwrap();
|
||||
assert_eq!(value, "processed");
|
||||
}
|
||||
```
|
||||
|
||||
Using `assert_matches!` macro:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn authenticates_with_valid_credentials() {
|
||||
let result = authenticate(&valid_creds);
|
||||
|
||||
assert!(matches!(result, Ok(AuthResult::Success { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_credentials() {
|
||||
let result = authenticate(&invalid_creds);
|
||||
|
||||
assert!(matches!(result, Err(AuthError::InvalidCredentials)));
|
||||
}
|
||||
```
|
||||
|
||||
### Documentation Tests
|
||||
|
||||
Executable documentation:
|
||||
|
||||
```rust
|
||||
/// Authenticates a user with credentials.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use auth::{authenticate, Credentials};
|
||||
///
|
||||
/// let creds = Credentials {
|
||||
/// email: "user@example.com".to_string(),
|
||||
/// password: "password".to_string(),
|
||||
/// };
|
||||
///
|
||||
/// let result = authenticate(&creds);
|
||||
/// assert!(result.is_ok());
|
||||
/// ```
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `AuthError::InvalidCredentials` if credentials are invalid:
|
||||
///
|
||||
/// ```
|
||||
/// use auth::{authenticate, Credentials, AuthError};
|
||||
///
|
||||
/// let bad_creds = Credentials {
|
||||
/// email: "wrong@example.com".to_string(),
|
||||
/// password: "wrong".to_string(),
|
||||
/// };
|
||||
///
|
||||
/// let result = authenticate(&bad_creds);
|
||||
/// assert!(matches!(result, Err(AuthError::InvalidCredentials)));
|
||||
/// ```
|
||||
pub fn authenticate(credentials: &Credentials) -> Result<AuthResult, AuthError> {
|
||||
// Implementation
|
||||
}
|
||||
```
|
||||
|
||||
### Snapshot Testing
|
||||
|
||||
Using `insta`:
|
||||
|
||||
```rust
|
||||
use insta::assert_snapshot;
|
||||
|
||||
#[test]
|
||||
fn serializes_user_correctly() {
|
||||
let user = User {
|
||||
id: "test-id".to_string(),
|
||||
email: "test@example.com".to_string(),
|
||||
role: Role::Admin,
|
||||
};
|
||||
|
||||
let serialized = serde_json::to_string_pretty(&user).unwrap();
|
||||
assert_snapshot!(serialized);
|
||||
}
|
||||
```
|
||||
|
||||
### Parameterized Tests
|
||||
|
||||
Manual parameterization:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn test_square() {
|
||||
let test_cases = vec![
|
||||
(5, 25),
|
||||
(-3, 9),
|
||||
(0, 0),
|
||||
(10, 100),
|
||||
];
|
||||
|
||||
for (input, expected) in test_cases {
|
||||
assert_eq!(square(input), expected, "Failed for input {}", input);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Using `rstest`:
|
||||
|
||||
```rust
|
||||
use rstest::rstest;
|
||||
|
||||
#[rstest]
|
||||
#[case(5, 25)]
|
||||
#[case(-3, 9)]
|
||||
#[case(0, 0)]
|
||||
#[case(10, 100)]
|
||||
fn test_square(#[case] input: i32, #[case] expected: i32) {
|
||||
assert_eq!(square(input), expected);
|
||||
}
|
||||
```
|
||||
|
||||
### Mock Objects
|
||||
|
||||
Using `mockall`:
|
||||
|
||||
```rust
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use mockall::predicate::*;
|
||||
use mockall::mock;
|
||||
|
||||
mock! {
|
||||
Database {}
|
||||
|
||||
impl Database {
|
||||
fn query(&self, sql: &str) -> Result<Vec<Row>, DbError>;
|
||||
fn execute(&self, sql: &str) -> Result<u64, DbError>;
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handles_empty_database() {
|
||||
let mut mock_db = MockDatabase::new();
|
||||
mock_db
|
||||
.expect_query()
|
||||
.with(eq("SELECT * FROM users"))
|
||||
.returning(|_| Ok(vec![]));
|
||||
|
||||
let users = find_all_users(&mock_db);
|
||||
assert_eq!(users.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handles_database_error() {
|
||||
let mut mock_db = MockDatabase::new();
|
||||
mock_db
|
||||
.expect_query()
|
||||
.returning(|_| Err(DbError::ConnectionLost));
|
||||
|
||||
let result = find_all_users(&mock_db);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Error Testing
|
||||
|
||||
Custom error types:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn returns_custom_error() {
|
||||
let result = process_value(-1);
|
||||
|
||||
assert!(result.is_err());
|
||||
let err = result.unwrap_err();
|
||||
assert_eq!(err.to_string(), "Value cannot be negative");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_contains_context() {
|
||||
let result = parse_config("invalid");
|
||||
|
||||
match result {
|
||||
Err(ConfigError::ParseError { line, message }) => {
|
||||
assert_eq!(line, 1);
|
||||
assert!(message.contains("invalid"));
|
||||
}
|
||||
_ => panic!("Expected ParseError"),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Integration Test Structure
|
||||
|
||||
Separate integration tests in `tests/` directory:
|
||||
|
||||
```rust
|
||||
// tests/integration/user_api.rs
|
||||
use my_crate::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_user_registration_flow() {
|
||||
// Setup test database
|
||||
let db = setup_test_db().await;
|
||||
|
||||
// Create user
|
||||
let user = register_user(&db, "test@example.com", "password").await.unwrap();
|
||||
|
||||
// Verify user created
|
||||
let found = find_user(&db, user.id).await.unwrap();
|
||||
assert_eq!(found.email, "test@example.com");
|
||||
|
||||
// Cleanup
|
||||
cleanup_test_db(db).await;
|
||||
}
|
||||
```
|
||||
|
||||
## Common Test Smells and Solutions
|
||||
|
||||
### Test Smell: Setup Longer Than Test
|
||||
|
||||
❌ Bad:
|
||||
|
||||
```typescript
|
||||
test('processes order', () => {
|
||||
const user = { id: '1', email: 'test@example.com', role: 'user', /* 10 more fields */ }
|
||||
const product = { id: 'p1', name: 'Widget', price: 100, /* 8 more fields */ }
|
||||
const cart = { items: [{ product, quantity: 2 }], /* 5 more fields */ }
|
||||
const payment = { method: 'card', /* 6 more fields */ }
|
||||
|
||||
const result = processOrder(user, cart, payment)
|
||||
expect(result.total).toBe(200)
|
||||
})
|
||||
```
|
||||
|
||||
✓ Good:
|
||||
|
||||
```typescript
|
||||
test('processes order', () => {
|
||||
const order = new OrderBuilder().withQuantity(2).withPrice(100).build()
|
||||
|
||||
const result = processOrder(order)
|
||||
expect(result.total).toBe(200)
|
||||
})
|
||||
```
|
||||
|
||||
### Test Smell: Multiple Unrelated Assertions
|
||||
|
||||
❌ Bad:
|
||||
|
||||
```typescript
|
||||
test('user management', () => {
|
||||
expect(createUser('test@example.com')).toBeDefined()
|
||||
expect(findUser('1')).toEqual({ id: '1' })
|
||||
expect(deleteUser('1')).toBe(true)
|
||||
})
|
||||
```
|
||||
|
||||
✓ Good:
|
||||
|
||||
```typescript
|
||||
test('creates user with valid email', () => {
|
||||
expect(createUser('test@example.com')).toBeDefined()
|
||||
})
|
||||
|
||||
test('finds user by id', () => {
|
||||
expect(findUser('1')).toEqual({ id: '1' })
|
||||
})
|
||||
|
||||
test('deletes user successfully', () => {
|
||||
expect(deleteUser('1')).toBe(true)
|
||||
})
|
||||
```
|
||||
|
||||
### Test Smell: Testing Implementation Details
|
||||
|
||||
❌ Bad:
|
||||
|
||||
```typescript
|
||||
test('caches results internally', () => {
|
||||
const service = new UserService()
|
||||
service.fetchUser('1')
|
||||
|
||||
expect(service._cache.has('1')).toBe(true) // Testing private implementation
|
||||
})
|
||||
```
|
||||
|
||||
✓ Good:
|
||||
|
||||
```typescript
|
||||
test('returns cached user on second fetch', async () => {
|
||||
const service = new UserService()
|
||||
const spy = mock.fn()
|
||||
|
||||
await service.fetchUser('1', spy)
|
||||
await service.fetchUser('1', spy)
|
||||
|
||||
expect(spy).toHaveBeenCalledTimes(1) // Testing observable behavior
|
||||
})
|
||||
```
|
||||
Reference in New Issue
Block a user