Featured image of post MooによるTDD講座 #5 - 実践的なTDDテクニックとベストプラクティス

MooによるTDD講座 #5 - 実践的なTDDテクニックとベストプラクティス

実践的なTDDのテクニック、よくあるアンチパターン、継続的な改善方法を学んでシリーズを完結します。テストの品質を高め、保守性の高いコードを書くための知見を身につけましょう。

はじめに

いよいよ最終回です!これまでの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のサイクルを思い出してください。

  1. レッド:失敗するテストを書く
  2. グリーン:テストを通す(最小実装でOK)
  3. リファクタリング:テストが通った状態でコードを改善 ← ここ!

テストがあるからこそ、安心してリファクタリングできるのです。

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! 🎉


連載リンク

comments powered by Disqus
Hugo で構築されています。
テーマ StackJimmy によって設計されています。