はじめに#
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)
})
})
|
プライベートメソッドをテストしたくなったら、以下のどちらかを検討してください。
- パブリックインターフェースを通じて間接的にテストする
- 別のクラスに抽出して、そのクラスをテストする
アンチパターン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:#000000TDDの習慣を身につけるためには、以下の実践が効果的です。
- 小さなステップから始める: 最初から完璧を目指さず、1つの機能に対して1つのテストから始める
- ペアプログラミングを活用: Ping-Pongスタイルでテストと実装を交互に担当する
- 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テスト |
ユーザージャーニー |
購入フロー全体 |
下位レベルで十分にテストされた内容は、上位レベルでは省略します。
アンチパターン早期発見チェックリスト#
以下のチェックリストを使って、テストスイートの健全性を定期的に評価しましょう。
構造的な問題#
設計的な問題#
プロセス的な問題#
保守性の問題#
まとめ#
TDDアンチパターンは、多くの開発者が経験する共通の落とし穴です。これらのパターンを認識し、適切な回避策を実践することで、TDDの本来のメリットを最大限に活かせます。
健全なテストスイートを維持するためのポイントをまとめます。
- 振る舞いをテストする: 実装詳細ではなく、期待される結果を検証する
- 適切なレベルでモックを使う: 外部境界のみモックし、ドメインロジックは実オブジェクトを使う
- テストしづらさは設計の問題: テストが書きにくいと感じたら、設計を見直す
- TDDサイクルを守る: Red-Green-Refactorを省略しない
- テストピラミッドを意識する: 各レベルで異なる観点を検証し、重複を避ける
テストは本番コードと同様に、継続的な改善が必要です。定期的にテストスイートを見直し、アンチパターンを検出・修正することで、長期的に保守可能な開発環境を維持できます。
参考リンク#