15 KiB
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:
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
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:
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
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:
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
// 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:
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:
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:
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:
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:
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:
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:
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)
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:
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:
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
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:
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:
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:
- Basic authentication with email/password
- Email validation
- 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