Featured image of post MooによるTDD講座 #3 - テストに守られながらリファクタリング

MooによるTDD講座 #3 - テストに守られながらリファクタリング

テストがあるからこそ安心してリファクタリングできる、というTDDの真価を体験します。Mooのロール機能を使ってコードの重複を排除し、保守性の高い設計を学びます。

はじめに

前回までの講座で、TDDの基本サイクル「レッド→グリーン→リファクタリング」を体験し、Test2の便利な機能を使ってメソッドのテストを書けるようになりました。

今回は、TDDの真価である「テストに守られたリファクタリング」を実践します。複数のクラスで共通する振る舞いをMooのロール機能で整理し、保守性の高いコードへと進化させていきましょう。

リファクタリングとは

リファクタリングの定義

リファクタリングとは、外部から見た動作を変えずに、内部のコード構造を改善することです。つまり、「何をするか」は変えず、「どう実現するか」を洗練させる作業です。

重要なのは、リファクタリングは「機能追加」ではないということ。新しい機能を加えるのではなく、既存のコードをより良い形に整えるのが目的です。

テストがある安心感

リファクタリングで最も怖いのは「既存の動作を壊してしまうこと」です。しかし、テストがあれば話は別です。

1
2
3
4
5
6
7
# リファクタリング前にテストを実行
docker compose run --rm app prove -lr t/

# コードを変更

# すぐにテストを実行して確認
docker compose run --rm app prove -lr t/

テストが全て通っていれば、外部から見た動作は変わっていないことが保証されます。これこそが、TDDの最大の価値です。

いつリファクタリングするか

リファクタリングのタイミングは「テストが全て通っている時」です。テストが失敗している状態では、リファクタリングと機能追加が混ざってしまい、どこで問題が起きたか分からなくなります。

また、「同じようなコードを3回書いたら」というDRY原則(Don’t Repeat Yourself)の目安も覚えておくと良いでしょう。

実践:重複コードの整理

複数のクラスで共通する振る舞い

前回までで、Personクラスにgreetメソッドとgreet_toメソッドを実装しました。今回は、新しくRobotクラスを追加して、コードの重複を体験してみましょう。

まず、Robotクラスを作成します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
# lib/Robot.pm
package Robot;
use Moo;
use strictures 2;

has name => (
    is => 'ro',
    required => 1,
);

sub greet {
    my $self = shift;
    return "Hello, I am " . $self->name . "!";
}

1;

現在のPersonクラスは以下のようになっています:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
# lib/Person.pm
package Person;
use Moo;
use strictures 2;

has name => (
    is => 'ro',
    required => 1,
);

sub greet {
    my $self = shift;
    return "Hello, I am " . $self->name . "!";
}

sub greet_to {
    my ($self, $target) = @_;
    return "Hello $target, I am " . $self->name . "!";
}

1;

PersonとRobotは、全く同じgreetメソッドを持っています。これは明らかなコードの重複です。

コピペコードの問題点

コピペコードには、以下のような問題があります。

  • 保守性の低下:挨拶のフォーマットを変更したい時、全てのクラスを修正する必要がある
  • バグの温床:片方だけ修正を忘れる、といったミスが起きやすい
  • テストの重複:同じ振る舞いのテストを何度も書くことになる

ロールによる解決

Mooのロール(Role)機能を使えば、この重複を解消できます。ロールは「クラスに混ぜ込める振る舞いのセット」のようなものです。

Mooのロール機能

Role::Tinyの基本

Mooでロールを定義するには、Role::Tinyを使います。まず、挨拶機能を持つロールを作りましょう。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# lib/Role/Greetable.pm
package Role::Greetable;
use Role::Tiny;
use strictures 2;

requires 'name';  # このロールを使うクラスは name を持つ必要がある

sub greet {
    my $self = shift;
    return "Hello, I am " . $self->name . "!";
}

1;

requires 'name'は、このロールを使うクラスがnameメソッドを持っていることを要求します。これにより、greetメソッド内で安全に$self->nameを呼び出せます。

withでロールを適用

ロールをクラスに適用するには、withキーワードを使います。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
# lib/Person.pm
package Person;
use Moo;
use strictures 2;

has name => (
    is => 'ro',
    required => 1,
);

with 'Role::Greetable';

sub greet_to {
    my ($self, $target) = @_;
    return "Hello $target, I am " . $self->name . "!";
}

1;
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# lib/Robot.pm
package Robot;
use Moo;
use strictures 2;

has name => (
    is => 'ro',
    required => 1,
);

with 'Role::Greetable';

1;

これで、PersonとRobotの両方がgreetメソッドを持つようになりました。しかも、実装は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
# t/03-greetable-role.t
use strict;
use warnings;
use Test2::V0 -target => 'Role::Greetable';

# テスト用のダミークラスを定義
{
    package TestClass;
    use Moo;
    
    has name => (
        is => 'ro',
        required => 1,
    );

    with 'Role::Greetable';
}

subtest 'Greetable role functionality' => sub {
    my $obj = TestClass->new(name => 'Test User');
    
    ok $obj->can('greet'), 'greet method exists';
    is $obj->greet, "Hello, I am Test User!", 'greet returns correct message';
};

done_testing;

テストに守られたリファクタリング

テストを全て通した状態で開始

リファクタリングを始める前に、必ず全てのテストが通っていることを確認します。

1
docker compose run --rm app prove -lr t/

全てのテストがパスしていることを確認したら、リファクタリング開始です。

一つずつ変更してテスト実行

リファクタリングは小さなステップで進めます。例えば:

  1. Role::Greetableを作成
  2. テスト実行(新しいテストを追加した場合)
  3. Personクラスでロールを使用
  4. テスト実行
  5. Robotクラスでロールを使用
  6. テスト実行

各ステップでテストを実行することで、どこで問題が起きたか即座に分かります。

1
2
# 変更後、すぐにテスト
docker compose run --rm app prove -lr t/

テストが壊れたら即座に戻す

もしテストが失敗したら、慌てずに前の状態に戻します。Gitを使っていれば簡単です。

1
2
3
4
5
# 変更を取り消す
git checkout -- lib/Person.pm

# またはコミット前なら
git restore lib/Person.pm

落ち着いて、何が問題だったかを確認してから、再度挑戦しましょう。

実践:Greetableロールの作成

複数のクラスで挨拶機能を共有

より実践的な例として、挨拶のバリエーションを増やしてみましょう。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# lib/Role/Greetable.pm
package Role::Greetable;
use Role::Tiny;
use strictures 2;

requires 'name';

sub greet {
    my $self = shift;
    return "Hello, I am " . $self->name . "!";
}

sub greet_formal {
    my $self = shift;
    return "Good day. My name is " . $self->name . "!";
}

sub greet_casual {
    my $self = shift;
    return "Hi! I'm " . $self->name . "!";
}

1;

このロールを使えば、PersonもRobotも、3種類の挨拶ができるようになります。

ロールのテスト

まず、ロールの機能をテストするために、テストファイルを更新しましょう:

 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
# t/03-greetable-role.t
use strict;
use warnings;
use Test2::V0 -target => 'Role::Greetable';

{
    package TestGreeter;
    use Moo;
    
    has name => (
        is => 'ro',
        required => 1,
    );

    with 'Role::Greetable';
}

subtest 'Greetable role all methods' => sub {
    my $greeter = TestGreeter->new(name => 'Alice');
    
    is $greeter->greet, "Hello, I am Alice!", 
        'greet returns standard greeting';
    
    is $greeter->greet_formal, "Good day. My name is Alice!",
        'greet_formal returns formal greeting';
    
    is $greeter->greet_casual, "Hi! I'm Alice!",
        'greet_casual returns casual greeting';
};

done_testing;

ロールを使うクラスのテスト

Personクラスには、ロールで追加されたメソッドと、独自のgreet_toメソッドのテストを追加します:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
# t/04-person-with-role.t
use strict;
use warnings;
use Test2::V0 -target => 'Person';

subtest 'Person with Greetable role' => sub {
    my $person = Person->new(name => 'Bob');
    
    ok $person->can('greet'), 'has greet method';
    ok $person->can('greet_formal'), 'has greet_formal method';
    ok $person->can('greet_casual'), 'has greet_casual method';
    ok $person->can('greet_to'), 'has greet_to method';
    
    is $person->greet, "Hello, I am Bob!",
        'Person can greet normally';
    
    is $person->greet_to('Alice'), "Hello Alice, I am Bob!",
        'Person can greet with target';
};

done_testing;
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
# t/05-robot.t
use strict;
use warnings;
use Test2::V0 -target => 'Robot';

subtest 'Robot with Greetable role' => sub {
    my $robot = Robot->new(name => 'R2D2');
    
    is $robot->greet_formal, "Good day. My name is R2D2!",
        'Robot can greet formally';
    
    is $robot->greet_casual, "Hi! I'm R2D2!",
        'Robot can greet casually';
};

done_testing;

全てのテストを実行して、グリーンであることを確認しましょう。

1
docker compose run --rm app prove -lr t/

まとめと次回予告

リファクタリングは「機能追加」ではない

今回学んだ重要なポイントは、リファクタリングは新機能の追加ではなく、コードの整理だということです。

  • コードの重複を排除する
  • 読みやすさを向上させる
  • 保守性を高める

これらは全て、外部から見た動作を変えずに実現します。そして、テストがその安全性を保証してくれます。

TDDの3つの柱

TDDの本質は、以下の3つのバランスにあります。

  1. レッド:失敗するテストで要件を明確にする
  2. グリーン:最小限の実装で動作を実現する
  3. リファクタリング:テストに守られながらコードを改善する

この3つを繰り返すことで、品質の高いコードが育っていきます。

次回予告

次回は「MooによるTDD講座 #4 - GitHub Actionsで自動テスト」として、以下の内容を扱います。

  • GitHub Actionsの基本設定
  • プッシュ時に自動でテストを実行
  • 複数のPerlバージョンでのマトリックステスト
  • バッジの設定でREADMEを充実

継続的インテグレーション(CI)を導入して、さらに安心してコードを書ける環境を整えましょう。お楽しみに!

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