はじめに
いよいよ最終回です!これまでの4回で、TDDの基本から環境構築、メソッドのテスト、そしてCI環境まで構築してきました。
この最終回では、実践的なTDDテクニックと**よくある落とし穴(アンチパターン)**を学びます。また、継続的に改善していく方法を身につけて、シリーズを完結させましょう。
これまでの旅路
簡単に振り返ってみます。
- #1:環境構築とTDDの基本サイクル(レッド→グリーン→リファクタリング)
- #2:メソッドのテストとTest2の便利な機能(例外テスト、サブテスト)
- #3:テストに守られながらリファクタリング(Mooのロール機能の活用)
- #4:GitHub ActionsでCI環境を構築
そして今回は、これらの知識を総動員して、より良いテストを書くためのノウハウを学びます。
テストの品質を高める
テストを書くだけでなく、良いテストを書くことが重要です。
意味のあるテストとは
良いテストの条件を整理しましょう。
1
2
3
4
5
6
7
8
9
10
11
| # 悪い例:カバレッジのためだけのテスト
subtest 'getter exists' => sub {
my $person = Person->new(name => 'Alice');
ok($person->can('name'), 'name method exists');
};
# 良い例:振る舞いをテストする
subtest 'name getter returns correct value' => sub {
my $person = Person->new(name => 'Alice');
is($person->name, 'Alice', 'name returns the set value');
};
|
良いテストは、コードの振る舞いをテストします。メソッドが存在するかではなく、期待通りに動作するかを確認しましょう。
テストすべき観点
効果的なテストを書くために、以下の観点を押さえましょう。
1. 正常系(Happy Path)
期待される通常の使い方をテストします。
1
2
3
4
5
| subtest 'normal deposit' => sub {
my $account = BankAccount->new(balance => 100);
$account->deposit(50);
is($account->balance, 150, 'deposit increases balance');
};
|
2. 異常系(Error Cases)
エラーが適切に処理されることを確認します。
1
2
3
4
5
6
7
8
9
10
11
| use Test2::Tools::Exception;
subtest 'invalid deposit' => sub {
my $account = BankAccount->new(balance => 100);
like(
dies { $account->deposit(-50) },
qr/Amount must be positive/,
'negative deposit throws error'
);
};
|
3. 境界値(Edge Cases)
境界となる値でのテストを忘れずに。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| subtest 'boundary values' => sub {
my $account = BankAccount->new(balance => 100);
# ちょうど残高と同じ金額
ok(lives { $account->withdraw(100) }, 'can withdraw exact balance');
is($account->balance, 0, 'balance is now zero');
# 残高がゼロの時
like(
dies { $account->withdraw(1) },
qr/Insufficient funds/,
'cannot withdraw from empty account'
);
};
|
テストしにくいコードは、往々にして設計上の問題を抱えています。
1
2
3
4
5
| # 悪い例:テストしにくい(現在時刻に依存)
sub is_business_hours {
my $hour = (localtime)[2];
return $hour >= 9 && $hour < 18;
}
|
このコードは、実行時刻によって結果が変わるため、テストが困難です。改善しましょう。
1
2
3
4
5
6
7
8
9
10
| # 良い例:時刻を引数で受け取る
sub is_business_hours {
my ($self, $hour) = @_;
$hour //= (localtime)[2]; # デフォルトは現在時刻
return $hour >= 9 && $hour < 18;
}
# テストが簡単になる
is($obj->is_business_hours(10), 1, '10時は営業時間内');
is($obj->is_business_hours(20), '', '20時は営業時間外');
|
依存性の注入によって、テスタビリティが向上しました。
テストしやすい設計のパターン
テストしやすいコードを書くための設計パターンをいくつか紹介します。
1. 依存性の注入(Dependency Injection)
外部依存を引数やコンストラクタで受け取ることで、テストで差し替えやすくします。
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
| # 悪い例:内部でハードコーディング
package UserService;
use Moo;
sub get_user {
my ($self, $id) = @_;
my $db = Database->new(host => 'prod.example.com'); # ハードコーディング
return $db->find_user($id);
}
# 良い例:依存性を注入
package UserService;
use Moo;
has 'database' => (
is => 'ro',
required => 1,
);
sub get_user {
my ($self, $id) = @_;
return $self->database->find_user($id);
}
# テストでモックを注入できる
my $mock_db = MockDatabase->new;
my $service = UserService->new(database => $mock_db);
|
2. 単一責任の原則(Single Responsibility Principle)
1つのクラスは1つの責任だけを持つようにします。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| # 悪い例:複数の責任を持つ
package UserManager;
sub create_user { ... }
sub send_welcome_email { ... } # メール送信も担当
sub generate_report { ... } # レポート生成も担当
# 良い例:責任を分離
package UserRepository;
sub create_user { ... }
package EmailService;
sub send_welcome_email { ... }
package ReportGenerator;
sub generate_report { ... }
|
責任を分離することで、各クラスのテストが簡潔になります。
実践的なTDDテクニック
経験から学んだ、実践で役立つテクニックを紹介します。
テストの粒度の考え方
テストの粒度(どこまで細かくテストするか)は、バランスが重要です。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| # 細かすぎる例(内部実装に依存しすぎ)
subtest 'increment counter internal' => sub {
my $counter = Counter->new;
is($counter->{_count}, 0, 'internal count is 0'); # 実装詳細に依存
$counter->increment;
is($counter->{_count}, 1, 'internal count is 1');
};
# 適切な粒度(公開インターフェースをテスト)
subtest 'increment counter' => sub {
my $counter = Counter->new;
is($counter->count, 0, 'initial count is 0');
$counter->increment;
is($counter->count, 1, 'count incremented to 1');
};
|
公開インターフェースをテストし、内部実装の詳細には立ち入らないようにしましょう。
モック・スタブの使い方
外部依存をモックすることで、テストの独立性を保ちます。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| use Test2::Mock;
subtest 'email notification with mock' => sub {
my $mock = Test2::Mock->new(
class => 'EmailService',
override => [
send => sub {
my ($self, %args) = @_;
return {
success => 1,
message_id => 'test-123'
};
}
]
);
my $notifier = Notifier->new(email_service => EmailService->new);
my $result = $notifier->notify_user('user@example.com', 'Hello');
ok($result->{success}, 'notification succeeded with mock');
};
|
モックを使うことで、実際にメールを送信せずにテストできます。
テストダブルのパターン
テストダブルには複数のパターンがあります。
スタブ(Stub):決まった値を返すだけのシンプルな代用品
1
2
3
| my $stub_db = StubDatabase->new(
find_user => sub { return { id => 1, name => 'Test User' } }
);
|
モック(Mock):呼び出しを記録し、検証できる代用品
1
2
3
| my $mock = Test2::Mock->new(class => 'Logger');
# ... テスト実行 ...
# 呼び出しが行われたかを検証
|
フェイク(Fake):本物に近い動作をする簡易実装(例:インメモリDB)
1
| my $fake_cache = FakeCache->new; # 実際のRedisの代わりにハッシュを使用
|
使い分けのポイント:
- スタブ:単純な戻り値のテストに
- モック:呼び出しの検証が必要なときに
- フェイク:複雑な振る舞いが必要なときに
TDDのアンチパターン
陥りがちな失敗例を知って、避けましょう。
テストのためのコード(避けるべき)
本番コードに「テストのため"だけ"のコード」を入れるのは避けるべきです。
1
2
3
4
5
6
7
8
9
10
| # アンチパターン:テストのためのフラグ
package UserService;
has 'test_mode' => (is => 'rw', default => 0); # 避けるべき
sub send_email {
my ($self, $email) = @_;
return if $self->test_mode; # テストモードでは送信しない
# 実際のメール送信処理
}
|
代わりに、依存性の注入を使いましょう。
1
2
3
4
5
6
7
8
9
10
11
12
| # 良い例:依存性の注入
package UserService;
has 'email_sender' => (is => 'ro', required => 1);
sub send_email {
my ($self, $email) = @_;
$self->email_sender->send($email);
}
# テストではモックを注入
my $service = UserService->new(email_sender => $mock_sender);
|
テストの重複
同じことを何度もテストするのは、メンテナンスコストを増やすだけです。
1
2
3
4
5
6
7
8
9
10
| # 重複したテスト(避けるべき)
subtest 'name validation test 1' => sub {
my $person = Person->new(name => 'Alice');
is($person->name, 'Alice', 'name is Alice');
};
subtest 'name validation test 2' => sub {
my $person = Person->new(name => 'Alice');
is($person->name, 'Alice', 'name is Alice again'); # 重複
};
|
テストは異なるケースをカバーするようにしましょう。
壊れやすいテスト
実装の些細な変更でテストが壊れるのは、テストが実装に密結合しているサインです。
1
2
3
4
5
6
7
8
9
10
11
12
13
| # 壊れやすいテスト
subtest 'internal implementation' => sub {
my $obj = MyClass->new;
is(ref($obj->{_internal_cache}), 'HASH', 'uses hash for cache'); # 実装詳細
};
# 堅牢なテスト(振る舞いに着目)
subtest 'caching behavior' => sub {
my $obj = MyClass->new;
my $result1 = $obj->expensive_operation;
my $result2 = $obj->expensive_operation;
is($result1, $result2, 'results are consistent (cached)');
};
|
what(何をするか) をテストし、how(どうやるか) には立ち入らないのがコツです。
継続的な改善
TDDは一度やって終わりではなく、継続的に改善していくものです。
リファクタリングのタイミング
「いつリファクタリングするか?」の答えは、テストが通っているときです。
TDDのサイクルを思い出してください。
- レッド:失敗するテストを書く
- グリーン:テストを通す(最小実装でOK)
- リファクタリング:テストが通った状態でコードを改善 ← ここ!
テストがあるからこそ、安心してリファクタリングできるのです。
1
2
3
4
5
| # リファクタリングの流れ
$ prove -lv t/ # グリーン確認
$ # コードを改善する
$ prove -lv t/ # まだグリーン?
$ git commit -m "refactor: improve readability"
|
テストコードもリファクタリング対象
テストコード自体も、読みやすく保守しやすくする必要があります。
共通処理をヘルパーメソッドに
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
| # Before:重複が多い
subtest 'test 1' => sub {
my $db = TestDB->new;
$db->setup_schema;
$db->insert_test_data;
# テスト本体
};
subtest 'test 2' => sub {
my $db = TestDB->new;
$db->setup_schema;
$db->insert_test_data;
# テスト本体
};
# After:ヘルパーを使う
sub create_test_db {
my $db = TestDB->new;
$db->setup_schema;
$db->insert_test_data;
return $db;
}
subtest 'test 1' => sub {
my $db = create_test_db();
# テスト本体
};
|
チームでのTDD文化の育て方
TDDを個人だけでなく、チーム全体に広げるためのヒントです。
1. 小さく始める
いきなり全てをTDDで書く必要はありません。
- まずは新機能だけTDDで書いてみる
- バグ修正時に、再現テストを先に書く習慣をつける
- レビュー時にテストの質を確認し合う
2. ペアプログラミング・モブプログラミング
一人で悩むより、チームで一緒にTDDを実践すると学びが深まります。
1
2
3
4
5
| # ペアでTDD:役割を交代しながら
Alice: テストを書く(レッド)
Bob: 実装を書く(グリーン)
Alice: リファクタリング
# 役割交代して次のテストへ
|
3. 成功体験を共有する
- テストのおかげで救われたバグの話
- リファクタリングが安全にできた話
- テストが増えて、コードへの自信が高まったこと
こうした体験をチームで共有すると、TDDの価値が伝わります。
4. CI/CDと組み合わせる
前回(#4)で構築したCIがあれば、テストが自動で走ります。これが「テストを書かないと気持ち悪い」文化を作ります。
1
2
3
4
5
6
7
8
9
10
11
| # .github/workflows/test.yml
name: Test
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Run tests
run: |
docker compose run --rm app prove -lr t/
|
自動テストがあれば、プルリクエストのマージ前に問題を検出できます。
まとめ:TDDの旅を振り返る
全5回の連載、お疲れさまでした!一緒にたくさんのことを学んできましたね。
5回で学んだこと
#1 環境構築とはじめてのテスト駆動開発
- Dockerでの開発環境構築
- TDDの基本サイクル(レッド→グリーン→リファクタリング)
- Test2とMooの基礎
#2 メソッドのテストとTest2の便利な機能
- 戻り値のテスト、例外のテスト
- Test2の多彩なアサーション(
like, is_deeply, matchなど) - サブテストによる整理
#3 テストに守られながらリファクタリング
- Mooのロール機能を使ったコードの再利用
- リファクタリングの実践
- テストがあるからこその安全なコード改善
#4 GitHub ActionsでCI環境を構築
- CIの重要性と自動テストの価値
- GitHub Actionsの基本
- DockerとCIの統合
#5 実践的なTDDテクニックとベストプラクティス(今回)
- 良いテストを書くための観点
- テストしやすい設計のパターン
- 実践的なTDDテクニック(モック、スタブ)
- アンチパターンの回避
- 継続的な改善とチーム文化
これからの学習リソース
TDDの旅は、ここで終わりではありません。さらに学びを深めるためのリソースを紹介します。
書籍
- 『テスト駆動開発』Kent Beck(オーム社):TDDのバイブル的存在
- 『リファクタリング』Martin Fowler(オーム社):テストとセットで学ぶべき必読書
オンラインリソース
コミュニティ
- CPAN Testersの活動を見る:オープンソースのテスト文化を学べます
- 社内勉強会でTDDを実践してみる:仲間と一緒に学ぶのが一番です
おわりに
TDDは、最初は「面倒だな」と感じるかもしれません。テストを先に書くのは、慣れないうちは不自然に思えるでしょう。
でも、続けていくうちに、こう感じる瞬間が来ます。
- 「テストがあるから、安心してリファクタリングできる」
- 「バグを見つけたとき、まずテストで再現できる」
- 「CIが通っているから、自信を持ってデプロイできる」
これがTDDの魅力です。
最初から完璧を目指す必要はありません。小さく始めて、少しずつ改善していきましょう。
テストを書く習慣を身につけ、それを楽しめるようになったとき、あなたのコーディングライフは大きく変わっているはずです。
最後まで読んでいただき、ありがとうございました。この連載が、あなたのTDDの第一歩になれば嬉しいです。
Happy Testing! 🎉
連載リンク