NaaN日記

やったこと、覚えたことを発信する場

Perlでテーブル駆動テストを書くのにハマっている

この記事は、はてなエンジニア Advent Calendar 2022 - Hatena Developer Blogの45日目の記事です。

昨日はid:cohalz さんのGitHub ActionsでJobやStepをデフォルトブランチだけ動かすようにする (2023年版) - Re:cohalzでした。
github.event.repository.default_branch、初めて知りましたが便利。

明日はid:dekokun さんのGuardDutyの検知結果をslack投稿するアーキテクチャを考えた(あるいはAWSでイベントのフィルタを行うときに考えたこと) - でこてっくろぐ ねおです。


テーブル駆動テスト、使っていますか。
大体同じようなことをテストするけど、

  1. AとBでちょっとだけ違う処理をしたい
  2. AとBで結果を変えたい

といった時に、テーブル駆動テストの書き方は便利です。

CNaanがテーブル駆動テストを知ったのは、今から約4ヶ月前で、id:papix さんが業務でリファクタリングのPullRequestを作成していて、そのPRをレビューしたときに「こういう書き方があるのか」と知りました。
id:papix さんはよくflaky testの修正やリファクタリングPRを作成しています。
姿勢を見習っていきたい。
それ以降、使えそうなところではできるだけテーブル駆動の書き方で書いてみることを心がけています。

私が思うテーブル駆動テストを書くことのメリット

1. リポジトリ内でテーブル駆動テストを使っている例を見つけやすい

大体下記の流れで書くので、$cases =とかで検索すると、他の人がどういうテストを書いているかが見つけやすい。
似たような書き方になるので統一感もある気がする。

my $cases = [
    +{
        description => '〇〇のとき',
        mock => sub {
            # サブルーチンも使える
        },
        expected => 1,
    },
    (略)
];

for my $case (@$cases) {
    subtest $case->{description} => sub {
        # 特定のcaseのみ実行する処理はcase経由で呼ぶ
        $case->{mock}->();
        # 全てのcaseで実行する処理
        sample();
        (略)
        is, $result, $case->{expected};
    };
}

done_testing();

2. テストの冒頭で何をテストするかがまとまっていて見やすい

$cases = []の中を読めばこれから何のテストをやっているかがわかる。
テーブル駆動(ループ)を使わずコピー&ペーストでテストを書いていたら、上から下まで見ていく必要がある。
コピー&ペーストだと可読性が下がるし、一つの変更をする時に複数箇所変更する必要があって、変更し忘れなどの漏れが起きやすい気がする。

3. 新しいケースを追加するとき、簡単に追加できる

$casesに差を追加するだけで、増やせるし、減らしたい時はコメントアウトすれば良い。

4. 似た処理の流れをまとめられる

既に述べたメリットと似ているけど、やはり似た処理をまとめられるのが便利。
業務で書くテストでも、ユーザーを作って、とかしているけれど、それをテストケースの数だけコピー&ペーストで書くのは、後々見返した時に見るのが辛い。
一つのサブルーチンに対してのテストで複数の条件分岐が発生しそうなときは、subtest; subtest; subtest;とするのではなく、for my $case (@$cases){subtest;}とテーブル駆動テストを使ってまとめるようにしたい。

なんとなく苦手意識があるテストファイルの行数を数えると1800行くらいでした。私は2000行近くなってくると苦手意識が生まれるようです。
したがって、条件を網羅しつつ、シンプルにテストを書けることが理想です。

おわり

さて、今はテーブル駆動テストな書き方にハマってますが、読者の好きな書き方を布教して頂けると嬉しいです。
CNaanのこれからの活躍にご期待ください。


こんな感じで書いている、という例を用意しようと思ったけど微妙な例になってしまったのでここで供養。

use strict;
use warnings;
use utf8;

use Test2::V0;

my $cases = [
    +{
        description => 'ゲストユーザーのとき',
        create_user => sub {
            create_test_user();
        },
        is_guest => 1,
    },
    +{
        description => 'ログインユーザーのとき',
        create_user => sub {
            create_test_user('test');
        },
        is_guest => 0,
    },
];

for my $case (@$cases) {
    subtest $case->{description} => sub {
        my $user = $case->{create_user}->();
        is $user->{is_guest}, $case->{is_guest};
    };
}

sub create_test_user {
    my ($name) = @_;

    if ( defined $name ) {
        return +{
            name     => $name,
            is_guest => 0,
        };
    }
    return +{
        name     => undef,
        is_guest => 1
    };
}

done_testing();

$ prove sample.t

All tests successful.
Files=1, Tests=2,  1 wallclock secs ( 0.00 usr  0.00 sys +  0.05 cusr  0.01 csys =  0.06 CPU)
Result: PASS