はじめに
最近はプライベートではGoのコードを書いていることが多いのですが、GoではTable Driven Testというやり方が知られています。
このテストコードの書き方が個人的にとても分かりやすく気に入っているので、PHPでテスト書く時も参考にしています。
Table Driven Testを意識してテストコードを書くようにしてから、コードレビュー時に「テストが分かりやすくなった」と言ってもらえたりすることもあったので、ご紹介したいと思います。
Table Driven Testとは
Goの公式Githubリポジトリで紹介されているテスト手法の一つです。
一般的には「Data Driven Test」や「Parameterized Test」とも呼ばれているみたいで、特に真新しいしいやり方ではないようです。
PHPのBDDテストフレームワークであるBehatでも、テーブル形式のテスト方法が用意されていたりもします。
Goでのサンプルコード
GoのTable Driven Testで掲載されているサンプルコードは若干見づらいのですが、他のテストに関するページで分かりやすいコードが紹介されていたので、そちらを紹介します。
package stringutil
import "testing"
func TestReverse(t *testing.T) {
cases := []struct {
in, want string
}{
{"Hello, world", "dlrow ,olleH"},
{"Hello, 世界", "界世 ,olleH"},
{"", ""},
}
for _, c := range cases {
got := Reverse(c.in)
if got != c.want {
t.Errorf("Reverse(%q) == %q, want %q", c.in, got, c.want)
}
}
}
ちょっとGoのコードに慣れていない人には読みづらいかもしれませんが、「入力した文字列の順番を逆に並び替える」という処理になります。
注目して欲しい点は以下の通りです。
- テスト対象の関数の入力と出力を構造を定義している
- 構造の定義の直後に実データも定義している
データ構造の定義と実データのセットがとても近い場所が行われているので、テストデータの内容がとても分かりやすいです。
また、入力と出力というシンプルなデータ構造をテストデータとすることにより、この関数がどういった機能を持っているのかというが一目瞭然だと思います。
PHPでやってみる
上のGoのコードを見て、「これってPHPUnitのDataProviderの機能に似ているな」と感じました。
実際に書いてみようと思います。
プロダクションコード
<?php
/**
* This file is part of the MyVendor.MyPackage
*
* @license http://opensource.org/licenses/MIT MIT
*/
namespace MyVendor\MyPackage;
class MyPackage
{
public function reserve($string){
preg_match_all('/./us', $string, $array);
return join('', array_reverse($array[0]));
}
}
マルチバイト文字を含む文字列の並び替えというのが、PHPの標準関数に存在しなかったので簡単に実装してみました。
UTF8限定です。
テストコード
<?php
namespace MyVendor\MyPackage;
class MyPackageTest extends \PHPUnit_Framework_TestCase
{
/**
* @var MyPackage
*/
protected $skeleton;
protected function setUp()
{
parent::setUp();
$this->skeleton = new MyPackage;
}
/**
* @param $in
* @param $expected
* @dataProvider providerReserveTestData
*/
public function testReverse($in, $expected)
{
$this->assertEquals($expected, $this->skeleton->reserve($in));
}
public function providerReserveTestData()
{
return [
'半角英数字のみ' => [
'in' => 'Hello, world',
'expected' => 'dlrow ,olleH'
],
'マルチバイト文字含む' => [
'in' => 'Hello, 世界',
'expected' => '界世 ,olleH'
],
'空' => [
'in' => '',
'expected' => ''
],
];
}
}
PHPではデータ構造の定義とデータの初期化を同時に行うことは出来ないので、こんな感じに連想配列にしてテストデータを渡すようにしてみました。
これでどういった値を入力すると、どんな結果が得られるかが分かりやすくなっていて良い感じじゃないでしょうか。
テストには関係ないのですが、連想配列のキーにどういったテストデータなのかの説明を書くようにもしています。
そうすることで、テスト時にどのデータでエラーになったかが分かりやすくなります。
$ ./vendor/bin/phpunit
PHPUnit 5.7.19 by Sebastian Bergmann and contributors.
...F 4 / 4 (100%)
Time: 202 ms, Memory: 6.00MB
There was 1 failure:
1) MyVendor\MyPackage\MyPackageTest::testReverse with data set "マルチバイト文字含む" ('Hello, 世界', '界世 ,OlleH')
Failed asserting that two strings are equal.
--- Expected
+++ Actual
@@ @@
-'界世 ,OlleH'
+'界世 ,olleH'
/path/to/tests/MyPackageTest.php:31
FAILURES!
Tests: 4, Assertions: 4, Failures: 1.
Table Driven Testでテストを書きやすいようにプロダクションコードを設計する
プロダクションコードがTable Driven Testでテストしやすい形になっているということは、以下のことが満たされているコードではないかと思っています。
- 入力及び出力が必要最低限に抑えられている
- 出力に変化を与える要素が全て入力に含まれている
- 出力が何かしら存在する
- 依存する対象がインターフェイスになっている
入力及び出力が必要最低限に抑えられている
これは単純に入出力が多いとテストデータを作るのが大変になるからです。
入出力が多い場合は、テスト対象が担う役割が多過ぎる可能性があるのかなと疑い、機能分割出来ないか考えてみることにしています。
出力に変化を与える要素が全て入力に含まれている
入力に明示的に現れていない、暗黙的な入力が出力結果に影響を及ぼしているとテストしづらいことが多いです。
暗黙的な入力の具体例としては、
- インスタンスのプロパティ
- 環境変数
- グローバル変数
- 現在の時間
といったところでしょうか。
こういった暗黙的な入力が出力結果に影響を与えてしまう場合は、引数に含められないか検討してみます。
また、これらの依存の排除がよりコードを複雑にしてしまったりして必ずしもいい結果を及ぼさないこともあるかと思いますので、出来るだけ検討してみるという感じで進めています。
出力が何かしら存在する
これは単純に出力結果が無いと検証しづらいからですね。
出力結果が無い場合の代表例が
- ファイル作成
- 標準出力への出力
といった外部へ出力するケースかと思います。
こういうケースは FileWriter
や StdoutWriter
のようなものを用意して、それらのオブジェクトに出力させるように実装して、テストの時にはモック化するというのが一般的かと思います。
ちなみにGoの場合は io.Writer
というインターフェイスが公式で用意されているので、それを利用することが一般的です。
依存する対象がインターフェイスになっている
インターフェイスに依存しているということは、モックを作りやすい=テストしやすいということです。
PHPUnitのモックは具象クラスも上手くモックしてくれることも多いですが、極力インターフェイスに依存するほうが望ましいかなと思います。
具体的には、
- ライブラリを利用する時にインターフェイスが用意されていない
- 外部のWeb APIを直接利用
といった場合でしょうか。
このケースも出来るだけインターフェイスに依存できないか検討してみるようにしています。
おわりに
如何でしたでしょうか?
Table Driven Testでテストしづらいコードは、良くないコードである
とまでは一概にはいえないかもしれませんが、設計を見直すべきサインになっている場合も結構あるというのが個人的な実感です。
実際の開発現場では色々な要因があるのでなんでもかんでもTable Driven Testでやるべきだとは思いませんし、テストにおいてもYAGNI精神とのバランスは大事かなとも思います。
Table Driven Testを上手く利用することで、よりよいコードを書く手助けになると思いますので、今後も上手に使っていけたらなと思います。
あと、このように他の言語で学んだことをPHPなどで流用して実践出来たりすると楽しいなと思いました。
今後も他の言語をどんどん触って、いろんなエッセンスを日々のプログラミングに取り入れていけたらなと思います!