PHPUnitとデータプロバイダとテストケース生成

  • 43
    いいね
  • 0
    コメント

PHPUnitのデータプロバイダという機能について、自分なりにまとめます。

データプロバイダとは何か

https://phpunit.de/manual/current/ja/writing-tests-for-phpunit.html

PHPUnitで、複数のテストパターンを書くことはよくあります。この時、似たようなアサーションを何度も書くことになります。

例えば… 足し算するだけの関数をテストするとして、

<?php
function add($a, $b) {
    return $a + $b;
}

素直に書いていくとこんな感じになるでしょう。

//...
public function testAdd()
{
    $this->assertEquals(5, add(2, 3));
    $this->assertEquals(0, add(-1, 1));
    $this->assertEquals(-5, add(-2, -3));
    // ...
}

データプロバイダを使うと、テストの値を与える部分だけ、別メソッドに分離することができます。

//...
/**
 * @dataProvider provideAdditionTestParams
 */
public function testAdd($expected, $a, $b)
{
    $this->assertEquals($expected, add($a, $b));
}

public function provideAdditionTestParams()
{
    return [
        [5, 2, 3],
        [0, -1, 1],
        [-5, -2, -3],
    ];
}

foreachに比べてメリットあるの?

正直、私は結構長い間、「foreachで書くほうがシンプルなのでは」って思ってました。

さっきの例だと、こんな風に書き換えても同じことができます。単純なループのほうがわかりやすくていいじゃん!

//...
public function testAdd()
{
    $patterns = [
        [5, 2, 3],
        [0, -1, 1],
        [-5, -2, -3],
    ];

    foreach ($patterns as list($expected, $a, $b)) {
        $this->assertEquals($expected, add($a, $b));
    }
}
//...

ところが、ちゃんとメリットがあるんです。
以下、メリットを記載しますが、いずれもデータプロバイダが単なる記法ではなく、PHPUnit自体の設計思想に則った機能だから発生しているものです。

メリット1: setUp/tearDownが使える

PHPUnit_Framework_TestCaseクラスは特定の名前のメソッドを定義しておくとテストの前後に自動実行してくれる機能があります。setUpとtearDownですね。

setUpにはモックオブジェクトの生成を行いプロパティに代入しておく、DBを使うテストならDBセットアップなどを記述することが多いでしょう。

foreachで記載した場合はテストメソッドが1個です。つまりループの前にsetUpが1回、ループの後にtearDownが1回実行されるだけです。

データプロバイダで記述すると、それぞれが1つのテスト扱いになるため、毎回setUp/tearDownが実行されます。

このため、モックの準備等をsetUpに逃がしていれば、毎回クリーンな環境が用意できます。

メリット2: どのデータでテストが落ちたのか分かりやすい

ループでアサーションを実行すると、テストが失敗したときにどのデータで落ちたのかがわかりにくくなります。行番号が常に同じになってしまうからです。
なので、さっきのテストに関しても、assertEqualsにコメントを含めるなどして、分かりやすく工夫することが実質必須になるでしょう。

//...
public function testAdd()
{
    $patterns = [
        [5, 2, 3],
        [0, -1, 1],
        [-5, -2, -3],
    ];

    foreach ($patterns as list($expected, $a, $b)) {
        $actual = add($a, $b);
        $this->assertEquals($expected, $actual, "add($a, $b) => $actual != $expected");
    }
}
//...

データプロバイダの場合、標準状態でも「落ちたのは先頭から数えて何番目のパターンか?」を表示してくれますし、ラベルを使うことでコメントで説明したのと同等の機能も使えます。

public function provideAdditionTestParams()
{
    return [
        "5  = add(2, 3)"   => [5, 2, 3],
        "0  = add(-1, 1)"  => [0, -1, 1],
        "-5 = add(-2, -3)" => [-5, -2, -3],
    ];
}

メリット3: 例外のテストとの組み合わせ

PHPUnitでは、例外が起きることをアノテーションで記述する機能があります。
これとデータプロバイダは組み合わせて使うことができます。

/**
 * @expectedException InvalidArgumentException
 * @dataProvider provideIllegalValues
 */
public function testAdd($a, $b)
{
    add($a, $b);
}

データプロバイダを使わずループで記述しようとすると、ループ内にtry catchを書くしかないため、少々見た目が煩雑になります。
ループでかつ@expectedExpectionを使うだけと、最初に例外が発生したらメソッドが終了してしまうため、その後のパターンをテストできません。

一旦まとめ

PHPUnitの使用感として、一つ一つのテストメソッドは小さい方が綺麗に書けるようになっています。

これとデータプロバイダは相性がよいので、PHPUnitで記載するならば素直に使う(使えるようにテストを書く)ほうがよいと思います。

応用:yieldとの組み合わせ

データプロバイダメソッドの返り値は、foreachで回せるiterableなものであれば何でもよく、必ずしも配列でなくても構いません。

そのため、直接ジェネレーター関数として実装することもできます。

public function provideAdditionTestParams()
{
    yield "5  = add(2, 3)"   => [5, 2, 3];
    yield "0  = add(-1, 1)"  => [0, -1, 1];
    yield "-5 = add(-2, -3)" => [-5, -2, -3];
}

2因子間網羅を自動生成する

ジェネレータ関数を使えることを利用して、組み合わせテストを簡潔に書いてみましょう。(今思いついた)

テストケースとして複数のパターンが考えられる場合、まあ理想は全件試すことなんですけど、パターンが多すぎて現実時間で終わらなくなってしまうと困ります。
そこで、100%は諦めて、70~90%ぐらいの網羅を保ちつつ、しかしテストケースを大幅に削減する、という方法がいくつかあります。

なんか適当な例として、引数3つを取る関数があったとします。カスタマイズできるラーメンにでもしよう。

function ラーメン注文($麺の硬さ, $脂の量, $味の濃さ);

それぞれ、これぐらい指定の幅があるとします。

麺の硬さ 脂の量 味の濃さ
柔らかめ 少なめ 薄め
普通 普通 普通
硬め 多め 濃いめ
バリカタ 抜き

真面目に全組み合わせを試すには、4*4*3で48通りになります。

これを、「2つの引数同士の組み合わせが一通り網羅できてれば、まあいいか」と間引きして減らすのが2因子間網羅の考え方です。よく教科書には、All-Pairs法とか、直交表とかで作るものとして紹介されています。

これをyieldで記述するなら機械的に記述できます。
素朴に、「すでに2因子間の組み合わせがテスト済みだったらスキップ」という感じのアルゴリズムで実装してみましょう。

今回はパラメータが全部文字列なので、単純に連想配列でメモしておけば、テスト済みかどうかわかります。

public function generateAllPairs()
{
    $a = ['柔らかめ', '普通', '硬め', 'バリカタ'];
    $b = ['少なめ', '普通', '多め', '抜き'];
    $c = ['薄め', '普通', '濃いめ'];
    $ab = $bc = $ca = [];

    foreach ($a as $i) {
        foreach ($b as $j) {
            foreach ($c as $k) {
                if (isset($ab["$i,$j"], $bc["$j,$k"], $ca["$k,$i"])) {
                    continue;
                }
                $ab["$i,$j"] = $bc["$j,$k"] = $ca["$k,$i"] = 1;
                yield [$i, $j, $k];
            }
        }
    }
}

この場合、24通りと、テストケースを半分まで減らすことができます。得られたパターンのみで、「ラーメンが提供されること」みたいなアサーションを試せばよいでしょう。全組み合わせを網羅してなくても、ぐっと確度は高まるはずです。

続く?

流石にこんな単純なやり方だとあんまり件数減らないですね。。本来はもっと劇的に減らせるのがAll-Pairsのハズなので。

あと手書きするにしても、3因子だと簡単だけど、4因子だと因子間の組み合わせだけで6個になるし、こんな素朴なやり方だと書いてられない。ライブラリがほしいと思い始めました。(既にないのかな?)

一旦ここで力尽きたので調べたらまた書きます。