Unit Tests
ユニットテスト完全ガイド
Unit Testing Comprehensive Guide
本書は、ユニットテストの概念、アーキテクチャ、設定例、実践的な使い方を複数のプログラミング言語にわたって包括的に解説するガイドである。
第1章: ユニットテストの概要
1.1 ユニットテストとは何か
ユニットテスト(Unit Test)とは、ソフトウェアの最小単位である「ユニット」を個別にテストする手法である。ここでいう「ユニット」とは、一般的には関数、メソッド、またはクラスを指す。ユニットテストの目的は、各ユニットが仕様通りに正しく動作することを検証することにある。
ユニットテストは、開発者自身が記述し、自動的に実行できるテストコードとして実装される。手動テストとは異なり、何度でも同じ条件で繰り返し実行でき、結果は即座に判定される。これにより、コード変更時のリグレッション(退行)を素早く検出できる。
// Java でのユニットテストの最もシンプルな例
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
class CalculatorTest {
@Test
void testAdd() {
Calculator calculator = new Calculator();
int result = calculator.add(2, 3);
assertEquals(5, result, "2 + 3 は 5 であるべき");
}
}
# Python でのユニットテストの最もシンプルな例
def test_add():
calculator = Calculator()
result = calculator.add(2, 3)
assert result == 5, "2 + 3 は 5 であるべき"
上記の例では、Calculator クラスの add メソッドが正しく加算を行うかを検証している。テストは自己完結しており、外部サービスやデータベースに依存しない。これがユニットテストの基本的な特徴である。
1.2 ユニットテストの歴史と背景
ユニットテストの概念は、1970年代にまで遡ることができる。しかし、現代的なユニットテストの実践が広まったのは、1990年代後半から2000年代初頭にかけてである。
主要なマイルストーン
- 1994年: Kent Beck が SUnit(Smalltalk 用のテストフレームワーク)を開発。これが現代のxUnitフレームワーク群の祖先となった。
- 1997年: Kent Beck と Erich Gamma が JUnit を開発。Java コミュニティにおけるユニットテストの普及に大きく貢献した。
- 1999年: Kent Beck が「Extreme Programming Explained」を出版。テスト駆動開発(TDD)の概念を広めた。
- 2002年: Kent Beck が「Test-Driven Development: By Example」を出版。TDD の実践的なガイドとなった。
- 2004年: Michael Feathers が「Working Effectively with Legacy Code」を出版。レガシーコードへのテスト追加手法を体系化した。
- 2005年: Martin Fowler が「Mocks Aren't Stubs」を発表。テストダブルの分類を明確にした。
- 2007年: Gerard Meszaros が「xUnit Test Patterns」を出版。テストパターンとアンチパターンを網羅的に整理した。
これらの先駆者たちの功績により、ユニットテストは現代のソフトウェア開発において不可欠なプラクティスとして確立された。
1.3 テストピラミッド
テストピラミッドは、Mike Cohn が2009年の著書「Succeeding with Agile」で提唱した概念である。ソフトウェアテストを3つの層に分類し、それぞれの層でどの程度のテストを書くべきかを示している。
/\
/ \ E2E テスト(少数)
/ \ - ブラウザテスト
/------\ - システム全体の動作確認
/ \
/ \ インテグレーションテスト(中程度)
/ \ - API テスト
/--------------\ - DB との結合テスト
/ \
/ \ ユニットテスト(大量)
/==================\ - 関数・メソッド単位のテスト
- 高速、安定、大量に実行可能
ユニットテスト(ピラミッドの底辺)
- 量: 最も多い(全テストの70〜80%)
- 速度: 非常に高速(ミリ秒単位)
- コスト: 作成・保守コストが最も低い
- スコープ: 個々の関数やメソッド
- 外部依存: なし(モックで置き換え)
インテグレーションテスト(ピラミッドの中間)
- 量: 中程度(全テストの15〜20%)
- 速度: 中程度(秒単位)
- コスト: 中程度
- スコープ: 複数のコンポーネントの結合
- 外部依存: データベース、API等との実際の連携
E2Eテスト(ピラミッドの頂点)
- 量: 最も少ない(全テストの5〜10%)
- 速度: 低速(分単位)
- コスト: 作成・保守コストが最も高い
- スコープ: システム全体
- 外部依存: 全ての外部サービスとの連携
近年では、テストピラミッドの変形として「テストトロフィー」(Kent C. Dodds 提唱)や「テストダイヤモンド」といった概念も提案されている。テストトロフィーでは、インテグレーションテストに最も多くの比重を置くことを推奨している。しかし、ユニットテストが重要であるという基本的な考え方は変わらない。
1.4 ユニットテストの目的と利点
目的
- 正しさの検証: コードが仕様通りに動作することを確認する
- リグレッション防止: コード変更後に既存の機能が壊れていないことを検証する
- 設計の改善: テストを書くことで、コードの設計について考える機会を得る
- ドキュメンテーション: テストコードは実行可能な仕様書として機能する
- リファクタリングの安全網: テストがあることで、安心してリファクタリングできる
利点
開発速度の向上
一見矛盾するように思えるが、ユニットテストを書くことで長期的な開発速度は向上する。バグの早期発見、リファクタリングの安全性、デバッグ時間の短縮がその理由である。Martin Fowler は「テストを書く時間よりも、テストがなくてデバッグする時間の方がはるかに長い」と述べている。
バグの早期発見
ユニットテストにより、バグはコードを書いた直後に発見される。バグの修正コストは発見が遅れるほど指数関数的に増加するため(Barry Boehm の研究)、早期発見の価値は極めて大きい。
コードの品質向上
テスト可能なコードを書くことを意識すると、自然と疎結合で凝集度の高い設計になる。依存性注入(Dependency Injection)や単一責任原則(Single Responsibility Principle)といった良い設計原則に従うことになる。
安全なリファクタリング
包括的なユニットテストスイートがあれば、コードの内部構造を自信を持って変更できる。テストが通る限り、外部から見た振る舞いは保たれていることが保証される。
実行可能なドキュメント
テストコードは、対象コードの使い方を示す生きたドキュメントとして機能する。テストは常に最新であり(テストが通る限り)、従来のドキュメントのように陳腐化する心配がない。
1.5 ソフトウェア品質とテストの関係
ソフトウェア品質は、ISO/IEC 25010 で定義された品質モデルに基づいて評価される。この規格では、以下の8つの品質特性が定義されている:
- 機能適合性: 機能が要件を満たしているか
- 性能効率性: リソースの使用効率
- 互換性: 他のシステムとの共存・相互運用
- 使用性: ユーザーにとっての使いやすさ
- 信頼性: 障害に対する耐性
- セキュリティ: 情報の保護
- 保守性: 変更の容易さ
- 移植性: 他の環境への移行の容易さ
ユニットテストは、特に「機能適合性」「信頼性」「保守性」の向上に直接的に貢献する。テストカバレッジが高いコードベースは、バグの発生率が低く、変更に強い傾向がある。
ただし、テストがあれば品質が保証されるわけではない。テスト自体の品質も重要であり、意味のないテスト(常に成功するテスト、何も検証しないテスト)を書いても品質は向上しない。重要なのは、テストがビジネスロジックの正しさを適切に検証していることである。
Dijkstra の有名な言葉「テストはバグの存在を示すことはできるが、バグの不在を証明することはできない」を忘れてはならない。ユニットテストは品質向上の重要な手段であるが、万能薬ではない。静的解析、コードレビュー、インテグレーションテスト、E2Eテストなど、他の品質保証手法と組み合わせて使用することで、真に高品質なソフトウェアを実現できる。
第2章: ユニットテストの基本原則
2.1 F.I.R.S.T 原則
優れたユニットテストは、F.I.R.S.T 原則に従う。この原則は Robert C. Martin(Uncle Bob)が「Clean Code」で紹介したもので、良いテストの特性を5つの頭文字で表している。
Fast(高速)
ユニットテストは高速に実行されなければならない。1つのテストの実行時間は数ミリ秒以内であるべきだ。テストスイート全体が数秒で完了することが理想的である。テストが遅いと、開発者はテストを頻繁に実行しなくなり、テストの価値が激減する。
高速性を維持するための指針:
- データベースアクセスを避ける
- ネットワーク通信を避ける
- ファイルシステムアクセスを最小限にする
- 外部依存はモックで置き換える
Independent(独立)
各テストは他のテストに依存してはならない。テストの実行順序に関わらず、同じ結果が得られなければならない。テスト間で状態を共有すると、あるテストの失敗が他のテストに連鎖し、デバッグが困難になる。
# 悪い例: テスト間で状態を共有している
class TestUserManager:
users = [] # クラス変数で状態を共有
def test_add_user(self):
self.users.append(User("Alice"))
assert len(self.users) == 1
def test_add_another_user(self):
# test_add_user が先に実行されていることを前提としている
self.users.append(User("Bob"))
assert len(self.users) == 2 # 順序依存!
# 良い例: 各テストが独立している
class TestUserManager:
def test_add_user(self):
users = []
users.append(User("Alice"))
assert len(users) == 1
def test_add_another_user(self):
users = []
users.append(User("Bob"))
assert len(users) == 1
Repeatable(再現可能)
テストはどの環境でも同じ結果を返さなければならない。開発者のローカル環境、CI サーバー、本番に近いステージング環境、いずれでも結果が一致するべきだ。
再現性を損なう要因:
- 現在時刻への依存(
new Date(),datetime.now()) - 乱数への依存
- 外部サービスへの依存
- 環境変数への暗黙の依存
- ファイルシステムの特定のパスへの依存
// 悪い例: 現在時刻に依存
@Test
void testIsExpired() {
Coupon coupon = new Coupon(LocalDate.of(2025, 12, 31));
// 現在の日付によって結果が変わる
assertFalse(coupon.isExpired());
}
// 良い例: 時刻を注入可能にする
@Test
void testIsExpired() {
Clock fixedClock = Clock.fixed(
Instant.parse("2025-06-15T00:00:00Z"),
ZoneId.of("UTC")
);
Coupon coupon = new Coupon(LocalDate.of(2025, 12, 31), fixedClock);
assertFalse(coupon.isExpired());
}
Self-Validating(自己検証的)
テストは明確に成功か失敗かを判定しなければならない。テストの結果を判断するために、ログファイルを目視で確認したり、データベースの中身を手動で確認したりする必要があってはならない。
# 悪い例: 結果を手動で確認する必要がある
def test_process_data():
result = process_data(input_data)
print(result) # 出力を目視確認?
# 良い例: アサーションで自動判定
def test_process_data():
result = process_data(input_data)
assert result == expected_output
assert result.status == "completed"
assert len(result.items) == 3
Timely(適時)
テストは適切なタイミングで書かれるべきである。理想的にはプロダクションコードの直前(TDD)または直後に書く。コードを書いてから長時間経過した後にテストを書くと、テストの質が下がる傾向がある。
2.2 Arrange-Act-Assert(AAA)パターン
AAA パターンは、ユニットテストの構造を3つのフェーズに分ける最も一般的なパターンである。
@Test
void testWithdraw() {
// Arrange(準備): テストに必要なオブジェクトやデータを準備する
BankAccount account = new BankAccount("12345", 1000.0);
// Act(実行): テスト対象の操作を1つだけ実行する
account.withdraw(200.0);
// Assert(検証): 期待される結果を検証する
assertEquals(800.0, account.getBalance(), 0.001);
}
def test_withdraw():
# Arrange
account = BankAccount("12345", 1000.0)
# Act
account.withdraw(200.0)
# Assert
assert account.balance == 800.0
AAA パターンのガイドライン
- Arrange セクション: テストに必要な前提条件をすべてセットアップする。オブジェクトの生成、モックの設定、テストデータの準備などを行う。
- Act セクション: テスト対象の操作を 1つだけ 実行する。複数の操作を実行すると、どの操作が失敗の原因かが不明確になる。
- Assert セクション: 期待される結果を検証する。1つの論理的な概念に対するアサーションを書く(必ずしも1つのassert文とは限らない)。
2.3 Given-When-Then パターン
Given-When-Then は、BDD(振る舞い駆動開発)から生まれたパターンで、AAA パターンと本質的に同じだが、ビジネスの文脈で記述する。
def test_apply_discount_for_premium_member():
# Given: プレミアム会員が商品をカートに入れている
member = Member(tier="premium")
cart = ShoppingCart(owner=member)
cart.add_item(Item("Laptop", price=100000))
# When: 割引を適用する
cart.apply_discount()
# Then: 10%の割引が適用される
assert cart.total == 90000
assert cart.discount_amount == 10000
2.4 テスト対象(SUT: System Under Test)
SUT(System Under Test)とは、テスト対象のコードのことである。ユニットテストにおいては、通常1つのクラスまたは1つの関数が SUT となる。
SUT を明確にすることは重要である。テストが何をテストしているのかが曖昧だと、テストの意図が不明確になり、保守が困難になる。
class OrderServiceTest {
// SUT: OrderService
private OrderService sut;
// 依存関係(コラボレーター)
private OrderRepository orderRepository;
private PaymentGateway paymentGateway;
private NotificationService notificationService;
@BeforeEach
void setUp() {
orderRepository = mock(OrderRepository.class);
paymentGateway = mock(PaymentGateway.class);
notificationService = mock(NotificationService.class);
sut = new OrderService(orderRepository, paymentGateway, notificationService);
}
@Test
void shouldCreateOrder() {
// Arrange
OrderRequest request = new OrderRequest("item-1", 2, 1000);
when(paymentGateway.charge(anyDouble())).thenReturn(true);
// Act
Order result = sut.createOrder(request);
// Assert
assertNotNull(result);
assertEquals("item-1", result.getItemId());
verify(orderRepository).save(any(Order.class));
}
}
2.5 テストの粒度と境界
ユニットテストにおける「ユニット」の粒度は、議論の多いテーマである。
古典派(Classical / Detroit School)
- ユニット = 振る舞いの単位(1つのクラスまたは複数の関連クラス)
- 実際のオブジェクトをできるだけ使用
- 共有依存(データベース、ファイルシステム等)のみをモックに置き換える
- 代表者: Kent Beck, Martin Fowler
モック派(Mockist / London School)
- ユニット = 1つのクラス
- SUT 以外のすべての依存をモックに置き換える
- テスト対象の振る舞いのみに注目
- 代表者: Steve Freeman, Nat Pryce
どちらのアプローチにも長所と短所がある。古典派はリファクタリングに強いが、テストのセットアップが複雑になることがある。モック派はテストが独立しているが、実装の詳細に結合しやすい。
2.6 ブラックボックステスト vs ホワイトボックステスト
ソフトウェアテストの技法は、テスト対象の内部構造を考慮するかどうかによってブラックボックステストとホワイトボックステストに大別される。ユニットテストにおいてはどちらの技法も活用されるが、それぞれの特性を理解し、適材適所で使い分けることが重要である。
2.6.1 ブラックボックステスト
ブラックボックステストは、テスト対象の内部実装を一切考慮せず、仕様(入力と期待される出力)のみに基づいてテストケースを設計する手法である。公開APIのインターフェースだけを対象にするため、リファクタリングに対して強い耐性を持つ。
(1) 同値分割法(Equivalence Partitioning)
同値分割法は、入力データの領域を**同じ振る舞いをするグループ(同値クラス)**に分割し、各クラスから代表値を1つ選んでテストする技法である。すべての入力値を個別にテストすることは現実的でないため、同値クラスの代表値でテストすることで効率的にカバレッジを確保する。
原則:
- 有効同値クラス(正常値)と無効同値クラス(異常値)の両方を識別する
- 各同値クラスから最低1つの代表値をテストケースとする
- 同値クラス内の値はすべて同じ結果を返すと仮定する
例:年齢を入力とする関数のテスト
| 同値クラス | 範囲 | 代表値 | 期待結果 |
|---|---|---|---|
| 無効(負の値) | age < 0 | -5 | エラー |
| 有効(未成年) | 0 ≤ age < 18 | 10 | "未成年" |
| 有効(成人) | 18 ≤ age < 65 | 30 | "成人" |
| 有効(高齢者) | 65 ≤ age ≤ 150 | 70 | "高齢者" |
| 無効(上限超過) | age > 150 | 200 | エラー |
// Java + JUnit 5 による同値分割テストの例
class AgeClassifierTest {
@ParameterizedTest
@CsvSource({
// 無効同値クラス: 負の値
"-5, INVALID",
"-1, INVALID",
// 有効同値クラス: 未成年
"0, MINOR",
"10, MINOR",
// 有効同値クラス: 成人
"18, ADULT",
"30, ADULT",
// 有効同値クラス: 高齢者
"65, SENIOR",
"70, SENIOR",
// 無効同値クラス: 上限超過
"151, INVALID",
"200, INVALID"
})
void shouldClassifyAge(int age, String expected) {
assertEquals(expected, AgeClassifier.classify(age));
}
}
# Python + pytest による同値分割テストの例
import pytest
from age_classifier import classify_age
@pytest.mark.parametrize("age, expected", [
# 無効同値クラス: 負の値
(-5, "invalid"),
# 有効同値クラス: 未成年
(10, "minor"),
# 有効同値クラス: 成人
(30, "adult"),
# 有効同値クラス: 高齢者
(70, "senior"),
# 無効同値クラス: 上限超過
(200, "invalid"),
])
def test_classify_age(age, expected):
assert classify_age(age) == expected
(2) 境界値分析(Boundary Value Analysis)
境界値分析は、同値クラスの**境界(端の値)**にバグが潜みやすいという経験則に基づき、境界付近の値を重点的にテストする技法である。同値分割法と組み合わせて使用することが多い。
原則:
- 各境界において、「境界値そのもの」「境界値 ± 1」をテストする
- 2値境界値分析(boundary value)では境界上の値のみ、3値境界値分析(robustness testing)では境界 ± 1 も含める
例:上記の年齢分類の境界値テスト
| 境界 | テスト値 | 期待結果 |
|---|---|---|
| 負→未成年 | -1, 0, 1 | INVALID, MINOR, MINOR |
| 未成年→成人 | 17, 18, 19 | MINOR, ADULT, ADULT |
| 成人→高齢者 | 64, 65, 66 | ADULT, SENIOR, SENIOR |
| 高齢者→無効 | 149, 150, 151 | SENIOR, SENIOR, INVALID |
// 境界値分析のテスト例
class AgeClassifierBoundaryTest {
@ParameterizedTest(name = "age={0} → {1}")
@CsvSource({
// 負→未成年 の境界
"-1, INVALID",
"0, MINOR",
"1, MINOR",
// 未成年→成人 の境界
"17, MINOR",
"18, ADULT",
"19, ADULT",
// 成人→高齢者 の境界
"64, ADULT",
"65, SENIOR",
"66, SENIOR",
// 高齢者→無効 の境界
"149, SENIOR",
"150, SENIOR",
"151, INVALID"
})
void shouldHandleBoundaryValues(int age, String expected) {
assertEquals(expected, AgeClassifier.classify(age));
}
}
(3) デシジョンテーブルテスト(Decision Table Testing)
デシジョンテーブルテスト(決定表テスト)は、複数の条件の組み合わせとそれに対応するアクション(期待結果)を表形式で整理し、すべての条件の組み合わせを網羅的にテストする技法である。条件が複雑に絡み合うビジネスロジックに特に有効である。
例:ECサイトの送料計算ロジック
| 条件 | ルール1 | ルール2 | ルール3 | ルール4 | ルール5 | ルール6 |
|---|---|---|---|---|---|---|
| 会員ランク: ゴールド | Y | Y | N | N | N | N |
| 注文金額 ≥ 5,000円 | Y | N | Y | N | Y | N |
| 離島への配送 | - | - | Y | Y | N | N |
| 送料無料 | ✓ | ✓ | - | - | ✓ | - |
| 送料500円 | - | - | ✓ | - | - | ✓ |
| 送料1,000円 | - | - | - | ✓ | - | - |
// デシジョンテーブルに基づくテスト例
class ShippingCostTest {
// ルール1: ゴールド会員 → 常に無料
@Test
void goldMember_highAmount_freeShipping() {
var order = new Order(MemberRank.GOLD, 10000, false);
assertEquals(0, ShippingCalculator.calculate(order));
}
// ルール2: ゴールド会員 → 金額に関わらず無料
@Test
void goldMember_lowAmount_freeShipping() {
var order = new Order(MemberRank.GOLD, 3000, false);
assertEquals(0, ShippingCalculator.calculate(order));
}
// ルール3: 一般会員、5000円以上、離島 → 500円
@Test
void regularMember_highAmount_island_500yen() {
var order = new Order(MemberRank.REGULAR, 6000, true);
assertEquals(500, ShippingCalculator.calculate(order));
}
// ルール4: 一般会員、5000円未満、離島 → 1000円
@Test
void regularMember_lowAmount_island_1000yen() {
var order = new Order(MemberRank.REGULAR, 3000, true);
assertEquals(1000, ShippingCalculator.calculate(order));
}
// ルール5: 一般会員、5000円以上、通常配送 → 無料
@Test
void regularMember_highAmount_normal_freeShipping() {
var order = new Order(MemberRank.REGULAR, 6000, false);
assertEquals(0, ShippingCalculator.calculate(order));
}
// ルール6: 一般会員、5000円未満、通常配送 → 500円
@Test
void regularMember_lowAmount_normal_500yen() {
var order = new Order(MemberRank.REGULAR, 3000, false);
assertEquals(500, ShippingCalculator.calculate(order));
}
}
(4) 状態遷移テスト(State Transition Testing)
状態遷移テストは、システムの状態と、イベントによる状態遷移を状態遷移図や状態遷移表で整理し、すべての遷移パスをテストする技法である。ステートマシンで表現できるロジック(注文ステータス、認証フロー等)に有効である。
例:注文ステータスの状態遷移
[作成] --支払い完了--> [支払済] --発送--> [発送済] --受取確認--> [完了]
| | |
+---キャンセル--> [キャンセル済] +--返品--> [返品処理中]
// 状態遷移テストの例
class OrderStateMachineTest {
@Test
void shouldTransitionFromCreatedToPaid() {
var order = new Order(OrderStatus.CREATED);
order.pay();
assertEquals(OrderStatus.PAID, order.getStatus());
}
@Test
void shouldTransitionFromPaidToShipped() {
var order = new Order(OrderStatus.PAID);
order.ship();
assertEquals(OrderStatus.SHIPPED, order.getStatus());
}
@Test
void shouldNotAllowShippingBeforePayment() {
var order = new Order(OrderStatus.CREATED);
assertThrows(IllegalStateException.class, () -> order.ship());
}
@Test
void shouldAllowCancelFromCreated() {
var order = new Order(OrderStatus.CREATED);
order.cancel();
assertEquals(OrderStatus.CANCELLED, order.getStatus());
}
@Test
void shouldNotAllowCancelAfterShipped() {
var order = new Order(OrderStatus.SHIPPED);
assertThrows(IllegalStateException.class, () -> order.cancel());
}
}
(5) ペアワイズテスト(Pairwise Testing / All-Pairs Testing)
ペアワイズテストは、複数のパラメータのすべての2因子間の組み合わせを最小限のテストケースでカバーする技法である。組み合わせ爆発を抑えつつ、高い欠陥検出率を実現できる(多くのバグは2因子の相互作用に起因するという研究に基づく)。
例:ブラウザ × OS × 言語の組み合わせ
全組み合わせ: 3 × 3 × 3 = 27通り → ペアワイズ: 9通りで全ペアをカバー
| テストケース | ブラウザ | OS | 言語 |
|---|---|---|---|
| 1 | Chrome | Windows | 日本語 |
| 2 | Chrome | macOS | 英語 |
| 3 | Chrome | Linux | 中国語 |
| 4 | Firefox | Windows | 英語 |
| 5 | Firefox | macOS | 中国語 |
| 6 | Firefox | Linux | 日本語 |
| 7 | Safari | Windows | 中国語 |
| 8 | Safari | macOS | 日本語 |
| 9 | Safari | Linux | 英語 |
ツール: Microsoft PICT, AllPairs, Jenny など
(6) 原因結果グラフ(Cause-Effect Graphing)
原因結果グラフは、仕様書から**原因(入力条件)と結果(出力・動作)**を抽出し、それらの論理関係をグラフで表現してデシジョンテーブルを導出する技法である。複雑な条件の組み合わせを体系的に分析する場合に有用である。
手順:
- 仕様書から原因(Cause)と結果(Effect)を識別する
- 原因と結果の因果関係を論理記号(AND, OR, NOT)で接続する
- 制約条件(排他、包含、必須等)を追加する
- グラフからデシジョンテーブルを導出する
- デシジョンテーブルからテストケースを作成する
ブラックボックステスト技法のまとめ
| 技法 | 適用場面 | 強み | 弱み |
|---|---|---|---|
| 同値分割法 | 入力範囲が明確な場合 | テストケース数を合理的に削減 | 境界付近のバグを見逃しやすい |
| 境界値分析 | 数値・範囲に関する処理 | バグ検出率が高い | 複数パラメータの組み合わせに弱い |
| デシジョンテーブル | 複数条件の組み合わせ | 条件組み合わせを網羅的にカバー | 条件が多いと表が巨大になる |
| 状態遷移テスト | 状態を持つオブジェクト | 不正な遷移の検出に強い | 状態数が多いと爆発する |
| ペアワイズテスト | パラメータが多い場合 | 組み合わせ爆発を効率的に抑制 | 3因子以上の相互作用を見逃す |
| 原因結果グラフ | 複雑な仕様の分析 | 仕様の曖昧さを発見できる | 作成コストが高い |
2.6.2 ホワイトボックステスト
ホワイトボックステストは、テスト対象の内部構造(ソースコード)を知った上で、コードのパスや分岐を意識してテストケースを設計する手法である。ブラックボックステストでは検出しにくいコード内部の欠陥を発見するのに有効だが、実装変更に弱いという特性を持つ。
(1) 命令網羅(Statement Coverage / C0)
命令網羅は、テスト対象のコード内の**すべての命令文(ステートメント)**が少なくとも1回は実行されることを確認する、最も基本的なカバレッジ基準である。
// テスト対象
public int calculateFee(int age, boolean isMember) {
int fee = 1000; // 文1
if (age < 12) { // 文2
fee = 500; // 文3
}
if (isMember) { // 文4
fee = (int)(fee * 0.8); // 文5
}
return fee; // 文6
}
命令網羅100%を達成するには、文3と文5を通すテストが最低限必要:
@Test
void statementCoverage() {
// テストケース1: age=10, isMember=true → 文1,2,3,4,5,6 をすべて通過
assertEquals(400, calculateFee(10, true));
}
// このテストケース1つだけで命令網羅100%を達成
// しかし age>=12 かつ isMember=false のパスは未テスト
(2) 分岐網羅(Branch Coverage / Decision Coverage / C1)
分岐網羅は、すべての分岐(if/else, switch等)の真偽両方が少なくとも1回はテストされることを確認するカバレッジ基準である。命令網羅より厳密で、実務上よく使われる基準である。
// 分岐網羅100%を達成するテスト
@Test
void branchCoverage_childMember() {
// if (age < 12): TRUE, if (isMember): TRUE
assertEquals(400, calculateFee(10, true));
}
@Test
void branchCoverage_adultNonMember() {
// if (age < 12): FALSE, if (isMember): FALSE
assertEquals(1000, calculateFee(30, false));
}
// 2テストで分岐網羅100%を達成
(3) 条件網羅(Condition Coverage / C2)
条件網羅は、複合条件式の中の個々の条件が真と偽の両方を少なくとも1回取ることを確認するカバレッジ基準である。
// テスト対象
public boolean isEligible(int age, boolean hasLicense) {
if (age >= 18 && hasLicense) { // 条件1: age >= 18, 条件2: hasLicense
return true;
}
return false;
}
// 条件網羅のテスト
@Test
void condition_bothTrue() {
// age >= 18: TRUE, hasLicense: TRUE
assertTrue(isEligible(20, true));
}
@Test
void condition_ageFalse_licenseFalse() {
// age >= 18: FALSE, hasLicense: FALSE
assertFalse(isEligible(15, false));
}
// 各条件が TRUE/FALSE を1回ずつ取っているので条件網羅100%
// ただし age >= 18: TRUE, hasLicense: FALSE のパスは未テスト
(4) 条件/判定網羅(Condition/Decision Coverage / MC/DC)
MC/DC(Modified Condition/Decision Coverage)は、各条件が判定結果に独立して影響することを示すカバレッジ基準である。航空宇宙(DO-178C)などの安全性が重要なシステムで要求される厳密な基準である。
// MC/DC のテスト例: if (A && B)
// 条件Aが結果に独立して影響することを示すペア:
// A=TRUE, B=TRUE → TRUE
// A=FALSE, B=TRUE → FALSE ← Aだけが変化して結果が変わる
// 条件Bが結果に独立して影響することを示すペア:
// A=TRUE, B=TRUE → TRUE
// A=TRUE, B=FALSE → FALSE ← Bだけが変化して結果が変わる
@Test void mcdc_TT() { assertTrue(isEligible(20, true)); }
@Test void mcdc_FT() { assertFalse(isEligible(15, true)); } // Aの影響
@Test void mcdc_TF() { assertFalse(isEligible(20, false)); } // Bの影響
// 3テストでMC/DC 100%を達成(N条件ではN+1テストケースが必要)
(5) パス網羅(Path Coverage)
パス網羅は、テスト対象のコード内のすべての実行パスを少なくとも1回通ることを確認するカバレッジ基準である。最も厳密だが、ループがあるとパス数が爆発するため、実用上は制限を設けて使用する。
// 前述の calculateFee の場合、4つのパスがある:
// パス1: age < 12 = TRUE, isMember = TRUE → fee=500*0.8=400
// パス2: age < 12 = TRUE, isMember = FALSE → fee=500
// パス3: age < 12 = FALSE, isMember = TRUE → fee=1000*0.8=800
// パス4: age < 12 = FALSE, isMember = FALSE → fee=1000
@Test void path1_childMember() { assertEquals(400, calculateFee(10, true)); }
@Test void path2_childNonMember() { assertEquals(500, calculateFee(10, false)); }
@Test void path3_adultMember() { assertEquals(800, calculateFee(30, true)); }
@Test void path4_adultNonMember() { assertEquals(1000, calculateFee(30, false)); }
(6) 制御フローテスト(Control Flow Testing)
制御フローテストは、プログラムの**制御フローグラフ(CFG)**を作成し、グラフ上のパスに基づいてテストケースを設計する技法である。ノード(基本ブロック)とエッジ(分岐)からなるグラフを分析し、独立パス数(サイクロマティック複雑度)を算出してテストケース数の目安とする。
サイクロマティック複雑度の計算:
- V(G) = E - N + 2P(E: エッジ数, N: ノード数, P: 連結成分数)
- または V(G) = 判定ノード数 + 1
// サイクロマティック複雑度 = 3(2つのif文 + 1)
// → 最低3つの独立パスをテストすべき
public String classify(int score) {
if (score >= 80) { // 判定ノード1
return "A";
} else if (score >= 60) { // 判定ノード2
return "B";
} else {
return "C";
}
}
(7) データフローテスト(Data Flow Testing)
データフローテストは、変数の**定義(def)と使用(use)**のペアに着目してテストケースを設計する技法である。変数が定義されてから使用されるまでのパスを分析し、定義−使用ペア(def-use pair)をカバーするテストを作成する。
カバレッジ基準:
- All-Defs: すべての変数定義に対して、その使用に至るパスを少なくとも1つテスト
- All-Uses: すべての定義−使用ペアに対するパスを少なくとも1つテスト
- All-DU-Paths: すべての定義−使用ペアに対するすべてのパスをテスト
// データフロー分析の例
public int process(int x, int y) {
int result = 0; // def(result) @ line 1
if (x > 0) {
result = x + y; // def(result) @ line 3
} else {
result = x - y; // def(result) @ line 5
}
return result; // use(result) @ line 7
}
// def-use ペア:
// (line 3, line 7): x > 0 が true のパス
// (line 5, line 7): x > 0 が false のパス
// All-Uses カバレッジには両方のパスが必要
ホワイトボックステスト技法のまとめ
| カバレッジ基準 | 略称 | 厳密さ | 実用性 | 主な用途 |
|---|---|---|---|---|
| 命令網羅 | C0 | ★☆☆☆☆ | 高い | 最低限の品質保証 |
| 分岐網羅 | C1 | ★★☆☆☆ | 高い | 業界標準的な目標値 |
| 条件網羅 | C2 | ★★★☆☆ | 中程度 | 複合条件の検証 |
| MC/DC | - | ★★★★☆ | 低い | 航空宇宙・安全系 |
| パス網羅 | - | ★★★★★ | 低い | 小規模・クリティカルなコード |
| 制御フロー | - | ★★★☆☆ | 中程度 | 複雑度分析と組み合わせ |
| データフロー | - | ★★★☆☆ | 低い | 変数の誤用検出 |
2.6.3 ブラックボックスとホワイトボックスの使い分け
┌─────────────────────────────────────────────────────────┐
│ テストケース設計の推奨アプローチ │
├─────────────────────────────────────────────────────────┤
│ Step 1: ブラックボックステストで基本のテストケースを作成 │
│ (同値分割 + 境界値分析を基本とする) │
│ ↓ │
│ Step 2: ホワイトボックステストでカバレッジを確認 │
│ (分岐網羅C1を目標にする) │
│ ↓ │
│ Step 3: カバレッジ不足の箇所にテストケースを追加 │
│ (未通過のパスや条件を特定してテスト追加) │
│ ↓ │
│ Step 4: ビジネスロジックの複雑な部分にデシジョンテーブル │
│ や状態遷移テストを追加 │
└─────────────────────────────────────────────────────────┘
一般的に、ユニットテストではブラックボックステストを基本とし、カバレッジの不足を補う目的でホワイトボックステストを追加するアプローチが推奨される。テストが実装の詳細に過度に依存すると、リファクタリングのたびにテストの修正が必要になり、テストの保守コストが増大する。
ブラックボックステストとホワイトボックステストの両方を組み合わせたグレーボックステストというアプローチも存在する。これは内部構造の知識を活かしつつも、テスト自体は外部インターフェースを通じて行うもので、実務上多くのユニットテストはこのアプローチに近い形で行われている。
第3章: テストダブル
3.1 テストダブルの分類
テストダブル(Test Double)とは、テスト時に本物のオブジェクトの代わりに使用される代替オブジェクトの総称である。Gerard Meszaros が「xUnit Test Patterns」で体系化した分類に基づき、テストダブルは5つの種類に分けられる。
Dummy(ダミー)
ダミーは、メソッドのシグネチャを満たすためだけに渡されるオブジェクトである。実際には使用されないが、パラメータとして要求されるため渡す必要がある。
// Dummy の例
@Test
void testCreateUser() {
// Logger は実際には使われないが、コンストラクタが要求する
Logger dummyLogger = new Logger() {
@Override public void log(String message) { /* 何もしない */ }
};
UserService service = new UserService(userRepository, dummyLogger);
User user = service.createUser("Alice", "alice@example.com");
assertNotNull(user);
}
Stub(スタブ)
スタブは、テスト中に呼び出されたときに事前に設定された値を返すオブジェクトである。テスト対象が依存するオブジェクトの戻り値を制御するために使用する。
// Stub の例
@Test
void testGetUserName() {
// UserRepository のスタブ: findById が呼ばれたら固定の User を返す
UserRepository stubRepository = mock(UserRepository.class);
when(stubRepository.findById(1L)).thenReturn(Optional.of(new User(1L, "Alice")));
UserService service = new UserService(stubRepository);
String name = service.getUserName(1L);
assertEquals("Alice", name);
}
# Python でのスタブ
def test_get_user_name(mocker):
# UserRepository のスタブ
mock_repo = mocker.Mock()
mock_repo.find_by_id.return_value = User(1, "Alice")
service = UserService(mock_repo)
name = service.get_user_name(1)
assert name == "Alice"
Spy(スパイ)
スパイは、呼び出しの記録を保持するオブジェクトである。テスト後に、どのメソッドがどの引数で何回呼ばれたかを検証できる。本物のオブジェクトの振る舞いを保持しつつ、呼び出しを記録する場合もある。
// Spy の例
@Test
void testSendNotification() {
// NotificationService のスパイ
NotificationService spyNotification = spy(new NotificationServiceImpl());
OrderService service = new OrderService(orderRepo, spyNotification);
service.completeOrder(orderId);
// 通知が1回送られたことを検証
verify(spyNotification, times(1)).sendEmail(anyString(), anyString());
}
Mock(モック)
モックは、期待される呼び出しを事前に設定し、その期待が満たされたかを検証するオブジェクトである。スタブとスパイの機能を兼ね備え、さらに呼び出しの検証をテストの中心に据える。
// Mock の例
@Test
void testProcessPayment() {
PaymentGateway mockGateway = mock(PaymentGateway.class);
when(mockGateway.charge(1000.0)).thenReturn(true);
PaymentService service = new PaymentService(mockGateway);
boolean result = service.processPayment(1000.0);
assertTrue(result);
// charge が正確に1回、1000.0 で呼ばれたことを検証
verify(mockGateway, times(1)).charge(1000.0);
// refund が呼ばれなかったことを検証
verify(mockGateway, never()).refund(anyDouble());
}
Fake(フェイク)
フェイクは、本物のオブジェクトの簡易版実装である。本物と同じインターフェースを持つが、実装はテスト用に簡略化されている。インメモリデータベース、ローカルファイルベースのメッセージキューなどが典型的な例である。
// Fake の例: インメモリリポジトリ
public class FakeUserRepository implements UserRepository {
private final Map<Long, User> users = new HashMap<>();
private long nextId = 1;
@Override
public User save(User user) {
if (user.getId() == null) {
user.setId(nextId++);
}
users.put(user.getId(), user);
return user;
}
@Override
public Optional<User> findById(Long id) {
return Optional.ofNullable(users.get(id));
}
@Override
public List<User> findAll() {
return new ArrayList<>(users.values());
}
@Override
public void deleteById(Long id) {
users.remove(id);
}
}
@Test
void testUserCRUD() {
UserRepository fakeRepo = new FakeUserRepository();
UserService service = new UserService(fakeRepo);
User created = service.createUser("Alice", "alice@example.com");
assertNotNull(created.getId());
User found = service.getUser(created.getId());
assertEquals("Alice", found.getName());
}
3.2 Martin Fowler の「Mocks Aren't Stubs」
2005年に Martin Fowler が発表した「Mocks Aren't Stubs」は、テストダブルの理解において重要な論文である。この論文の主要なポイントは以下の通りである。
状態検証 vs 振る舞い検証
- 状態検証(State Verification): テスト対象の操作後、オブジェクトの状態を検証する。スタブやフェイクと組み合わせて使用する。
- 振る舞い検証(Behavior Verification): テスト対象がコラボレーターに対してどのような呼び出しを行ったかを検証する。モックと組み合わせて使用する。
// 状態検証の例
@Test
void testAddToCart_stateVerification() {
FakeCartRepository fakeRepo = new FakeCartRepository();
CartService service = new CartService(fakeRepo);
service.addItem("user-1", new Item("laptop", 100000));
// 状態を検証: カートにアイテムが追加されたか
Cart cart = fakeRepo.findByUserId("user-1");
assertEquals(1, cart.getItems().size());
assertEquals("laptop", cart.getItems().get(0).getName());
}
// 振る舞い検証の例
@Test
void testAddToCart_behaviorVerification() {
CartRepository mockRepo = mock(CartRepository.class);
CartService service = new CartService(mockRepo);
service.addItem("user-1", new Item("laptop", 100000));
// 振る舞いを検証: save が呼ばれたか
verify(mockRepo).save(argThat(cart ->
cart.getUserId().equals("user-1") &&
cart.getItems().size() == 1
));
}
Fowler は、状態検証を基本とし、振る舞い検証は必要な場合にのみ使用することを推奨している。
3.3 いつモックを使い、いつ使わないか
モックを使うべき場面
- 外部サービス: HTTP API、メール送信、SMS送信など
- データベース: 特に書き込み操作
- ファイルシステム: ファイルの読み書き
- 時刻: 現在時刻に依存する処理
- 乱数: ランダムな値に依存する処理
- 非決定的な処理: ネットワークの遅延など
モックを使うべきでない場面
- 値オブジェクト: 不変で副作用のないオブジェクト
- データ構造: List、Map などの標準コレクション
- シンプルなヘルパー: ユーティリティ関数やPOJO
- テスト対象自体: SUT 自体をモックにしてはならない
3.4 過剰なモックの問題
過剰なモックは、テストを脆くし、保守コストを増大させる。
// 過剰なモックの例(アンチパターン)
@Test
void testCalculateTotal_overMocked() {
// 単純な計算なのに、すべてをモックしている
PriceCalculator mockCalculator = mock(PriceCalculator.class);
TaxCalculator mockTax = mock(TaxCalculator.class);
DiscountCalculator mockDiscount = mock(DiscountCalculator.class);
when(mockCalculator.getBasePrice("item-1")).thenReturn(1000.0);
when(mockTax.calculate(1000.0)).thenReturn(100.0);
when(mockDiscount.calculate(1000.0, "SALE10")).thenReturn(100.0);
OrderCalculator sut = new OrderCalculator(mockCalculator, mockTax, mockDiscount);
double total = sut.calculateTotal("item-1", "SALE10");
assertEquals(1000.0, total); // 1000 + 100 - 100
// 実装の詳細に強く依存している
verify(mockCalculator).getBasePrice("item-1");
verify(mockTax).calculate(1000.0);
verify(mockDiscount).calculate(1000.0, "SALE10");
}
// 改善例: 本物のオブジェクトを使用
@Test
void testCalculateTotal_better() {
PriceCalculator calculator = new PriceCalculator(priceTable);
TaxCalculator tax = new TaxCalculator(0.10);
DiscountCalculator discount = new DiscountCalculator();
OrderCalculator sut = new OrderCalculator(calculator, tax, discount);
double total = sut.calculateTotal("item-1", "SALE10");
assertEquals(1000.0, total);
}
Vladimir Khorikov は「Unit Testing: Principles, Practices, and Patterns」の中で、モックの過剰使用について警告し、「テストでモックを使うのは、管理外の依存関係(外部システム)に対してのみにすべき」と述べている。
第4章: Java でのユニットテスト
4.1 JUnit 5 の基本
JUnit 5(JUnit Jupiter)は、Java エコシステムで最も広く使用されているテストフレームワークである。JUnit 5 は3つのモジュールで構成される:
- JUnit Platform: テストの実行基盤
- JUnit Jupiter: テスト記述用の新しい API
- JUnit Vintage: JUnit 3/4 との後方互換性
基本的なアノテーション
import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.*;
class UserServiceTest {
private UserService userService;
private UserRepository userRepository;
@BeforeAll
static void setUpAll() {
// テストクラス全体で1回だけ実行(static メソッド)
System.out.println("テスト開始");
}
@BeforeEach
void setUp() {
// 各テストメソッドの前に実行
userRepository = new FakeUserRepository();
userService = new UserService(userRepository);
}
@Test
@DisplayName("ユーザーを正常に作成できる")
void shouldCreateUser() {
User user = userService.createUser("Alice", "alice@example.com");
assertNotNull(user);
assertNotNull(user.getId());
assertEquals("Alice", user.getName());
assertEquals("alice@example.com", user.getEmail());
}
@Test
@DisplayName("重複したメールアドレスで例外が発生する")
void shouldThrowExceptionForDuplicateEmail() {
userService.createUser("Alice", "alice@example.com");
IllegalArgumentException exception = assertThrows(
IllegalArgumentException.class,
() -> userService.createUser("Bob", "alice@example.com")
);
assertEquals("Email already exists", exception.getMessage());
}
@Test
@DisplayName("存在しないユーザーを検索するとEmptyを返す")
void shouldReturnEmptyForNonExistentUser() {
Optional<User> result = userService.findById(999L);
assertTrue(result.isEmpty());
}
@Nested
@DisplayName("ユーザー更新のテスト")
class UpdateUserTest {
private User existingUser;
@BeforeEach
void setUp() {
existingUser = userService.createUser("Alice", "alice@example.com");
}
@Test
@DisplayName("ユーザー名を更新できる")
void shouldUpdateUserName() {
userService.updateName(existingUser.getId(), "Alice Smith");
User updated = userService.findById(existingUser.getId()).orElseThrow();
assertEquals("Alice Smith", updated.getName());
}
}
@ParameterizedTest
@ValueSource(strings = {"", " ", " "})
@DisplayName("空白の名前で例外が発生する")
void shouldRejectBlankNames(String blankName) {
assertThrows(
IllegalArgumentException.class,
() -> userService.createUser(blankName, "test@example.com")
);
}
@ParameterizedTest
@CsvSource({
"alice@example.com, true",
"invalid-email, false",
"bob@, false",
"@example.com, false"
})
@DisplayName("メールアドレスのバリデーション")
void shouldValidateEmail(String email, boolean expected) {
assertEquals(expected, userService.isValidEmail(email));
}
@AfterEach
void tearDown() {
// 各テストメソッドの後に実行
}
@AfterAll
static void tearDownAll() {
// テストクラス全体で1回だけ実行(static メソッド)
System.out.println("テスト終了");
}
}
JUnit 5 のアサーション
// 基本的なアサーション
assertEquals(expected, actual);
assertNotEquals(unexpected, actual);
assertTrue(condition);
assertFalse(condition);
assertNull(object);
assertNotNull(object);
assertSame(expected, actual); // 参照の同一性
assertNotSame(unexpected, actual);
// 例外のアサーション
assertThrows(IllegalArgumentException.class, () -> {
service.doSomething(invalidInput);
});
// タイムアウトのアサーション
assertTimeout(Duration.ofSeconds(2), () -> {
service.longRunningOperation();
});
// グループアサーション(すべて実行される)
assertAll("user properties",
() -> assertEquals("Alice", user.getName()),
() -> assertEquals("alice@example.com", user.getEmail()),
() -> assertNotNull(user.getId())
);
4.2 Mockito によるモック
Mockito は Java で最も広く使われているモックフレームワークである。
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import static org.mockito.Mockito.*;
import static org.junit.jupiter.api.Assertions.*;
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
@Mock
private OrderRepository orderRepository;
@Mock
private PaymentGateway paymentGateway;
@Mock
private NotificationService notificationService;
@InjectMocks
private OrderService orderService;
@Test
void shouldCreateOrderSuccessfully() {
// Arrange
OrderRequest request = new OrderRequest("item-1", 2, 5000);
when(paymentGateway.charge(10000.0)).thenReturn(PaymentResult.success("txn-123"));
when(orderRepository.save(any(Order.class))).thenAnswer(invocation -> {
Order order = invocation.getArgument(0);
order.setId(1L);
return order;
});
// Act
Order result = orderService.createOrder(request);
// Assert
assertNotNull(result);
assertEquals(1L, result.getId());
assertEquals(10000.0, result.getTotalAmount());
// 振る舞いの検証
verify(paymentGateway).charge(10000.0);
verify(orderRepository).save(any(Order.class));
verify(notificationService).sendOrderConfirmation(any(Order.class));
}
@Test
void shouldNotSaveOrderWhenPaymentFails() {
// Arrange
OrderRequest request = new OrderRequest("item-1", 2, 5000);
when(paymentGateway.charge(10000.0))
.thenReturn(PaymentResult.failure("Insufficient funds"));
// Act & Assert
assertThrows(PaymentFailedException.class, () -> {
orderService.createOrder(request);
});
// save が呼ばれなかったことを検証
verify(orderRepository, never()).save(any(Order.class));
verify(notificationService, never()).sendOrderConfirmation(any(Order.class));
}
@Test
void shouldRetryPaymentOnTransientError() {
// Arrange
OrderRequest request = new OrderRequest("item-1", 1, 3000);
when(paymentGateway.charge(3000.0))
.thenThrow(new TransientException("Network error")) // 1回目: 失敗
.thenReturn(PaymentResult.success("txn-456")); // 2回目: 成功
when(orderRepository.save(any(Order.class))).thenAnswer(invocation -> {
Order order = invocation.getArgument(0);
order.setId(2L);
return order;
});
// Act
Order result = orderService.createOrder(request);
// Assert
assertNotNull(result);
verify(paymentGateway, times(2)).charge(3000.0); // 2回呼ばれた
}
@Test
void shouldCaptureNotificationDetails() {
// ArgumentCaptor を使った詳細な引数検証
ArgumentCaptor<Order> orderCaptor = ArgumentCaptor.forClass(Order.class);
OrderRequest request = new OrderRequest("item-1", 1, 3000);
when(paymentGateway.charge(anyDouble())).thenReturn(PaymentResult.success("txn-789"));
when(orderRepository.save(any(Order.class))).thenAnswer(invocation -> {
Order order = invocation.getArgument(0);
order.setId(3L);
return order;
});
orderService.createOrder(request);
verify(notificationService).sendOrderConfirmation(orderCaptor.capture());
Order capturedOrder = orderCaptor.getValue();
assertEquals("item-1", capturedOrder.getItemId());
assertEquals(3000.0, capturedOrder.getTotalAmount());
}
}
4.3 AssertJ によるアサーション
AssertJ は流暢な(fluent)API を提供するアサーションライブラリで、JUnit 標準のアサーションよりも読みやすい。
import static org.assertj.core.api.Assertions.*;
@Test
void demonstrateAssertJ() {
// 文字列アサーション
assertThat("Hello World")
.startsWith("Hello")
.endsWith("World")
.contains("lo Wo")
.hasSize(11);
// 数値アサーション
assertThat(3.14)
.isCloseTo(Math.PI, within(0.01))
.isGreaterThan(3.0)
.isLessThan(4.0);
// コレクションアサーション
List<User> users = List.of(
new User("Alice", 30),
new User("Bob", 25),
new User("Charlie", 35)
);
assertThat(users)
.hasSize(3)
.extracting(User::getName)
.containsExactly("Alice", "Bob", "Charlie");
assertThat(users)
.filteredOn(user -> user.getAge() >= 30)
.hasSize(2)
.extracting(User::getName)
.containsExactlyInAnyOrder("Alice", "Charlie");
// 例外アサーション
assertThatThrownBy(() -> service.process(null))
.isInstanceOf(NullPointerException.class)
.hasMessageContaining("must not be null");
// Optional アサーション
Optional<User> found = service.findByName("Alice");
assertThat(found)
.isPresent()
.get()
.extracting(User::getName)
.isEqualTo("Alice");
}
4.4 Maven / Gradle でのテスト設定
Maven (pom.xml)
<dependencies>
<!-- JUnit 5 -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.2</version>
<scope>test</scope>
</dependency>
<!-- Mockito -->
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<version>5.11.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-junit-jupiter</artifactId>
<version>5.11.0</version>
<scope>test</scope>
</dependency>
<!-- AssertJ -->
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.25.3</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.2.5</version>
</plugin>
</plugins>
</build>
Gradle (build.gradle.kts)
dependencies {
testImplementation("org.junit.jupiter:junit-jupiter:5.10.2")
testImplementation("org.mockito:mockito-core:5.11.0")
testImplementation("org.mockito:mockito-junit-jupiter:5.11.0")
testImplementation("org.assertj:assertj-core:3.25.3")
}
tasks.test {
useJUnitPlatform()
testLogging {
events("passed", "skipped", "failed")
showStandardStreams = true
}
}
4.5 Spring Boot でのユニットテスト
// Spring Boot でのサービス層ユニットテスト
@ExtendWith(MockitoExtension.class)
class ProductServiceTest {
@Mock
private ProductRepository productRepository;
@Mock
private PricingService pricingService;
@InjectMocks
private ProductService productService;
@Test
void shouldReturnProductWithCalculatedPrice() {
Product product = new Product(1L, "Laptop", "Electronics");
when(productRepository.findById(1L)).thenReturn(Optional.of(product));
when(pricingService.calculatePrice(product)).thenReturn(new BigDecimal("99800"));
ProductDTO result = productService.getProduct(1L);
assertThat(result.getName()).isEqualTo("Laptop");
assertThat(result.getPrice()).isEqualByComparingTo(new BigDecimal("99800"));
}
}
// Spring Boot でのコントローラ層ユニットテスト(WebMvcTest)
@WebMvcTest(ProductController.class)
class ProductControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private ProductService productService;
@Test
void shouldReturnProduct() throws Exception {
ProductDTO dto = new ProductDTO(1L, "Laptop", new BigDecimal("99800"));
when(productService.getProduct(1L)).thenReturn(dto);
mockMvc.perform(get("/api/products/1")
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.name").value("Laptop"))
.andExpect(jsonPath("$.price").value(99800));
}
@Test
void shouldReturn404WhenProductNotFound() throws Exception {
when(productService.getProduct(999L))
.thenThrow(new ProductNotFoundException(999L));
mockMvc.perform(get("/api/products/999"))
.andExpect(status().isNotFound());
}
@Test
void shouldCreateProduct() throws Exception {
CreateProductRequest request = new CreateProductRequest("Laptop", "Electronics");
ProductDTO created = new ProductDTO(1L, "Laptop", new BigDecimal("99800"));
when(productService.createProduct(any())).thenReturn(created);
mockMvc.perform(post("/api/products")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"name": "Laptop",
"category": "Electronics"
}
"""))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.id").value(1))
.andExpect(jsonPath("$.name").value("Laptop"));
}
}
第5章: Python でのユニットテスト
5.1 unittest モジュール
Python の標準ライブラリに含まれる unittest モジュールは、JUnit にインスパイアされたテストフレームワークである。
import unittest
from datetime import datetime, timedelta
class User:
def __init__(self, name: str, email: str):
if not name or not name.strip():
raise ValueError("Name must not be blank")
if "@" not in email:
raise ValueError("Invalid email format")
self.name = name
self.email = email
self.created_at = datetime.now()
self.is_active = True
def deactivate(self):
self.is_active = False
def __repr__(self):
return f"User(name={self.name!r}, email={self.email!r})"
class TestUser(unittest.TestCase):
"""User クラスのユニットテスト"""
def setUp(self):
"""各テストの前に実行"""
self.user = User("Alice", "alice@example.com")
def test_create_user(self):
"""ユーザーを正常に作成できる"""
self.assertEqual(self.user.name, "Alice")
self.assertEqual(self.user.email, "alice@example.com")
self.assertTrue(self.user.is_active)
def test_deactivate_user(self):
"""ユーザーを無効化できる"""
self.user.deactivate()
self.assertFalse(self.user.is_active)
def test_blank_name_raises_error(self):
"""空白の名前で ValueError が発生する"""
with self.assertRaises(ValueError) as context:
User("", "test@example.com")
self.assertIn("must not be blank", str(context.exception))
def test_invalid_email_raises_error(self):
"""無効なメールで ValueError が発生する"""
with self.assertRaises(ValueError):
User("Bob", "invalid-email")
if __name__ == "__main__":
unittest.main()
5.2 pytest の基本と高度な使い方
pytest は Python で最も人気のあるテストフレームワークで、シンプルな構文と強力な機能を提供する。
基本的な使い方
# test_calculator.py
import pytest
class Calculator:
def add(self, a: float, b: float) -> float:
return a + b
def subtract(self, a: float, b: float) -> float:
return a - b
def multiply(self, a: float, b: float) -> float:
return a * b
def divide(self, a: float, b: float) -> float:
if b == 0:
raise ZeroDivisionError("Cannot divide by zero")
return a / b
class TestCalculator:
"""Calculator のテスト"""
def setup_method(self):
self.calc = Calculator()
def test_add(self):
assert self.calc.add(2, 3) == 5
def test_add_negative(self):
assert self.calc.add(-1, -2) == -3
def test_add_float(self):
result = self.calc.add(0.1, 0.2)
assert result == pytest.approx(0.3)
def test_divide(self):
assert self.calc.divide(10, 3) == pytest.approx(3.333, rel=1e-3)
def test_divide_by_zero(self):
with pytest.raises(ZeroDivisionError, match="Cannot divide by zero"):
self.calc.divide(10, 0)
フィクスチャ(@pytest.fixture)
フィクスチャは pytest の最も強力な機能の一つで、テストのセットアップとティアダウンを柔軟に管理する。
# conftest.py - 共有フィクスチャの定義
import pytest
from unittest.mock import MagicMock
from myapp.models import User, Product
from myapp.services import UserService, ProductService
from myapp.repositories import UserRepository, ProductRepository
@pytest.fixture
def user_repository():
"""インメモリのユーザーリポジトリ"""
repo = FakeUserRepository()
return repo
@pytest.fixture
def user_service(user_repository):
"""UserService のインスタンス(FakeRepository使用)"""
return UserService(user_repository)
@pytest.fixture
def sample_user(user_service):
"""テスト用のサンプルユーザー"""
return user_service.create_user("Alice", "alice@example.com")
@pytest.fixture
def mock_payment_gateway():
"""PaymentGateway のモック"""
gateway = MagicMock()
gateway.charge.return_value = {"status": "success", "transaction_id": "txn-123"}
return gateway
@pytest.fixture(autouse=True)
def reset_database(db_connection):
"""各テスト後にデータベースをリセット(autouse=True で自動適用)"""
yield
db_connection.rollback()
# スコープ付きフィクスチャ
@pytest.fixture(scope="module")
def database_connection():
"""モジュール内で共有するデータベース接続"""
conn = create_test_database()
yield conn
conn.close()
@pytest.fixture(scope="session")
def docker_compose():
"""テストセッション全体で共有する Docker 環境"""
compose = DockerCompose("docker-compose.test.yml")
compose.up()
yield compose
compose.down()
# テストでの使用
class TestUserService:
def test_create_user(self, user_service):
user = user_service.create_user("Bob", "bob@example.com")
assert user.name == "Bob"
assert user.email == "bob@example.com"
def test_find_user(self, user_service, sample_user):
found = user_service.find_by_id(sample_user.id)
assert found is not None
assert found.name == "Alice"
def test_delete_user(self, user_service, sample_user):
user_service.delete(sample_user.id)
assert user_service.find_by_id(sample_user.id) is None
5.3 pytest-mock / unittest.mock
from unittest.mock import MagicMock, patch, AsyncMock
import pytest
class EmailService:
def __init__(self, smtp_client, template_engine):
self.smtp_client = smtp_client
self.template_engine = template_engine
def send_welcome_email(self, user):
html = self.template_engine.render("welcome", {"user": user})
self.smtp_client.send(
to=user.email,
subject="Welcome!",
body=html
)
return True
async def send_welcome_email_async(self, user):
html = await self.template_engine.render_async("welcome", {"user": user})
await self.smtp_client.send_async(to=user.email, subject="Welcome!", body=html)
return True
class TestEmailService:
def test_send_welcome_email(self, mocker):
# mocker は pytest-mock が提供するフィクスチャ
mock_smtp = mocker.Mock()
mock_template = mocker.Mock()
mock_template.render.return_value = "<h1>Welcome Alice!</h1>"
service = EmailService(mock_smtp, mock_template)
user = User("Alice", "alice@example.com")
result = service.send_welcome_email(user)
assert result is True
mock_template.render.assert_called_once_with(
"welcome", {"user": user}
)
mock_smtp.send.assert_called_once_with(
to="alice@example.com",
subject="Welcome!",
body="<h1>Welcome Alice!</h1>"
)
@pytest.mark.asyncio
async def test_send_welcome_email_async(self):
"""非同期メソッドのテスト"""
mock_smtp = AsyncMock()
mock_template = AsyncMock()
mock_template.render_async.return_value = "<h1>Welcome!</h1>"
service = EmailService(mock_smtp, mock_template)
user = User("Charlie", "charlie@example.com")
result = await service.send_welcome_email_async(user)
assert result is True
mock_smtp.send_async.assert_awaited_once()
5.4 パラメータ化テスト
import pytest
# @pytest.mark.parametrize を使ったパラメータ化テスト
class TestEmailValidator:
@pytest.mark.parametrize("email,expected", [
("alice@example.com", True),
("bob.smith@company.co.jp", True),
("user+tag@example.org", True),
("invalid-email", False),
("@example.com", False),
("user@", False),
("", False),
("user@.com", False),
("user@exam ple.com", False),
])
def test_validate_email(self, email, expected):
assert validate_email(email) == expected
@pytest.mark.parametrize("password,is_valid,reason", [
("Str0ng!Pass", True, "有効なパスワード"),
("short", False, "8文字未満"),
("nouppercase1!", False, "大文字なし"),
("NOLOWERCASE1!", False, "小文字なし"),
("NoNumbers!!", False, "数字なし"),
("NoSpecial123", False, "特殊文字なし"),
], ids=lambda x: x if isinstance(x, str) and len(x) < 20 else "")
def test_validate_password(self, password, is_valid, reason):
result = validate_password(password)
assert result.is_valid == is_valid, f"Failed for: {reason}"
# 複数のパラメータの組み合わせ
@pytest.mark.parametrize("x", [1, 2, 3])
@pytest.mark.parametrize("y", [10, 20])
def test_multiply(x, y):
assert multiply(x, y) == x * y
# 6つのテストケースが生成される: (1,10), (1,20), (2,10), (2,20), (3,10), (3,20)
5.5 tox/nox による複数環境テスト
tox.ini
[tox]
envlist = py39, py310, py311, py312, lint, type
[testenv]
deps =
pytest>=7.0
pytest-cov
pytest-mock
pytest-asyncio
commands =
pytest {posargs:tests/} -v --cov=myapp --cov-report=term-missing
[testenv:lint]
deps =
ruff
black
commands =
ruff check myapp/ tests/
black --check myapp/ tests/
[testenv:type]
deps =
mypy
types-requests
commands =
mypy myapp/
pyproject.toml
# pyproject.toml
[tool.pytest.ini_options]
minversion = "7.0"
testpaths = ["tests"]
python_files = ["test_*.py", "*_test.py"]
python_classes = ["Test*"]
python_functions = ["test_*"]
addopts = [
"-ra",
"--strict-markers",
"--strict-config",
"-v",
]
markers = [
"slow: marks tests as slow (deselect with '-m \"not slow\"')",
"integration: marks integration tests",
"e2e: marks end-to-end tests",
]
[tool.coverage.run]
source = ["myapp"]
branch = true
[tool.coverage.report]
show_missing = true
fail_under = 80
exclude_lines = [
"pragma: no cover",
"def __repr__",
"if TYPE_CHECKING:",
"raise NotImplementedError",
]
第6章: JavaScript/TypeScript でのユニットテスト
6.1 Jest の基本
Jest は Meta(旧 Facebook)が開発したテストフレームワークで、React エコシステムを中心に広く採用されている。ゼロ設定で動作し、テストランナー、アサーション、モック機能を一体で提供する。
// calculator.test.js
const Calculator = require('./calculator');
describe('Calculator', () => {
let calc;
beforeEach(() => {
calc = new Calculator();
});
describe('add', () => {
test('should add two positive numbers', () => {
expect(calc.add(2, 3)).toBe(5);
});
test('should add negative numbers', () => {
expect(calc.add(-1, -2)).toBe(-3);
});
test('should handle floating point', () => {
expect(calc.add(0.1, 0.2)).toBeCloseTo(0.3);
});
});
describe('divide', () => {
test('should divide two numbers', () => {
expect(calc.divide(10, 3)).toBeCloseTo(3.333, 2);
});
test('should throw error when dividing by zero', () => {
expect(() => calc.divide(10, 0)).toThrow('Division by zero');
});
});
describe('fetchAndAdd', () => {
test('should fetch value and add', async () => {
global.fetch = jest.fn().mockResolvedValue({
json: jest.fn().mockResolvedValue({ value: 10 }),
});
const result = await calc.fetchAndAdd('https://api.example.com/value', 5);
expect(result).toBe(15);
expect(fetch).toHaveBeenCalledWith('https://api.example.com/value');
});
});
});
Jest のマッチャー
// 基本マッチャー
expect(value).toBe(expected); // === 厳密等値
expect(value).toEqual(expected); // 深い等値比較
expect(value).toStrictEqual(expected); // より厳密な等値比較
// 真偽値
expect(value).toBeTruthy();
expect(value).toBeFalsy();
expect(value).toBeNull();
expect(value).toBeUndefined();
expect(value).toBeDefined();
// 数値
expect(value).toBeGreaterThan(3);
expect(value).toBeCloseTo(0.3, 5);
// 文字列
expect(value).toMatch(/regex/);
expect(value).toContain('substring');
// 配列・オブジェクト
expect(array).toContain(item);
expect(array).toHaveLength(3);
expect(object).toHaveProperty('key', 'value');
expect(object).toMatchObject({ key: 'value' });
// 例外
expect(() => fn()).toThrow();
expect(() => fn()).toThrow('error message');
// 非同期
await expect(asyncFn()).resolves.toBe(value);
await expect(asyncFn()).rejects.toThrow('error');
6.2 モジュールモック(jest.mock)
// userService.test.js
const axios = require('axios');
const UserService = require('./userService');
const { sendEmail } = require('./emailService');
jest.mock('axios');
jest.mock('./emailService');
describe('UserService', () => {
let service;
beforeEach(() => {
service = new UserService();
jest.clearAllMocks();
});
describe('createUser', () => {
test('should create user and send welcome email', async () => {
const mockUser = { id: 1, name: 'Alice', email: 'alice@example.com' };
axios.post.mockResolvedValue({ data: mockUser });
sendEmail.mockResolvedValue(true);
const result = await service.createUser('Alice', 'alice@example.com');
expect(result).toEqual(mockUser);
expect(axios.post).toHaveBeenCalledWith('/api/users', {
name: 'Alice',
email: 'alice@example.com',
});
expect(sendEmail).toHaveBeenCalledWith(
'alice@example.com',
'Welcome!',
'Hello Alice'
);
});
});
});
6.3 Vitest
Vitest は Vite ベースの高速テストフレームワークで、Jest 互換の API を提供する。
// userService.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { UserService, UserRepository, User } from './userService';
describe('UserService', () => {
let service: UserService;
let mockRepository: UserRepository;
beforeEach(() => {
mockRepository = {
save: vi.fn(),
findById: vi.fn(),
findAll: vi.fn(),
};
service = new UserService(mockRepository);
});
describe('createUser', () => {
it('should create a user successfully', async () => {
const savedUser: User = { id: 1, name: 'Alice', email: 'alice@example.com' };
vi.mocked(mockRepository.save).mockResolvedValue(savedUser);
const result = await service.createUser('Alice', 'alice@example.com');
expect(result).toEqual(savedUser);
expect(mockRepository.save).toHaveBeenCalledWith({
name: 'Alice',
email: 'alice@example.com',
});
});
it('should throw error for blank name', async () => {
await expect(service.createUser('', 'test@example.com'))
.rejects.toThrow('Name must not be blank');
});
});
});
6.4 React Testing Library
// UserProfile.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
import { UserProfile } from './UserProfile';
describe('UserProfile', () => {
const mockUser = { id: 1, name: 'Alice', email: 'alice@example.com' };
test('should show loading state initially', () => {
const fetchUser = jest.fn().mockReturnValue(new Promise(() => {}));
render(<UserProfile userId={1} fetchUser={fetchUser} />);
expect(screen.getByRole('status')).toHaveTextContent('Loading...');
});
test('should display user info after loading', async () => {
const fetchUser = jest.fn().mockResolvedValue(mockUser);
render(<UserProfile userId={1} fetchUser={fetchUser} />);
await waitFor(() => {
expect(screen.getByText('Alice')).toBeInTheDocument();
});
expect(screen.getByText('alice@example.com')).toBeInTheDocument();
});
test('should display error when fetch fails', async () => {
const fetchUser = jest.fn().mockRejectedValue(new Error('Not found'));
render(<UserProfile userId={999} fetchUser={fetchUser} />);
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent('Error: Not found');
});
});
});
6.5 設定例
jest.config.js
module.exports = {
preset: 'ts-jest',
testEnvironment: 'jsdom',
roots: ['<rootDir>/src'],
testMatch: [
'**/__tests__/**/*.+(ts|tsx|js)',
'**/?(*.)+(spec|test).+(ts|tsx|js)',
],
collectCoverageFrom: [
'src/**/*.{ts,tsx}',
'!src/**/*.d.ts',
'!src/index.ts',
],
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
},
};
vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['src/**/*.{test,spec}.{js,ts}'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
thresholds: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
},
},
});
第7章: Go でのユニットテスト
7.1 testing パッケージ
Go の標準ライブラリに含まれる testing パッケージは、シンプルで強力なテスト機能を提供する。Go のテストは _test.go サフィックスのファイルに記述し、go test コマンドで実行する。
// calculator_test.go
package calculator
import (
"math"
"testing"
)
func TestAdd(t *testing.T) {
result := Add(2, 3)
if result != 5 {
t.Errorf("Add(2, 3) = %f; want 5", result)
}
}
func TestDivide(t *testing.T) {
result, err := Divide(10, 3)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
expected := 3.3333333
if math.Abs(result-expected) > 0.001 {
t.Errorf("Divide(10, 3) = %f; want %f", result, expected)
}
}
func TestDivideByZero(t *testing.T) {
_, err := Divide(10, 0)
if err == nil {
t.Fatal("expected error for division by zero")
}
if err != ErrDivisionByZero {
t.Errorf("got error %v; want %v", err, ErrDivisionByZero)
}
}
7.2 テーブル駆動テスト
テーブル駆動テスト(Table-Driven Tests)は Go における標準的なテストパターンである。
func TestAdd_TableDriven(t *testing.T) {
tests := []struct {
name string
a, b float64
expected float64
}{
{"positive numbers", 2, 3, 5},
{"negative numbers", -1, -2, -3},
{"zero", 0, 0, 0},
{"positive and negative", 5, -3, 2},
{"floating point", 0.1, 0.2, 0.3},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := Add(tt.a, tt.b)
if math.Abs(result-tt.expected) > 1e-9 {
t.Errorf("Add(%f, %f) = %f; want %f",
tt.a, tt.b, result, tt.expected)
}
})
}
}
7.3 testify ライブラリ
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestUserService_CreateUser(t *testing.T) {
repo := NewFakeUserRepository()
svc := NewUserService(repo)
user, err := svc.CreateUser("Alice", "alice@example.com")
require.NoError(t, err)
require.NotNil(t, user)
assert.Equal(t, "Alice", user.Name)
assert.Equal(t, "alice@example.com", user.Email)
assert.NotZero(t, user.ID)
assert.True(t, user.IsActive)
}
7.4 httptest パッケージ
func TestGetUser(t *testing.T) {
fakeService := &FakeUserService{
users: map[int64]*User{
1: {ID: 1, Name: "Alice", Email: "alice@example.com"},
},
}
handler := &UserHandler{service: fakeService}
req := httptest.NewRequest(http.MethodGet, "/users/1", nil)
req.SetPathValue("id", "1")
rec := httptest.NewRecorder()
handler.GetUser(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
var user User
err := json.NewDecoder(rec.Body).Decode(&user)
require.NoError(t, err)
assert.Equal(t, "Alice", user.Name)
}
7.5 ベンチマークテスト
func BenchmarkAdd(b *testing.B) {
for i := 0; i < b.N; i++ {
Add(2.0, 3.0)
}
}
// 実行: go test -bench=. -benchmem
第8章: テストカバレッジ
8.1 コードカバレッジとは何か
コードカバレッジ(Code Coverage)とは、テストスイートがソースコードのどの程度を実行したかを示す指標である。テストの網羅性を定量的に評価するために使用される。
カバレッジは「テストされているコードの割合」を示すが、「コードが正しくテストされているか」を示すものではない。カバレッジ100%のコードにもバグは存在し得る。
8.2 カバレッジの種類
行カバレッジ(Line Coverage)
テストによって実行された行の割合を示す。最も基本的なカバレッジ指標。
分岐カバレッジ(Branch Coverage)
条件分岐の各分岐(true/false)がテストされた割合を示す。行カバレッジよりも厳密。
条件カバレッジ(Condition Coverage)
複合条件の各部分条件が true/false の両方でテストされた割合。
パスカバレッジ(Path Coverage)
プログラム内のすべての実行パスがテストされた割合。最も厳密だが、組み合わせ爆発により実用的でないことが多い。
8.3 Java: JaCoCo
Maven 設定
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.11</version>
<executions>
<execution>
<id>prepare-agent</id>
<goals><goal>prepare-agent</goal></goals>
</execution>
<execution>
<id>report</id>
<phase>test</phase>
<goals><goal>report</goal></goals>
</execution>
<execution>
<id>check</id>
<goals><goal>check</goal></goals>
<configuration>
<rules>
<rule>
<element>BUNDLE</element>
<limits>
<limit>
<counter>LINE</counter>
<value>COVEREDRATIO</value>
<minimum>0.80</minimum>
</limit>
<limit>
<counter>BRANCH</counter>
<value>COVEREDRATIO</value>
<minimum>0.70</minimum>
</limit>
</limits>
</rule>
</rules>
</configuration>
</execution>
</executions>
</plugin>
8.4 Python: coverage.py / pytest-cov
# 実行
pytest --cov=myapp --cov-report=term-missing --cov-report=html tests/
# pyproject.toml
[tool.coverage.run]
source = ["myapp"]
branch = true
[tool.coverage.report]
show_missing = true
fail_under = 80
8.5 Go: go test -cover
go test -coverprofile=coverage.out ./...
go tool cover -html=coverage.out -o coverage.html
go tool cover -func=coverage.out
8.6 カバレッジ目標の設定と落とし穴
一般的なガイドライン:
- 新規プロジェクト: 80%以上を目標とする
- レガシープロジェクト: 現状から段階的に改善する
- クリティカルなコンポーネント: 90%以上を目標とする
カバレッジの落とし穴
# カバレッジ100%だがバグがある例
def add(a, b):
return a * b # バグ: 乗算になっている
def test_add():
result = add(2, 2)
assert result == 4 # 2 * 2 = 4 で通ってしまう!
Goodhart の法則「指標が目標になると、良い指標でなくなる」を念頭に置き、カバレッジは補助的な指標として活用し、テストの質を直接評価する手段(コードレビュー、ミューテーションテスト等)と組み合わせることが重要である。
第9章: テスト駆動開発(TDD)
9.1 TDD の概要
テスト駆動開発(Test-Driven Development, TDD)は、テストを先に書いてからプロダクションコードを実装する開発手法である。Kent Beck が2002年の著書「Test-Driven Development: By Example」で体系化した。
9.2 Red-Green-Refactor サイクル
TDD は3つのステップを繰り返すサイクルで進行する:
(1) Red: 失敗するテストを書く
|
v
(2) Green: テストを通す最小限のコードを書く
|
v
(3) Refactor: コードを改善する(テストは緑のまま)
|
└──> (1) へ戻る
9.3 TDD の実践例(FizzBuzz)
# Step 1: Red
def test_returns_1_for_1():
assert fizzbuzz(1) == "1"
# Step 2: Green
def fizzbuzz(n):
return str(n)
# Step 3: Red - 3の倍数
def test_returns_fizz_for_3():
assert fizzbuzz(3) == "Fizz"
# Step 4: Green
def fizzbuzz(n):
if n % 3 == 0:
return "Fizz"
return str(n)
# Step 5: 5の倍数、15の倍数...と繰り返す
# 最終形
def fizzbuzz(n):
result = ""
if n % 3 == 0:
result += "Fizz"
if n % 5 == 0:
result += "Buzz"
return result or str(n)
9.4 TDD のメリットとデメリット
メリット
- 設計の改善
- リグレッション防止
- 高いテストカバレッジ
- 小さなステップでの問題解決
- 即座のフィードバック
- 実行可能なドキュメント
デメリット
- 学習コストが高い
- 初期速度の低下
- UI テストの困難さ
- レガシーコードへの適用困難
9.5 Kent Beck の教え
"仮実装"(Fake It)
テストを通すために、まずはハードコーディングで実装し、後から一般化する。
"三角測量"(Triangulation)
2つ以上のテストケースで異なる入力を与え、一般化を強制する。
"明白な実装"(Obvious Implementation)
実装が明らかな場合は、仮実装を経ずに直接実装してもよい。
9.6 TDD vs テストファースト vs テストラスト
| 特徴 | TDD | テストファースト | テストラスト |
|---|---|---|---|
| テスト記述タイミング | コード前 | コード前 | コード後 |
| Red-Green-Refactor | はい | 部分的 | なし |
| 設計への影響 | 強い | 中程度 | 弱い |
| カバレッジ | 高い | 高い | 変動する |
第10章: 振る舞い駆動開発(BDD)
10.1 BDD の概要
振る舞い駆動開発(Behavior-Driven Development, BDD)は、Dan North が2003年に提唱した開発手法である。TDD を発展させたもので、テストを「振る舞い」の記述として捉え、ビジネスステークホルダーとの共通言語を確立することを目的としている。
10.2 Gherkin 記法
Feature: ショッピングカート
オンラインストアの顧客として
商品をカートに追加して購入したい
Scenario: 商品をカートに追加する
Given カートが空である
When "ノートPC" を 1 個カートに追加する
Then カートの商品数は 1 である
And カートの合計金額は 100000 円である
Scenario Outline: 数量割引
Given カートが空である
When "<商品>" を <数量> 個カートに追加する
Then 割引率は <割引率> である
Examples:
| 商品 | 数量 | 割引率 |
| マウス | 1 | 0% |
| マウス | 5 | 5% |
| マウス | 10 | 10% |
10.3 Cucumber(Java)
import io.cucumber.java.ja.*;
public class ShoppingCartSteps {
private ShoppingCart cart;
@前提("カートが空である")
public void カートが空である() {
cart = new ShoppingCart(store);
assertThat(cart.isEmpty()).isTrue();
}
@もし("{string} を {int} 個カートに追加する")
public void 商品をカートに追加する(String productName, int quantity) {
cart.addItem(productName, quantity);
}
@ならば("カートの商品数は {int} である")
public void カートの商品数を確認(int expectedCount) {
assertThat(cart.getItemCount()).isEqualTo(expectedCount);
}
}
10.4 Behave(Python)
from behave import given, when, then
@given('カートが空である')
def step_impl(context):
context.cart = ShoppingCart(context.store)
assert context.cart.is_empty()
@when('"{product_name}" を {quantity:d} 個カートに追加する')
def step_impl(context, product_name, quantity):
context.cart.add_item(product_name, quantity)
@then('カートの商品数は {expected_count:d} である')
def step_impl(context, expected_count):
assert context.cart.item_count == expected_count
10.5 BDD とユニットテストの関係
| 観点 | ユニットテスト | BDD |
|---|---|---|
| 対象読者 | 開発者 | 全ステークホルダー |
| 記述言語 | プログラミング言語 | 自然言語(Gherkin) |
| 粒度 | 関数/メソッド | ビジネスシナリオ |
| 実行速度 | 高速 | 比較的遅い |
第11章: ミューテーションテスト
11.1 ミューテーションテストとは何か
ミューテーションテスト(Mutation Testing)は、テストスイートの品質を評価するための手法である。ソースコードに意図的な小さな変更(ミューテーション)を加え、テストスイートがその変更を検出できるかどうかを確認する。
ミューテーションスコア = 殺されたミュータント数 / 全ミュータント数 x 100%
11.2 ミューテーションオペレータ
# 元のコード
def calculate_discount(price, quantity):
if quantity >= 10:
return price * 0.9
# ミュータント1: 条件境界変更
def calculate_discount_mutant1(price, quantity):
if quantity > 10: # >= を > に変更
return price * 0.9
# ミュータント2: 算術演算子置換
def calculate_discount_mutant2(price, quantity):
if quantity >= 10:
return price + 0.9 # * を + に変更
11.3 PIT(Java)
<plugin>
<groupId>org.pitest</groupId>
<artifactId>pitest-maven</artifactId>
<version>1.15.3</version>
<configuration>
<targetClasses>
<param>com.example.service.*</param>
</targetClasses>
<mutators>
<mutator>DEFAULTS</mutator>
</mutators>
<mutationThreshold>80</mutationThreshold>
</configuration>
</plugin>
11.4 mutmut(Python)
pip install mutmut
mutmut run --paths-to-mutate=myapp/
mutmut results
mutmut html
11.5 Stryker(JavaScript)
// stryker.config.mjs
const config = {
testRunner: 'jest',
coverageAnalysis: 'perTest',
mutate: ['src/**/*.ts', '!src/**/*.test.ts'],
thresholds: { high: 80, low: 60, break: 50 },
};
export default config;
第12章: CI/CD でのユニットテスト
12.1 CI/CD パイプラインでのテスト自動化
すべてのコード変更に対してテストが自動実行され、テストが失敗した場合はマージがブロックされる。フィードバックは10分以内が理想的である。
12.2 GitHub Actions でのテスト設定
Java プロジェクト
name: Java CI with Tests
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
java-version: [17, 21]
steps:
- uses: actions/checkout@v4
- name: Set up JDK ${{ matrix.java-version }}
uses: actions/setup-java@v4
with:
java-version: ${{ matrix.java-version }}
distribution: 'temurin'
cache: 'gradle'
- name: Run tests
run: ./gradlew test
- name: Generate coverage report
run: ./gradlew jacocoTestReport
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: build/reports/jacoco/test/jacocoTestReport.xml
Python プロジェクト
name: Python CI with Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ['3.10', '3.11', '3.12']
steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: pip install -e ".[dev]"
- name: Run tests with coverage
run: |
pytest tests/ -v \
--cov=myapp --cov-report=xml \
--junitxml=test-results.xml \
--cov-fail-under=80
Go プロジェクト
name: Go CI with Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.22'
- name: Run tests
run: go test -v -race -coverprofile=coverage.out ./...
12.3 Jenkins でのテスト設定
pipeline {
agent any
stages {
stage('Build & Test') {
steps {
sh 'mvn clean test'
}
post {
always {
junit '**/target/surefire-reports/*.xml'
jacoco(execPattern: '**/target/jacoco.exec')
}
}
}
}
}
12.4 並列テスト実行
# pytest-xdist
pytest -n auto
# Go
t.Parallel()
# Jest
// jest.config.js: maxWorkers: '50%'
第13章: レガシーコードのテスト
13.1 レガシーコードとは何か
Michael Feathers は「Working Effectively with Legacy Code」の中で、レガシーコードを端的に定義した:
「テストのないコードはレガシーコードである」
13.2 テストのないコードにテストを追加する戦略
レガシーコード変更のアルゴリズム(Feathers)
- 変更点を特定する
- テストポイントを見つける
- 依存関係を排除する
- テストを書く(特性テスト)
- 変更を加える
- リファクタリングする
特性テスト(Characterization Test)
# レガシーコード: 何をしているか不明確
def process_data(data, flag, mode):
result = []
for item in data:
if flag:
val = item * 2 + 1
if mode == "A":
val = val - 3
else:
val = item
if mode == "A":
val = val * 3
result.append(val)
return result
# 特性テスト: 現在の振る舞いを記録する
def test_flag_true_mode_a():
result = process_data([1, 2, 3], True, "A")
assert result == [0, 2, 4] # 実際に実行して得た値
def test_flag_false_mode_a():
result = process_data([1, 2, 3], False, "A")
assert result == [3, 6, 9]
13.3 Seam(接合部)の見つけ方
// Before: 直接インスタンス化(テスト困難)
public class ReportGenerator {
public String generateReport(int year) {
DatabaseConnection db = new DatabaseConnection("production-url");
List<Record> records = db.query("SELECT * FROM sales WHERE year = " + year);
return formatReport(records);
}
}
// After: 依存性注入(Seamを作る)
public class ReportGenerator {
private final DatabaseConnection db;
public ReportGenerator(DatabaseConnection db) { this.db = db; }
public String generateReport(int year) {
List<Record> records = db.query("SELECT * FROM sales WHERE year = " + year);
return formatReport(records);
}
}
13.4 Approval Testing / Golden Master Testing
import approvaltests
from approvaltests.approvals import verify
def test_report_generation():
report = generate_complex_report(test_data)
verify(report)
# 初回: 出力が .approved.txt として保存される
# 2回目以降: .received.txt と .approved.txt を比較
第14章: ベストプラクティスとアンチパターン
14.1 テストの命名規約
// パターン1: should_expectedBehavior_when_condition
@Test void should_returnDiscount_when_customerIsPremium() { }
// パターン2: methodName_scenario_expectedResult
@Test void calculatePrice_withBulkOrder_appliesTenPercentDiscount() { }
// パターン3: @DisplayName による日本語記述
@Test
@DisplayName("プレミアム会員が1万円以上注文した場合、送料無料になる")
void premiumCustomerFreeShipping() { }
14.2 テストの可読性
// 可読性が高いテスト
@Test
@DisplayName("注文を作成すると、数量x単価の合計金額が設定される")
void shouldSetTotalAmountBasedOnQuantityAndPrice() {
// Arrange
OrderService service = createOrderServiceWithMocks();
OrderRequest request = anOrderRequest()
.withItemId("laptop-1")
.withQuantity(2)
.withPricePerUnit(5000)
.build();
givenPaymentWillSucceed();
// Act
Order order = service.createOrder(request);
// Assert
assertThat(order.getTotalAmount()).isEqualTo(10000.0);
}
14.3 DRY vs DAMP 原則
テストコードでは DAMP(Descriptive And Meaningful Phrases)原則が推奨される。テストは「わかりやすさ」を優先し、多少の重複は許容する。
# DAMPなテスト(読みやすい)
def test_single_item_order_total(self):
service = OrderService(FakeRepository())
request = OrderRequest(item="laptop", quantity=1, price=100000)
order = service.create_order(request)
assert order.total == 100000
14.4 テストのアンチパターン
脆いテスト(Fragile Tests)
実装の詳細に強く依存し、わずかなリファクタリングで壊れるテスト。テストは結果に注目すべき。
フレーキーテスト(Flaky Tests)
実行のたびに結果が変わる不安定なテスト。非決定性の原因(時刻依存、順序依存、外部サービス依存)を排除する。
# フレーキー: 順序依存
def test_flaky():
result = get_items()
assert result == ["item1", "item2", "item3"]
# 安定: 順序を無視
def test_stable():
result = get_items()
assert set(result) == {"item1", "item2", "item3"}
何も検証しないテスト
アサーションのないテストは価値がない。必ず明確なアサーションを追加する。
14.5 テストメンテナンスの考え方
- テストは公開 API に対して書く
- テストヘルパーを活用する
- テストの独立性を保つ
- テストの意図を明確にする
- 不要なテストを削除する
第15章: まとめと参考資料
15.1 ユニットテストのまとめ
本書では、ユニットテストの概念から実践まで、包括的に解説してきた。
基本概念
- F.I.R.S.T 原則に従い、AAA パターンでテストを構造化する
- テストダブルは外部依存に対してのみ使用する
- カバレッジは補助的な指標として活用する
言語別の実践
- Java: JUnit 5 + Mockito + AssertJ
- Python: pytest + pytest-mock
- JavaScript/TypeScript: Jest / Vitest
- Go: testing パッケージ + testify
開発手法
- TDD: Red-Green-Refactor サイクル
- BDD: ビジネスステークホルダーとの共通言語
- ミューテーションテスト: テストの品質評価
15.2 テスト文化の構築
| レベル | 状態 | 特徴 |
|---|---|---|
| Level 0 | テストなし | 手動テストのみ |
| Level 1 | 部分的 | 一部のコードにテストがある |
| Level 2 | 標準化 | テストが開発プロセスの一部。CI で自動実行 |
| Level 3 | 最適化 | TDD/BDD を実践。高いカバレッジ |
| Level 4 | 継続的改善 | テスト文化が定着。継続的に改善 |
15.3 推奨書籍
必読書
- 「Test-Driven Development: By Example」 - Kent Beck (2002)
- 「Working Effectively with Legacy Code」 - Michael Feathers (2004)
- 「xUnit Test Patterns」 - Gerard Meszaros (2007)
- 「Unit Testing: Principles, Practices, and Patterns」 - Vladimir Khorikov (2020)
推奨書
- 「Clean Code」 - Robert C. Martin (2008)
- 「The Art of Unit Testing」 - Roy Osherove (第3版, 2024)
- 「Growing Object-Oriented Software, Guided by Tests」 - Steve Freeman & Nat Pryce (2009)
- 「Refactoring」 - Martin Fowler (第2版, 2018)
- 「The Pragmatic Programmer」 - David Thomas & Andrew Hunt (第2版, 2019)
- 「Continuous Delivery」 - Jez Humble & David Farley (2010)
15.4 参考文献・リソース
オンラインリソース
- Martin Fowler のブログ: https://martinfowler.com/
- Google Testing Blog: https://testing.googleblog.com/
- JUnit 5 ドキュメント: https://junit.org/junit5/docs/current/user-guide/
- pytest ドキュメント: https://docs.pytest.org/
- Jest ドキュメント: https://jestjs.io/docs/getting-started
- Go Testing ドキュメント: https://pkg.go.dev/testing
おわりに
ユニットテストは、ソフトウェア開発において不可欠なスキルである。しかし、テストを書くこと自体が目的ではない。テストは、ソフトウェアの品質を高め、変更に強いコードベースを維持するための手段である。
完璧なテストスイートは存在しない。重要なのは、継続的にテストを改善し続けることである。今日書いたテストが明日のリファクタリングを可能にし、明日書いたテストが来週の機能追加を安全にする。
テストは投資である。短期的にはコストがかかるが、長期的には開発速度の向上、バグの減少、コードの品質向上という形でリターンをもたらす。
ソフトウェアエンジニアとして、テストを書く習慣を身につけ、テスト文化を推進していくことを強く推奨する。