はじめに

TDD(テスト駆動開発)は、Red-Green-Refactorサイクルに従うことで、高品質なコードと自動化されたテストスイートを同時に構築できる強力な開発手法です。しかし、TDDを実践する中で多くの開発者が共通の落とし穴に陥り、本来得られるべきメリットを失ってしまうケースが少なくありません。

「テストを書いているのに、リファクタリングのたびにテストが壊れる」「テストの実行に時間がかかりすぎて開発速度が落ちた」「テストコードの保守に本番コード以上の時間を費やしている」

これらの問題は、TDDのアンチパターンに陥っている兆候です。本記事では、TDDで陥りがちな12のアンチパターンを体系的に解説し、それぞれの問題点と具体的な回避策を提示します。

TDDアンチパターンの分類

TDDアンチパターンは、大きく4つのカテゴリに分類できます。

flowchart TB
    A[TDDアンチパターン] --> B[構造的アンチパターン]
    A --> C[設計的アンチパターン]
    A --> D[プロセス的アンチパターン]
    A --> E[保守性アンチパターン]
    
    B --> B1[実装密結合テスト]
    B --> B2[過度なモック]
    B --> B3[神オブジェクトテスト]
    
    C --> C1[テストしづらい設計]
    C --> C2[プライベートメソッドテスト]
    C --> C3[継承階層テスト]
    
    D --> D1[テストファースト放棄]
    D --> D2[リファクタリング省略]
    D --> D3[カバレッジ信仰]
    
    E --> E1[脆いテスト]
    E --> E2[遅いテスト]
    E --> E3[重複テスト]
    
    style A fill:#e1f5fe,stroke:#01579b,color:#000000
    style B fill:#fff3e0,stroke:#e65100,color:#000000
    style C fill:#f3e5f5,stroke:#7b1fa2,color:#000000
    style D fill:#e8f5e9,stroke:#2e7d32,color:#000000
    style E fill:#ffebee,stroke:#c62828,color:#000000
カテゴリ 影響範囲 主な症状
構造的アンチパターン テストコードの品質 テストが実装詳細に依存、過度に複雑
設計的アンチパターン プロダクトコードの品質 テストしづらい設計、責務の混在
プロセス的アンチパターン 開発フロー TDDサイクルの崩壊、形骸化
保守性アンチパターン 長期的な保守コスト 脆い・遅い・重複したテスト

構造的アンチパターン

アンチパターン1: 実装密結合テスト(The Inspector)

実装密結合テストは、テストが「何を」ではなく「どのように」を検証してしまうアンチパターンです。内部実装の詳細に依存するため、リファクタリングのたびにテストが壊れます。

問題のあるコード例

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
// userService.js
export class UserService {
  constructor(repository, cache) {
    this.repository = repository
    this.cache = cache
  }

  async getUser(id) {
    // キャッシュをチェック
    const cached = this.cache.get(`user:${id}`)
    if (cached) {
      return cached
    }
    
    // データベースから取得
    const user = await this.repository.findById(id)
    if (user) {
      this.cache.set(`user:${id}`, user, 3600)
    }
    return user
  }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// 悪い例: 実装詳細に密結合したテスト
describe('UserService', () => {
  it('should check cache first, then database, then set cache', async () => {
    const mockCache = {
      get: jest.fn().mockReturnValue(null),
      set: jest.fn(),
    }
    const mockRepository = {
      findById: jest.fn().mockResolvedValue({ id: 1, name: 'Alice' }),
    }
    
    const service = new UserService(mockRepository, mockCache)
    await service.getUser(1)
    
    // 実装詳細を検証している
    expect(mockCache.get).toHaveBeenCalledWith('user:1')
    expect(mockCache.get).toHaveBeenCalledBefore(mockRepository.findById)
    expect(mockRepository.findById).toHaveBeenCalledWith(1)
    expect(mockCache.set).toHaveBeenCalledWith('user:1', { id: 1, name: 'Alice' }, 3600)
  })
})

このテストは、キャッシュキーの形式(user:1)、TTL値(3600)、呼び出し順序など、すべての実装詳細に依存しています。キャッシュ戦略を変更するだけでテストが壊れてしまいます。

改善されたコード例

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// 良い例: 振る舞いを検証するテスト
describe('UserService', () => {
  it('should return user when user exists', async () => {
    const expectedUser = { id: 1, name: 'Alice' }
    const mockCache = { get: jest.fn().mockReturnValue(null), set: jest.fn() }
    const mockRepository = { findById: jest.fn().mockResolvedValue(expectedUser) }
    
    const service = new UserService(mockRepository, mockCache)
    const result = await service.getUser(1)
    
    // 振る舞いを検証: 正しいユーザーが返される
    expect(result).toEqual(expectedUser)
  })

  it('should return cached user without hitting database', async () => {
    const cachedUser = { id: 1, name: 'Alice' }
    const mockCache = { get: jest.fn().mockReturnValue(cachedUser), set: jest.fn() }
    const mockRepository = { findById: jest.fn() }
    
    const service = new UserService(mockRepository, mockCache)
    const result = await service.getUser(1)
    
    // 振る舞いを検証: キャッシュされたユーザーが返され、DBは呼ばれない
    expect(result).toEqual(cachedUser)
    expect(mockRepository.findById).not.toHaveBeenCalled()
  })

  it('should return null when user does not exist', async () => {
    const mockCache = { get: jest.fn().mockReturnValue(null), set: jest.fn() }
    const mockRepository = { findById: jest.fn().mockResolvedValue(null) }
    
    const service = new UserService(mockRepository, mockCache)
    const result = await service.getUser(999)
    
    expect(result).toBeNull()
  })
})

改善されたテストは「ユーザーを取得する」という振る舞いに焦点を当て、内部でどのようにキャッシュを使うかには関与しません。

回避策のポイント

問題 回避策
メソッドの呼び出し順序を検証 最終的な結果のみを検証する
内部変数の状態を検証 公開インターフェースの出力を検証する
詳細な引数を検証 重要な引数のみを検証する

アンチパターン2: 過度なモック(Mockery)

すべての依存関係をモックに置き換えてしまうと、テストは本番環境での動作を保証できなくなります。モックの設定が現実と乖離し、テストは通るが本番で壊れるという状況を招きます。

問題のあるコード例

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// 悪い例: すべてをモックで置き換えている
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
    @Mock private OrderRepository orderRepository;
    @Mock private PaymentGateway paymentGateway;
    @Mock private InventoryService inventoryService;
    @Mock private NotificationService notificationService;
    @Mock private PricingCalculator pricingCalculator;
    @Mock private TaxCalculator taxCalculator;
    @Mock private DiscountEngine discountEngine;
    @Mock private AuditLogger auditLogger;
    
    @InjectMocks
    private OrderService orderService;
    
    @Test
    void shouldProcessOrder() {
        // 8つのモックすべてに詳細な振る舞いを設定
        when(pricingCalculator.calculate(any())).thenReturn(new BigDecimal("100.00"));
        when(taxCalculator.calculate(any())).thenReturn(new BigDecimal("10.00"));
        when(discountEngine.apply(any())).thenReturn(new BigDecimal("5.00"));
        when(inventoryService.checkAvailability(any())).thenReturn(true);
        when(paymentGateway.process(any())).thenReturn(PaymentResult.SUCCESS);
        when(orderRepository.save(any())).thenReturn(new Order());
        doNothing().when(notificationService).sendConfirmation(any());
        doNothing().when(auditLogger).log(any());
        
        Order order = new Order();
        OrderResult result = orderService.processOrder(order);
        
        assertThat(result.isSuccess()).isTrue();
        
        // すべてのモックが正しく呼ばれたことを検証
        verify(pricingCalculator).calculate(any());
        verify(taxCalculator).calculate(any());
        // ... 以下8つのverify
    }
}

このテストには以下の問題があります。

  • テストの準備コードが膨大で理解しづらい
  • モックの設定が現実を反映しているか保証がない
  • 1つのモック設定ミスでテストが失敗する
  • クラスの責務が多すぎることを隠蔽している

改善されたコード例

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// 良い例: 適切なレベルでモックを使用
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
    // 外部システムとの境界のみモック
    @Mock private PaymentGateway paymentGateway;
    @Mock private NotificationService notificationService;
    
    // 純粋なドメインロジックは実オブジェクトを使用
    private PricingCalculator pricingCalculator = new PricingCalculator();
    private TaxCalculator taxCalculator = new TaxCalculator();
    private DiscountEngine discountEngine = new DiscountEngine();
    
    // インメモリ実装を使用
    private OrderRepository orderRepository = new InMemoryOrderRepository();
    private InventoryService inventoryService = new InMemoryInventoryService();
    
    @Test
    void shouldProcessOrderSuccessfully() {
        // 在庫を準備
        inventoryService.addStock("PROD-001", 10);
        
        // 外部システムのモックのみ設定
        when(paymentGateway.process(any())).thenReturn(PaymentResult.SUCCESS);
        
        OrderService orderService = new OrderService(
            orderRepository, paymentGateway, inventoryService,
            notificationService, pricingCalculator, taxCalculator, discountEngine
        );
        
        Order order = new Order("PROD-001", 2);
        OrderResult result = orderService.processOrder(order);
        
        assertThat(result.isSuccess()).isTrue();
        assertThat(result.getTotalAmount()).isEqualTo(new BigDecimal("231.00")); // 実際の計算結果
        assertThat(orderRepository.findById(result.getOrderId())).isPresent();
    }
}

回避策のポイント

flowchart LR
    subgraph mock["モックを使う"]
        A1[外部API]
        A2[データベース]
        A3[ファイルシステム]
        A4[メール送信]
    end
    
    subgraph real["実オブジェクトを使う"]
        B1[計算ロジック]
        B2[ドメインオブジェクト]
        B3[バリデーション]
        B4[状態遷移]
    end
    
    subgraph fake["フェイクを使う"]
        C1[リポジトリ]
        C2[キャッシュ]
        C3[イベントバス]
    end
    
    style mock fill:#ffebee,stroke:#c62828,color:#000000
    style real fill:#e8f5e9,stroke:#2e7d32,color:#000000
    style fake fill:#fff3e0,stroke:#e65100,color:#000000

アンチパターン3: 神オブジェクトテスト(The Giant)

1つのテストクラスやテストメソッドが肥大化し、複数の関心事を同時にテストしてしまうパターンです。

問題のあるコード例

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// 悪い例: 1つのテストで複数のことを検証
describe('UserRegistration', () => {
  it('should handle complete registration flow', async () => {
    const userData = {
      email: 'test@example.com',
      password: 'SecurePass123!',
      name: 'Test User',
    }
    
    // 1. バリデーション
    expect(validateEmail(userData.email)).toBe(true)
    expect(validatePassword(userData.password)).toBe(true)
    expect(validateName(userData.name)).toBe(true)
    
    // 2. ユーザー作成
    const user = await createUser(userData)
    expect(user.id).toBeDefined()
    expect(user.email).toBe(userData.email)
    expect(user.passwordHash).not.toBe(userData.password)
    
    // 3. メール送信
    expect(mockEmailService.send).toHaveBeenCalledWith(
      expect.objectContaining({ to: userData.email })
    )
    
    // 4. 監査ログ
    expect(mockAuditLog.log).toHaveBeenCalledWith('USER_CREATED', user.id)
    
    // 5. ウェルカムキャンペーン
    expect(mockCampaignService.enroll).toHaveBeenCalledWith(user.id, 'welcome')
    
    // このテストが失敗したとき、何が原因か特定しづらい
  })
})

改善されたコード例

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
// 良い例: 関心事ごとにテストを分離
describe('UserRegistration', () => {
  describe('validation', () => {
    it('should accept valid email format', () => {
      expect(validateEmail('user@example.com')).toBe(true)
    })

    it('should reject invalid email format', () => {
      expect(validateEmail('invalid-email')).toBe(false)
    })

    it('should require minimum password strength', () => {
      expect(validatePassword('weak')).toBe(false)
      expect(validatePassword('SecurePass123!')).toBe(true)
    })
  })

  describe('user creation', () => {
    it('should create user with hashed password', async () => {
      const userData = createValidUserData()
      const user = await createUser(userData)
      
      expect(user.passwordHash).not.toBe(userData.password)
    })

    it('should generate unique user id', async () => {
      const user = await createUser(createValidUserData())
      expect(user.id).toBeDefined()
    })
  })

  describe('notification', () => {
    it('should send welcome email after registration', async () => {
      const user = await registerUser(createValidUserData())
      
      expect(mockEmailService.send).toHaveBeenCalledWith(
        expect.objectContaining({ 
          to: user.email,
          template: 'welcome' 
        })
      )
    })
  })
})

// テストヘルパー関数
function createValidUserData() {
  return {
    email: 'test@example.com',
    password: 'SecurePass123!',
    name: 'Test User',
  }
}

回避策のポイント

1つのテストでは1つのことだけを検証するという原則(Single Assertion Principle)に従います。テストが失敗したとき、原因が即座に特定できる粒度を保ちます。

設計的アンチパターン

アンチパターン4: テストしづらい設計(The Untestable)

テストを書く際に「このコードはテストしづらい」と感じたら、それは設計上の問題を示すサインです。テストしづらさは、密結合、隠れた依存、副作用の混在などの設計問題を反映しています。

問題のあるコード例

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// 悪い例: テストしづらい設計
public class ReportGenerator {
    public String generateMonthlyReport() {
        // 隠れた依存: 現在日時を直接取得
        LocalDate today = LocalDate.now();
        LocalDate startOfMonth = today.withDayOfMonth(1);
        
        // 隠れた依存: シングルトンからDBアクセス
        Connection conn = DatabaseManager.getInstance().getConnection();
        
        // 密結合: SQL直接実行
        String sql = "SELECT * FROM sales WHERE date >= ?";
        PreparedStatement stmt = conn.prepareStatement(sql);
        stmt.setDate(1, java.sql.Date.valueOf(startOfMonth));
        ResultSet rs = stmt.executeQuery();
        
        // ロジックとI/Oが混在
        StringBuilder report = new StringBuilder();
        while (rs.next()) {
            report.append(formatRow(rs));
        }
        
        // 副作用: ファイル出力
        Files.writeString(Path.of("/reports/monthly.txt"), report.toString());
        
        return report.toString();
    }
}

このコードをテストするには、データベース、ファイルシステム、現在日時のすべてを制御する必要があります。

改善されたコード例

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// 良い例: テスタブルな設計
public class ReportGenerator {
    private final SalesRepository salesRepository;
    private final ReportWriter reportWriter;
    private final Clock clock;
    
    // 依存性を明示的に注入
    public ReportGenerator(SalesRepository salesRepository, 
                          ReportWriter reportWriter, 
                          Clock clock) {
        this.salesRepository = salesRepository;
        this.reportWriter = reportWriter;
        this.clock = clock;
    }
    
    public String generateMonthlyReport() {
        LocalDate today = LocalDate.now(clock);
        LocalDate startOfMonth = today.withDayOfMonth(1);
        
        List<Sale> sales = salesRepository.findByDateAfter(startOfMonth);
        
        String report = formatReport(sales);
        
        reportWriter.write(report);
        
        return report;
    }
    
    // 純粋関数として分離
    String formatReport(List<Sale> sales) {
        return sales.stream()
            .map(this::formatSale)
            .collect(Collectors.joining("\n"));
    }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
// テストコード
@Test
void shouldGenerateReportForCurrentMonth() {
    // 固定された時刻を使用
    Clock fixedClock = Clock.fixed(
        LocalDate.of(2026, 1, 15).atStartOfDay(ZoneId.systemDefault()).toInstant(),
        ZoneId.systemDefault()
    );
    
    // インメモリ実装を使用
    InMemorySalesRepository repository = new InMemorySalesRepository();
    repository.save(new Sale(LocalDate.of(2026, 1, 5), new BigDecimal("100")));
    repository.save(new Sale(LocalDate.of(2026, 1, 10), new BigDecimal("200")));
    
    StringReportWriter writer = new StringReportWriter();
    
    ReportGenerator generator = new ReportGenerator(repository, writer, fixedClock);
    String report = generator.generateMonthlyReport();
    
    assertThat(report).contains("100", "200");
    assertThat(writer.getWrittenContent()).isEqualTo(report);
}

回避策のポイント

設計の問題 解決策
シングルトン依存 依存性注入(DI)を使用する
現在日時の直接取得 Clockオブジェクトを注入する
ファイル/DBへの直接アクセス Repository/Gatewayパターンで抽象化する
ロジックとI/Oの混在 純粋関数を抽出する

アンチパターン5: プライベートメソッドテスト(The Peeping Tom)

プライベートメソッドを直接テストしたくなったら、それは設計上の問題を示しています。

問題のあるコード例

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 悪い例: プライベートメソッドを無理やりテスト
class PriceCalculator {
  calculate(items) {
    const subtotal = this._calculateSubtotal(items)
    const tax = this._calculateTax(subtotal)
    const discount = this._applyDiscount(subtotal)
    return subtotal + tax - discount
  }
  
  // これらはプライベートメソッド
  _calculateSubtotal(items) { /* ... */ }
  _calculateTax(amount) { /* ... */ }
  _applyDiscount(amount) { /* ... */ }
}

// プライベートメソッドを直接テスト(悪い例)
describe('PriceCalculator internals', () => {
  it('should calculate tax correctly', () => {
    const calculator = new PriceCalculator()
    // プライベートメソッドに直接アクセス
    expect(calculator._calculateTax(100)).toBe(10)
  })
})

改善されたコード例

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// 良い例: パブリックインターフェースを通してテスト
describe('PriceCalculator', () => {
  it('should include 10% tax in final price', () => {
    const calculator = new PriceCalculator()
    const items = [{ price: 100, quantity: 1 }]
    
    const result = calculator.calculate(items)
    
    // 税込み価格が期待通りであることを検証
    expect(result).toBe(110) // 100 + 10% tax
  })
})

// もしプライベートメソッドが複雑なら、別クラスに抽出
class TaxCalculator {
  calculate(amount) {
    return amount * 0.1
  }
}

class PriceCalculator {
  constructor(taxCalculator = new TaxCalculator()) {
    this.taxCalculator = taxCalculator
  }
  
  calculate(items) {
    const subtotal = this.calculateSubtotal(items)
    const tax = this.taxCalculator.calculate(subtotal)
    return subtotal + tax
  }
}

// 抽出したクラスを個別にテスト
describe('TaxCalculator', () => {
  it('should calculate 10% tax', () => {
    const calculator = new TaxCalculator()
    expect(calculator.calculate(100)).toBe(10)
  })
})

プライベートメソッドをテストしたくなったら、以下のどちらかを検討してください。

  1. パブリックインターフェースを通じて間接的にテストする
  2. 別のクラスに抽出して、そのクラスをテストする

アンチパターン6: 継承階層テスト(The Inheritance Trap)

継承を多用した設計では、テストの重複や親クラスへの依存が問題になります。

flowchart TB
    subgraph problem["問題: 継承ベースのテスト"]
        P1[AbstractRepositoryTest]
        P2[UserRepositoryTest]
        P3[OrderRepositoryTest]
        P1 --> P2
        P1 --> P3
    end
    
    subgraph solution["解決: 合成ベースのテスト"]
        S1[RepositoryContractTest]
        S2[UserRepositoryTest]
        S3[OrderRepositoryTest]
        S1 -.->|uses| S2
        S1 -.->|uses| S3
    end
    
    style problem fill:#ffebee,stroke:#c62828,color:#000000
    style solution fill:#e8f5e9,stroke:#2e7d32,color:#000000

テストクラスの継承を避け、合成(コンポジション)やテストヘルパーを使用することで、テストの独立性と柔軟性を保てます。

プロセス的アンチパターン

アンチパターン7: テストファースト放棄(Test-After Development)

「後でテストを書こう」と思っていると、結局テストが書かれないか、テストしづらいコードになってしまいます。

問題の兆候

  • テストカバレッジが低い部分が放置されている
  • 「時間がなくてテストを書けなかった」という発言が頻繁
  • テストが後付けで追加され、仕様書としての価値がない
  • バグ修正時にテストが追加されない

回避策

flowchart LR
    subgraph tdd["TDDサイクル"]
        R[Red<br/>失敗するテスト] --> G[Green<br/>テストを通す]
        G --> RF[Refactor<br/>コード改善]
        RF --> R
    end
    
    subgraph rule["ルール"]
        Rule1["テストなしにコードを書かない"]
        Rule2["すべてのテストが通るまでコミットしない"]
        Rule3["リファクタリングはテストが通った後に"]
    end
    
    style R fill:#ffebee,stroke:#c62828,color:#000000
    style G fill:#e8f5e9,stroke:#2e7d32,color:#000000
    style RF fill:#e3f2fd,stroke:#1565c0,color:#000000

TDDの習慣を身につけるためには、以下の実践が効果的です。

  1. 小さなステップから始める: 最初から完璧を目指さず、1つの機能に対して1つのテストから始める
  2. ペアプログラミングを活用: Ping-Pongスタイルでテストと実装を交互に担当する
  3. CIでテストを必須化: テストが通らないとマージできない仕組みを導入する

アンチパターン8: リファクタリング省略(The Skip Refactor)

Greenになった時点で次の機能に進んでしまい、Refactorフェーズを省略するパターンです。

問題の兆候

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
// リファクタリングを省略し続けた結果
function processOrder(order) {
  // TODO: リファクタリングする
  if (order.type === 'standard') {
    if (order.total > 100) {
      if (order.customer.isPremium) {
        order.discount = order.total * 0.15
      } else {
        order.discount = order.total * 0.1
      }
    } else {
      if (order.customer.isPremium) {
        order.discount = order.total * 0.05
      } else {
        order.discount = 0
      }
    }
  } else if (order.type === 'express') {
    // 同様のネストが続く...
  }
  // 以下200行続く
}

回避策

Refactorフェーズは省略してはいけません。以下のサインが見えたらリファクタリングを行います。

コードの臭い リファクタリング手法
深いネスト ガード節の導入、ポリモーフィズム
長いメソッド メソッドの抽出
重複コード 共通処理の抽出
多すぎるパラメータ パラメータオブジェクトの導入

アンチパターン9: カバレッジ信仰(Coverage Obsession)

テストカバレッジ100%を目標にすることで、意味のないテストが増え、保守コストが上がります。

問題のあるコード例

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// 悪い例: カバレッジのためだけのテスト
@Test
void shouldHaveToStringMethod() {
    User user = new User("Alice", "alice@example.com");
    assertThat(user.toString()).isNotNull(); // 何も検証していない
}

@Test
void shouldHaveGettersAndSetters() {
    User user = new User();
    user.setName("Alice");
    assertThat(user.getName()).isEqualTo("Alice"); // trivial
}

@Test
void shouldConstructWithNoArgs() {
    User user = new User();
    assertThat(user).isNotNull(); // 無意味
}

回避策

カバレッジは指標の1つにすぎません。以下の基準でテストの価値を判断します。

評価軸 良いテスト 悪いテスト
バグ検出能力 本番で起こりうるバグを検出 trivialなコードを検証
ドキュメント性 仕様が読み取れる 実装詳細の羅列
保守コスト 変更に強い リファクタリングで壊れる

保守性アンチパターン

アンチパターン10: 脆いテスト(The Fragile Test)

本番コードの正しい変更によって壊れてしまうテストは、開発の足かせになります。

問題のあるコード例

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 悪い例: 順序に依存した脆いテスト
it('should return users in correct format', () => {
  const users = getUserList()
  
  // 順序に依存
  expect(users[0].name).toBe('Alice')
  expect(users[1].name).toBe('Bob')
  expect(users[2].name).toBe('Charlie')
})

// 悪い例: 完全一致に依存した脆いテスト
it('should format error message', () => {
  const error = validateUser({ name: '' })
  
  // エラーメッセージの文言変更で壊れる
  expect(error.message).toBe('Name is required. Please enter a valid name.')
})

// 悪い例: 日時に依存した脆いテスト
it('should set creation date', () => {
  const user = createUser({ name: 'Alice' })
  
  // テスト実行タイミングで結果が変わる
  expect(user.createdAt).toBe(new Date().toISOString())
})

改善されたコード例

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// 良い例: 順序非依存のテスト
it('should return all expected users', () => {
  const users = getUserList()
  const names = users.map(u => u.name)
  
  expect(names).toContain('Alice')
  expect(names).toContain('Bob')
  expect(names).toContain('Charlie')
  expect(names).toHaveLength(3)
})

// 良い例: 部分一致のテスト
it('should return error for empty name', () => {
  const error = validateUser({ name: '' })
  
  expect(error.field).toBe('name')
  expect(error.type).toBe('required')
  // メッセージは含まれていればOK
  expect(error.message).toContain('name')
})

// 良い例: 固定された時刻を使用
it('should set creation date', () => {
  const fixedDate = new Date('2026-01-05T12:00:00Z')
  jest.useFakeTimers().setSystemTime(fixedDate)
  
  const user = createUser({ name: 'Alice' })
  
  expect(user.createdAt).toBe('2026-01-05T12:00:00.000Z')
  
  jest.useRealTimers()
})

アンチパターン11: 遅いテスト(The Slowpoke)

テストスイートの実行に時間がかかりすぎると、開発者はテストを頻繁に実行しなくなります。

問題の原因と対策

原因 対策
実際のDBへのアクセス インメモリDBまたはリポジトリのフェイクを使用
外部APIの呼び出し WireMockなどでスタブ化
ファイルI/O インメモリファイルシステムを使用
sleep/待機処理 非同期処理のモック化、タイムアウトの短縮
過剰なセットアップ テストごとに必要最小限のデータのみ準備

改善されたコード例

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// 遅い例: 実DBを使用
@Test
void shouldSaveUser() {
    // 実際のPostgreSQLに接続(遅い)
    UserRepository repository = new PostgresUserRepository(dataSource);
    repository.save(new User("Alice"));
    // ...
}

// 速い例: インメモリ実装を使用
@Test
void shouldSaveUser() {
    // インメモリ実装(高速)
    UserRepository repository = new InMemoryUserRepository();
    repository.save(new User("Alice"));
    
    assertThat(repository.findByName("Alice")).isPresent();
}

テストの実行時間の目安は以下のとおりです。

  • 単体テスト: 10ms以下/テスト
  • 統合テスト: 100ms以下/テスト
  • E2Eテスト: 数秒/テスト

アンチパターン12: 重複テスト(The Test Redundancy)

同じ振る舞いを複数のレベルで重複してテストしていると、保守コストが増大します。

flowchart TB
    subgraph problem["問題: 重複したテスト"]
        E2E1["E2E: ログインして商品を検索"]
        IT1["統合: 商品検索APIをテスト"]
        UT1["単体: 検索ロジックをテスト"]
        
        E2E1 -->|"同じ検索ロジック"| IT1
        IT1 -->|"同じ検索ロジック"| UT1
    end
    
    subgraph solution["解決: 各レベルで固有の観点"]
        E2E2["E2E: ユーザージャーニー全体"]
        IT2["統合: API契約とDB連携"]
        UT2["単体: エッジケースと計算ロジック"]
    end
    
    style problem fill:#ffebee,stroke:#c62828,color:#000000
    style solution fill:#e8f5e9,stroke:#2e7d32,color:#000000

回避策

各テストレベルで異なる観点を検証します。

テストレベル 検証すべき観点
単体テスト ビジネスロジック、エッジケース 割引計算、バリデーション
統合テスト コンポーネント間の連携 DB永続化、API契約
E2Eテスト ユーザージャーニー 購入フロー全体

下位レベルで十分にテストされた内容は、上位レベルでは省略します。

アンチパターン早期発見チェックリスト

以下のチェックリストを使って、テストスイートの健全性を定期的に評価しましょう。

構造的な問題

  • テストがメソッドの呼び出し順序を検証していないか
  • モックが5個以上設定されているテストがないか
  • 1つのテストで10個以上のアサーションがないか

設計的な問題

  • プライベートメソッドを直接テストしていないか
  • new Date()Math.random()を直接使っていないか
  • シングルトンやstatic変数に依存していないか

プロセス的な問題

  • テストを書く前にコードを書いていないか
  • Greenになった後すぐに次の機能に進んでいないか
  • カバレッジ100%を機械的に目指していないか

保守性の問題

  • リファクタリングのたびにテストが壊れないか
  • テストスイートの実行に5分以上かかっていないか
  • 同じロジックを複数レベルで重複テストしていないか

まとめ

TDDアンチパターンは、多くの開発者が経験する共通の落とし穴です。これらのパターンを認識し、適切な回避策を実践することで、TDDの本来のメリットを最大限に活かせます。

健全なテストスイートを維持するためのポイントをまとめます。

  1. 振る舞いをテストする: 実装詳細ではなく、期待される結果を検証する
  2. 適切なレベルでモックを使う: 外部境界のみモックし、ドメインロジックは実オブジェクトを使う
  3. テストしづらさは設計の問題: テストが書きにくいと感じたら、設計を見直す
  4. TDDサイクルを守る: Red-Green-Refactorを省略しない
  5. テストピラミッドを意識する: 各レベルで異なる観点を検証し、重複を避ける

テストは本番コードと同様に、継続的な改善が必要です。定期的にテストスイートを見直し、アンチパターンを検出・修正することで、長期的に保守可能な開発環境を維持できます。

参考リンク