shep

TDD + Clean Architecture Guide

Complete guide to implementing features using Test-Driven Development with Clean Architecture.

Philosophy

β€œWrite the test first, then write the code to make it pass.”

TDD ensures:

The TDD Cycle: Red-Green-Refactor

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                                                              β”‚
β”‚     β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”                                             β”‚
β”‚     β”‚   RED   β”‚  Write a failing test                       β”‚
β”‚     β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”˜                                             β”‚
β”‚          β”‚                                                   β”‚
β”‚          β–Ό                                                   β”‚
β”‚     β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”                                             β”‚
β”‚     β”‚  GREEN  β”‚  Write minimal code to pass                 β”‚
β”‚     β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”˜                                             β”‚
β”‚          β”‚                                                   β”‚
β”‚          β–Ό                                                   β”‚
β”‚     β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”                                            β”‚
β”‚     β”‚ REFACTOR β”‚  Improve code while keeping tests green    β”‚
β”‚     β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜                                            β”‚
β”‚          β”‚                                                   β”‚
β”‚          └──────────────► Repeat                            β”‚
β”‚                                                              β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Quick Start: TDD a New Feature

Example: Adding β€œArchive Feature” Functionality

Let’s implement the ability to archive a feature, walking through each layer with TDD.


Step 1: Domain Layer (Start Here)

1.1 RED - Write Failing Domain Test

# Start test watcher
pnpm test:watch tests/unit/domain/entities/feature.test.ts
// tests/unit/domain/entities/feature.test.ts
import { describe, it, expect } from 'vitest';
import { Feature } from '@/domain/entities/feature';
import { SdlcLifecycle } from '@/domain/value-objects/sdlc-lifecycle';

describe('Feature', () => {
  describe('archive', () => {
    it('should archive a feature in Maintenance lifecycle', () => {
      // Arrange
      const feature = createFeatureInLifecycle(SdlcLifecycle.Maintenance);

      // Act
      feature.archive();

      // Assert
      expect(feature.isArchived).toBe(true);
      expect(feature.archivedAt).toBeInstanceOf(Date);
    });

    it('should not allow archiving a feature not in Maintenance', () => {
      // Arrange
      const feature = createFeatureInLifecycle(SdlcLifecycle.Implementation);

      // Act & Assert
      expect(() => feature.archive()).toThrow('Cannot archive feature not in Maintenance');
    });

    it('should not allow archiving an already archived feature', () => {
      // Arrange
      const feature = createFeatureInLifecycle(SdlcLifecycle.Maintenance);
      feature.archive();

      // Act & Assert
      expect(() => feature.archive()).toThrow('Feature is already archived');
    });
  });
});

// Test helper
function createFeatureInLifecycle(lifecycle: SdlcLifecycle): Feature {
  const feature = Feature.create({
    name: 'Test Feature',
    description: 'Description',
    repoPath: '/test/repo',
  });
  // Transition through lifecycle to reach target
  // (simplified for example)
  feature['_lifecycle'] = lifecycle;
  return feature;
}

Run tests - they should FAIL (RED)

# Expected output:
# βœ— should archive a feature in Maintenance lifecycle
#   Property 'archive' does not exist on type 'Feature'

1.2 GREEN - Write Minimal Code to Pass

// src/domain/entities/feature.ts
export class Feature {
  // ... existing code ...

  private _isArchived: boolean = false;
  private _archivedAt: Date | null = null;

  get isArchived(): boolean {
    return this._isArchived;
  }

  get archivedAt(): Date | null {
    return this._archivedAt;
  }

  archive(): void {
    if (this._lifecycle !== SdlcLifecycle.Maintenance) {
      throw new CannotArchiveError('Cannot archive feature not in Maintenance');
    }
    if (this._isArchived) {
      throw new AlreadyArchivedError('Feature is already archived');
    }
    this._isArchived = true;
    this._archivedAt = new Date();
  }
}

Run tests - they should PASS (GREEN)

1.3 REFACTOR - Improve While Green

// src/domain/entities/feature.ts
export class Feature {
  // Refactor: Extract to value object if needed
  // Refactor: Add domain event emission

  archive(): void {
    this.ensureCanArchive();
    this._isArchived = true;
    this._archivedAt = new Date();
    // Could emit: this.addDomainEvent(new FeatureArchivedEvent(this.id));
  }

  private ensureCanArchive(): void {
    if (this._lifecycle !== SdlcLifecycle.Maintenance) {
      throw new CannotArchiveError('Cannot archive feature not in Maintenance');
    }
    if (this._isArchived) {
      throw new AlreadyArchivedError('Feature is already archived');
    }
  }
}

Run tests - they should still PASS


Step 2: Application Layer (Use Case)

2.1 RED - Write Failing Use Case Test

// tests/unit/application/use-cases/archive-feature.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ArchiveFeatureUseCase } from '@/application/use-cases/archive-feature';
import { createMockFeatureRepository } from '@tests/helpers/mocks';
import { Feature } from '@/domain/entities/feature';

describe('ArchiveFeatureUseCase', () => {
  let useCase: ArchiveFeatureUseCase;
  let featureRepository: ReturnType<typeof createMockFeatureRepository>;

  beforeEach(() => {
    featureRepository = createMockFeatureRepository();
    useCase = new ArchiveFeatureUseCase(featureRepository);
  });

  it('should archive an existing feature', async () => {
    // Arrange
    const feature = createMaintenanceFeature();
    featureRepository.findById.mockResolvedValue(feature);

    // Act
    const result = await useCase.execute({ featureId: feature.id });

    // Assert
    expect(result.success).toBe(true);
    expect(feature.isArchived).toBe(true);
    expect(featureRepository.save).toHaveBeenCalledWith(feature);
  });

  it('should fail if feature not found', async () => {
    // Arrange
    featureRepository.findById.mockResolvedValue(null);

    // Act & Assert
    await expect(useCase.execute({ featureId: 'nonexistent' })).rejects.toThrow(
      'Feature not found'
    );
  });

  it('should fail if feature cannot be archived', async () => {
    // Arrange
    const feature = createImplementationFeature();
    featureRepository.findById.mockResolvedValue(feature);

    // Act & Assert
    await expect(useCase.execute({ featureId: feature.id })).rejects.toThrow(
      'Cannot archive feature not in Maintenance'
    );
  });
});

2.2 GREEN - Implement Use Case

// src/application/use-cases/archive-feature.ts
import { IFeatureRepository } from '@/application/ports/output/feature-repository.port';
import { FeatureNotFoundError } from '@/application/errors';

export interface ArchiveFeatureInput {
  featureId: string;
}

export interface ArchiveFeatureOutput {
  success: boolean;
  archivedAt: Date;
}

export class ArchiveFeatureUseCase {
  constructor(private readonly featureRepository: IFeatureRepository) {}

  async execute(input: ArchiveFeatureInput): Promise<ArchiveFeatureOutput> {
    const feature = await this.featureRepository.findById(input.featureId);

    if (!feature) {
      throw new FeatureNotFoundError(input.featureId);
    }

    feature.archive(); // Domain method handles validation

    await this.featureRepository.save(feature);

    return {
      success: true,
      archivedAt: feature.archivedAt!,
    };
  }
}

2.3 REFACTOR


Step 3: Infrastructure Layer (Repository)

3.1 RED - Write Failing Integration Test

// tests/integration/repositories/feature-repository.test.ts
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { SqliteFeatureRepository } from '@/infrastructure/repositories/sqlite/feature.repository';
import { createTestDatabase, runMigrations } from '@tests/helpers/db';
import { Feature } from '@/domain/entities/feature';
import { SdlcLifecycle } from '@/domain/value-objects/sdlc-lifecycle';

describe('SqliteFeatureRepository', () => {
  let db: Database;
  let repository: SqliteFeatureRepository;

  beforeEach(async () => {
    db = createTestDatabase();
    await runMigrations(db);
    repository = new SqliteFeatureRepository(db);
  });

  afterEach(() => db.close());

  describe('archive persistence', () => {
    it('should persist archived state', async () => {
      // Arrange
      const feature = createMaintenanceFeature();
      await repository.save(feature);

      // Act
      feature.archive();
      await repository.save(feature);

      // Assert
      const retrieved = await repository.findById(feature.id);
      expect(retrieved?.isArchived).toBe(true);
      expect(retrieved?.archivedAt).toBeInstanceOf(Date);
    });

    it('should find only non-archived features by default', async () => {
      // Arrange
      const active = createMaintenanceFeature();
      const archived = createMaintenanceFeature();
      archived.archive();

      await repository.save(active);
      await repository.save(archived);

      // Act
      const features = await repository.findByRepoPath(active.repoPath);

      // Assert
      expect(features).toHaveLength(1);
      expect(features[0].id).toBe(active.id);
    });

    it('should find archived features when requested', async () => {
      // Arrange
      const archived = createMaintenanceFeature();
      archived.archive();
      await repository.save(archived);

      // Act
      const features = await repository.findArchived(archived.repoPath);

      // Assert
      expect(features).toHaveLength(1);
      expect(features[0].isArchived).toBe(true);
    });
  });
});

3.2 GREEN - Update Repository Implementation

// src/infrastructure/repositories/sqlite/feature.repository.ts
export class SqliteFeatureRepository implements IFeatureRepository {
  // Add new columns to schema
  async save(feature: Feature): Promise<void> {
    await this.db.run(
      `
      INSERT INTO features (id, name, description, lifecycle, repo_path, is_archived, archived_at)
      VALUES (?, ?, ?, ?, ?, ?, ?)
      ON CONFLICT(id) DO UPDATE SET
        name = excluded.name,
        description = excluded.description,
        lifecycle = excluded.lifecycle,
        is_archived = excluded.is_archived,
        archived_at = excluded.archived_at,
        updated_at = CURRENT_TIMESTAMP
    `,
      [
        feature.id,
        feature.name,
        feature.description,
        feature.lifecycle,
        feature.repoPath,
        feature.isArchived ? 1 : 0,
        feature.archivedAt?.toISOString() ?? null,
      ]
    );
  }

  async findByRepoPath(repoPath: string): Promise<Feature[]> {
    const rows = await this.db.all<FeatureRow[]>(
      'SELECT * FROM features WHERE repo_path = ? AND is_archived = 0',
      [repoPath]
    );
    return rows.map((row) => FeatureMapper.toDomain(row));
  }

  async findArchived(repoPath: string): Promise<Feature[]> {
    const rows = await this.db.all<FeatureRow[]>(
      'SELECT * FROM features WHERE repo_path = ? AND is_archived = 1',
      [repoPath]
    );
    return rows.map((row) => FeatureMapper.toDomain(row));
  }
}

Step 4: Presentation Layer

4.1 CLI Command (TDD)

// tests/e2e/cli/archive-feature.test.ts
import { describe, it, expect } from 'vitest';
import { execSync } from 'child_process';

describe('shep feature archive', () => {
  it('should archive a feature', () => {
    // Setup: Create a feature first
    const createResult = execSync('shep feature create "Test" --lifecycle maintenance');
    const featureId = extractFeatureId(createResult);

    // Act
    const result = execSync(`shep feature archive ${featureId}`);

    // Assert
    expect(result.toString()).toContain('Feature archived successfully');
  });
});

4.2 Web UI Component (TDD with Playwright)

// tests/e2e/web/archive-feature.spec.ts
import { test, expect } from '@playwright/test';

test.describe('Archive Feature', () => {
  test('should archive feature from UI', async ({ page }) => {
    // Arrange
    await page.goto('/features/test-feature');

    // Act
    await page.click('[data-testid="archive-button"]');
    await page.click('[data-testid="confirm-archive"]');

    // Assert
    await expect(page.locator('[data-testid="archived-badge"]')).toBeVisible();
  });
});

TDD Command Reference

# Start TDD session (watch mode)
pnpm test:watch

# Run specific test file
pnpm test:single tests/unit/domain/entities/feature.test.ts

# Run tests matching pattern
pnpm test -- --grep "archive"

# Run only unit tests
pnpm test:unit

# Run only integration tests
pnpm test:int

# Run e2e tests (Playwright)
pnpm test:e2e

# Run tests for changed files only
pnpm test -- --changed

TDD Best Practices

1. Test Naming Convention

// Pattern: should_[expected behavior]_when_[condition]
it('should archive feature when in Maintenance lifecycle', () => {});
it('should throw error when archiving Implementation feature', () => {});

2. Arrange-Act-Assert (AAA)

it('should do something', () => {
  // Arrange - Set up test data
  const feature = createFeature();

  // Act - Execute the behavior
  feature.archive();

  // Assert - Verify the result
  expect(feature.isArchived).toBe(true);
});

3. One Assertion Per Test (When Possible)

// Good: Focused tests
it('should set isArchived to true', () => {
  feature.archive();
  expect(feature.isArchived).toBe(true);
});

it('should set archivedAt timestamp', () => {
  feature.archive();
  expect(feature.archivedAt).toBeInstanceOf(Date);
});

4. Test Factories Over Fixtures

// tests/helpers/factories.ts
export function createFeature(overrides: Partial<FeatureProps> = {}): Feature {
  return Feature.create({
    name: 'Default Name',
    description: 'Default Description',
    repoPath: '/default/path',
    ...overrides,
  });
}

export function createMaintenanceFeature(): Feature {
  const feature = createFeature();
  feature['_lifecycle'] = SdlcLifecycle.Maintenance;
  return feature;
}

Layer Testing Summary

Layer Test Type Speed Dependencies
Domain Unit Fast None
Application Unit Fast Mocked ports
Infrastructure Integration Medium Real DB (in-memory)
Presentation E2E Slow Full stack

Testing TypeSpec-Generated Code

Philosophy

TypeSpec models are the single source of truth. Generated TypeScript types in src/domain/generated/output.ts should never be edited manually.

Testing Approach

DO:

DON’T:

Example: Testing Repository with Generated Types

// tests/integration/repositories/settings.repository.test.ts
import type { Settings } from '@/domain/generated/output';
import { SQLiteSettingsRepository } from '@/infrastructure/repositories/sqlite-settings.repository';

describe('SQLiteSettingsRepository', () => {
  it('should persist Settings with all TypeSpec-defined fields', async () => {
    // Arrange - Create Settings object using generated type
    const settings: Settings = {
      id: 'singleton',
      createdAt: new Date(),
      updatedAt: new Date(),
      models: {
        analyze: 'claude-opus-4',
        requirements: 'claude-sonnet-4',
        plan: 'claude-sonnet-4',
        implement: 'claude-sonnet-4',
      },
      user: {
        name: 'Test User',
        email: 'test@example.com',
      },
      environment: {
        defaultEditor: 'vim',
        shellPreference: 'bash',
      },
      system: {
        autoUpdate: true,
        logLevel: 'info',
      },
    };

    // Act
    await repository.initialize(settings);
    const loaded = await repository.load();

    // Assert - Verify all fields persisted correctly
    expect(loaded).toMatchObject(settings);
  });
});

Regenerating Types After TypeSpec Changes

# 1. Modify TypeSpec model
vim tsp/domain/entities/settings.tsp

# 2. Regenerate TypeScript types
pnpm tsp:compile

# 3. Run tests (should fail if breaking change)
pnpm test

# 4. Fix implementation to match new types
# TypeScript will show compile errors

# 5. Tests pass β†’ commit both .tsp and generated files
git add tsp/ src/domain/generated/
git commit -m "feat(domain): add new field to Settings model"

Testing Repositories with In-Memory SQLite

Why In-Memory Databases?

Integration tests for repositories should use in-memory SQLite databases:

Setup Pattern

// tests/helpers/database.helper.ts
import Database from 'better-sqlite3';

export function createInMemoryDatabase(): Database.Database {
  return new Database(':memory:', {
    // fileMustExist: false (implicit for :memory:)
  });
}

export function tableExists(db: Database.Database, tableName: string): boolean {
  const result = db
    .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?")
    .get(tableName);
  return result !== undefined;
}

Test Structure

// tests/integration/infrastructure/repositories/sqlite-settings.repository.test.ts
import 'reflect-metadata'; // IMPORTANT: Required for tsyringe DI
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import type Database from 'better-sqlite3';
import { createInMemoryDatabase, tableExists } from '@tests/helpers/database.helper';
import { runSQLiteMigrations } from '@/infrastructure/persistence/sqlite/migrations';
import { SQLiteSettingsRepository } from '@/infrastructure/repositories/sqlite-settings.repository';
import type { Settings } from '@/domain/generated/output';

describe('SQLiteSettingsRepository', () => {
  let db: Database.Database;
  let repository: SQLiteSettingsRepository;

  beforeEach(async () => {
    // Create fresh in-memory database
    db = createInMemoryDatabase();

    // Run migrations to create schema
    await runSQLiteMigrations(db);

    // Create repository instance
    repository = new SQLiteSettingsRepository(db);
  });

  afterEach(() => {
    // Close database connection
    db.close();
  });

  describe('migrations', () => {
    it('should create settings table', () => {
      expect(tableExists(db, 'settings')).toBe(true);
    });

    it('should create singleton constraint', async () => {
      // Arrange
      const settings1 = createTestSettings();
      const settings2 = createTestSettings();
      settings2.id = 'duplicate'; // Try to create second record

      // Act
      await repository.initialize(settings1);

      // Assert - Singleton constraint prevents duplicate
      await expect(repository.initialize(settings2)).rejects.toThrow();
    });
  });

  describe('CRUD operations', () => {
    it('should initialize settings', async () => {
      // Arrange
      const settings = createTestSettings();

      // Act
      await repository.initialize(settings);

      // Assert
      const row = db.prepare('SELECT * FROM settings WHERE id = ?').get('singleton');
      expect(row).toBeDefined();
    });

    it('should load settings', async () => {
      // Arrange
      const settings = createTestSettings();
      await repository.initialize(settings);

      // Act
      const loaded = await repository.load();

      // Assert
      expect(loaded).toMatchObject(settings);
    });

    it('should update settings', async () => {
      // Arrange
      const settings = createTestSettings();
      await repository.initialize(settings);

      // Act
      settings.system.logLevel = 'debug';
      await repository.update(settings);

      // Assert
      const loaded = await repository.load();
      expect(loaded?.system.logLevel).toBe('debug');
    });

    it('should return null when no settings exist', async () => {
      // Act
      const loaded = await repository.load();

      // Assert
      expect(loaded).toBeNull();
    });
  });

  describe('SQL injection prevention', () => {
    it('should safely handle quotes in email', async () => {
      // Arrange
      const settings = createTestSettings();
      settings.user.email = "test'@example.com"; // SQL injection attempt

      // Act
      await repository.initialize(settings);

      // Assert
      const loaded = await repository.load();
      expect(loaded?.user.email).toBe("test'@example.com");
    });
  });

  describe('database mapping', () => {
    it('should use snake_case for database columns', async () => {
      // Arrange
      const settings = createTestSettings();

      // Act
      await repository.initialize(settings);

      // Assert - Verify column naming
      const row = db.prepare('SELECT model_analyze, sys_log_level FROM settings').get() as {
        model_analyze: string;
        sys_log_level: string;
      };
      expect(row.model_analyze).toBe(settings.models.analyze);
      expect(row.sys_log_level).toBe(settings.system.logLevel);
    });

    it('should convert boolean to integer (SQLite limitation)', async () => {
      // Arrange
      const settings = createTestSettings();
      settings.system.autoUpdate = true;

      // Act
      await repository.initialize(settings);

      // Assert
      const row = db.prepare('SELECT sys_auto_update FROM settings').get() as {
        sys_auto_update: number;
      };
      expect(row.sys_auto_update).toBe(1); // boolean true β†’ 1
    });

    it('should correctly convert integer back to boolean when loading', async () => {
      // Arrange - Manually insert with integer value
      db.prepare(
        `INSERT INTO settings (id, sys_auto_update, ...) VALUES ('singleton', 0, ...)`
      ).run();

      // Act
      const loaded = await repository.load();

      // Assert
      expect(loaded?.system.autoUpdate).toBe(false); // 0 β†’ boolean false
    });
  });
});

// Test helper
function createTestSettings(): Settings {
  return {
    id: 'singleton',
    createdAt: new Date(),
    updatedAt: new Date(),
    models: {
      analyze: 'claude-opus-4',
      requirements: 'claude-sonnet-4',
      plan: 'claude-sonnet-4',
      implement: 'claude-sonnet-4',
    },
    user: {
      name: 'Test User',
      email: 'test@example.com',
    },
    environment: {
      defaultEditor: 'vim',
      shellPreference: 'bash',
    },
    system: {
      autoUpdate: true,
      logLevel: 'info',
    },
  };
}

Best Practices

  1. Always import reflect-metadata first (required for tsyringe DI):

    import 'reflect-metadata'; // MUST be first import
    import { describe, it, expect } from 'vitest';
    
  2. Test database constraints (UNIQUE, NOT NULL, CHECK, FOREIGN KEY):

    it('should enforce singleton constraint', async () => {
      await repository.initialize(settings1);
      await expect(repository.initialize(settings2)).rejects.toThrow();
    });
    
  3. Test SQL injection prevention (verify prepared statements work):

    it('should safely handle special characters', async () => {
      const malicious = createSettings();
      malicious.user.email = "'; DROP TABLE settings; --";
      await repository.initialize(malicious);
      expect(tableExists(db, 'settings')).toBe(true); // Table still exists
    });
    
  4. Test database mapping (camelCase ↔ snake_case):

    it('should use snake_case columns', async () => {
      await repository.save(entity);
      const row = db.prepare('SELECT created_at FROM entities').get();
      expect(row.created_at).toBeDefined(); // Not createdAt
    });
    
  5. Test migrations (verify schema creation):

    it('should create all required tables', () => {
      expect(tableExists(db, 'settings')).toBe(true);
      expect(tableExists(db, 'features')).toBe(true);
    });
    

Continuous TDD Workflow

1. Pick a small feature slice
2. Write failing domain test β†’ Make it pass β†’ Refactor
3. Write failing use case test β†’ Make it pass β†’ Refactor
4. Write failing repository test β†’ Make it pass β†’ Refactor
5. Write failing UI test β†’ Make it pass β†’ Refactor
6. Commit and repeat

Maintaining This Document

Update when:

Related docs: