🙌

[PHPUnit] 差分だけデータプロバイダのすゝめ

2023/12/09に公開

季節はめっきり冬めき、街を歩けばクリスマスソングが聞こえ始めた今日この頃。
NE株式会社AdventCalendar9日目は、さくらいが担当いたします。

こちらは【ツールなど使わず手動で】【データプロバイダに】【少しずつ値を変えて大量のパターンをテストしたい】という方向けの記事です。手っ取り早く書き方だけ知りたい方はジャンプ

はじめに

みなさん、テスト書いてますか?テストを書くのは好きですか?
私は最近テストのメリットを実感しはじめ、自らすすんで書くようになってきました。
私がテストを書くようになった話はこちらから↓
https://zenn.dev/neinc_tech/articles/8ff78955aaa85a

最近はテストを書くこと自体にも慣れてきましたが、いまだに嫌で仕方ないものがあります。
それがデータプロバイダです。データプロバイダを書くのは面倒くさい。本当に本当に面倒くさい。

というのも、リリース嫌だ怖いマンの私は、対象メソッドのありとあらゆるパスや条件を通したくなります。「いや〜書かなくても大丈夫だろうけど、怖いし一応書いとくか…」を繰り返すうちに、どんどんテストパターンが増え、データプロバイダが膨大になるのです。

ここでは、私をそんな無限コピペ地獄から救ってくれた方法をご紹介します。
同じようなことをしている記事が見あたらなかったので、同地獄に陥っている方の一助となれば幸いです。

言い訳あらため、おことわり

  • テスト駆動開発すれば?
  • テストケースが膨大になるって、それはもうプロダクトコード側に問題があるのでは?
  • copilotでテストケースを網羅できるようなメソッドにすれば?

ここらへんの話は非常にごもっともなのですが、上記ができない諸々な事情があると想定しまして、今回は一旦置かせてください。TDDしたい。

こんな時に使えるかも

実際にはもっと巨大かつ複雑なものを網羅的にテストしたい場合に有用ですが、ここでは色々と簡素化して書きます。

例えば、下記のような注文があるとします。

  • 注文番号:1
    • 商品Aをx個
    • 商品Bをy個

ここに、この注文を出荷して良いか判断するメソッドがあります。

Hoge
public function DBから出荷できる商品のみ取得する()
    {
	//出荷できる=以下のいずれにも当てはまらない
		//・注文に対してキャンセルがされている
		//・商品に対してキャンセルがされている
		//・在庫切れ
	
	$sth = $dbh->prepare(
		"SELECT '商品名' FROM `注文テーブル` 
			WHERE `注文キャンセルフラグ` IS '有効'
			AND `商品キャンセルフラグ` IS '有効'
			AND `在庫` IS '在庫あり';");
	$sth->execute();
	return $sth->fetchAll();	
    }

これを対照実験的にテストしたい場合、一部省略しますが、以下パターンが必要だとします。
※対照実験:検証対象となる項目以外は、条件を全て同じにして検証すること

▼注文テーブル

パターン 注文キャンセルフラグ 商品名 商品キャンセルフラグ 在庫
1 有効 A 有効 あり
有効 B 有効 あり
2 キャンセル A 有効 あり
キャンセル B 有効 あり
3 有効 A 有効 あり
有効 B キャンセル あり
4 有効 A キャンセル あり
有効 B 有効 あり
5 有効 A 有効 あり
有効 B 有効 なし
6 有効 A 有効 なし
有効 B 有効 あり

表内の太字が、前述した【データプロバイダに】【少しずつ値を変えて大量のパターンをテストしたい】部分です。逆に言えば、パターン1のデータ状態を基本(テンプレート)と設定した場合、太字以外はテンプレートと変わらないので、あわよくば使い回したいわけです。

そんな時に使う、以下テスト記述です。

差分だけのデータプロバイダを作る

無名関数を使います。
1, まず最も基本となるデータパターンを1つ決め、テンプレートを作成
 ※今回は上記表のパターン1を基本としました
2, テンプレートを呼び出して変数に格納
3, 以降はテストパターンによって異なる値のみを書き換える
4, 書き換えたパターンを使うよう無名関数を定義
5, 即時実行して、パラメータを書き換えてテスト実行

DataProvider
/**
* テンプレートとなる基本パターン
* ここでは、上記表のパターン1を基本とする
*/
private function dataProviderForHoge_Default()
    {
	return
		[
			'param' => [
				'注文キャンセルフラグ' => '有効',
				'商品キャンセルフラグ' => ['有効', '有効'],
				'在庫' =>  ['あり', 'あり'],
			],
			'出荷可能な商品' => ['A', 'B'],
		]
    }

/**
* 基本パターンを必要に応じて書き換える
* これが実際にテストで使われる値になります
*/
public function dataProviderForHoge()
    {
        $default = $this->dataProviderForHoge_Default();
        return
            [
                //テンプレート
                1 => $default,
                //以下、テンプレートとは異なる値を挿入する
                2 => (function ($pattern) {
                    $pattern['param']['注文キャンセルフラグ'] = 'キャンセル';
		    $pattern['出荷可能な商品'] => [];
                    return $pattern;
                }
                )($default), 
                3 => (function ($pattern) {
		    $pattern['param']['商品キャンセルフラグ'] = ['有効', 'キャンセル'];
		    $pattern['出荷可能な商品'] => ['A'];
                    return $pattern;
                }
                )($default),
                4 => (function ($pattern) {
		    $pattern['param']['商品キャンセルフラグ'] = ['キャンセル', '有効'];
		    $pattern['出荷可能な商品'] => ['B'];
                    return $pattern;
                }
                )($default),
                5 => (function ($pattern) {
		    $pattern['param']['在庫'] = ['あり', 'なし'];
		    $pattern['出荷可能な商品'] => ['A'];
                    return $pattern;
                }
                )($default),
                6 => (function ($pattern) {
		    $pattern['param']['在庫'] = ['なし', 'あり'];
		    $pattern['出荷可能な商品'] => ['B'];
                    return $pattern;
                }
                )($default),
            ];
    }

テスト側で実際に呼び出すのは書き換えた方です。

Test
public function testHoge(array $param, array $出荷可能な商品)
    {
    	$sth = $dbh->prepare(
		"INSERT INTO `注文テーブル` (`注文キャンセルフラグ`, `商品名`, `商品キャンセルフラグ`, `在庫`)
		VALUES ({$param['注文キャンセルフラグ'][0]}, '商品A', {$param['商品キャンセルフラグ'][0]}, {$param['在庫'][0]}),
			({$param['注文キャンセルフラグ'][1]}, '商品A', {$param['商品キャンセルフラグ'][0]}, {$param['在庫'][1]});");
	$sth->execute();
  
        $actual = Hoge();
        $this->assertSame($出荷可能な商品, $actual);
    }

簡単な解説

1周目は、基本となるテンプレートのデータが使われます。

private function dataProviderForHoge_Default()
    {
	return
		[
			'param' => [
				'注文キャンセルフラグ' => '有効',
				'商品キャンセルフラグ' => ['有効', '有効'],
				'在庫' =>  ['あり', 'あり'],
			],
			'出荷可能な商品' => ['A', 'B'],
		]
    }
public function dataProviderForHoge()
    {
        $default = $this->dataProviderForHoge_Default();
        return
            [
		//テンプレート
                1 => $default,
            ];
    }

2周目以降は、テンプレートと異なる値を挿入したい時のみ、キーを指定して値を書き換えます。
例えば2周目の場合、'注文キャンセルフラグ'と'出荷可能な商品'だけが書き変わり、他の値(商品キャンセルフラグ、在庫数)はテンプレートのものが使われます。

public function dataProviderForHoge()
    {
        $default = $this->dataProviderForHoge_Default();
        return
            [
                //テンプレート
                1 => $default,
                //以下、テンプレートとは異なる値を挿入する
                2 => (function ($pattern) {
                    $pattern['param']['注文キャンセルフラグ'] = 'キャンセル';
		    $pattern['出荷可能な商品'] => [];
                    return $pattern;
                }
                )($default), 

上記の最終行($default)が、無名関数の実行部分です。
引数でテンプレートを渡して、無名関数内で書き換えています。
無名関数の定義全体を()で囲えば、連想配列の右辺に無名関数を定義することができました。

メリット

この方法を使うメリットは大きくこの通りです。

  • 書き手に優しい
    • なんといっても書く量と時間の削減、無限コピペ地獄からの解放
    • 私はこれで900行必要だったデータプロバイダを、約300行まで抑えられました
  • 読み手にも優しい
    • パターンごとにどこが違うのか、何を目的にテストしているのかが分かりやすい
  • メンテする人にも優しい
    • 膨大な量のテストパターンを、ひとつひとつ間違い探ししなくて済む

ちなみに、テストケースによって書き換えたいパラメータが増えたり、配列の次元が増えた場合も、テストコード側に受け入れ体制({$param['hoge']}の部分)は必要ですが、テンプレートを変えずとも配列にキーを付け足していけば再利用できるので楽ちんです。

(余談)
この書き方を知ったことで、関数の呼び出しという概念の本質は、関数ではなく()にある、ということも知りました。面白いですね。

最後に

もっと良い書き方、楽な書き方は他にもあるとは思いますが、「網羅的なパターンをテストしたい!」「でもデータプロバイダを書く量はなるべく減らしたい!」を両立させる、あくまで手段の一つとして見ていただけたら幸いです。

データプロバイダは、書くひと大変・読むひと大変・メンテするひと大変、の三重苦な側面がありますが、三者みんなが幸せになれるデータプロバイダを作っていきたいですね。
ありがとうございました!

NE株式会社の開発ブログ

Discussion