PHPでネイティブ関数を含むコードのテスタビリティを上げる2つの方法
PHPでテストケースを作成する場合、ネイティブ関数を使っているようなコードに対してテストを実行しようとすると、どうしても環境に依存したり、実リソースにアクセスする必要が出てしまうことがあります。
この記事では、そのような問題に対する対処法を提示します。
経緯みたいなもの
先日もWeb APIをコールするPHPライブラリを書いていたのですが、HTTPをたたく部分のテストを切り離せず、もやもやしていました。
ちょっと前にPerlのTest::Timeというライブラリを教わって感動していたのですが、PHPでもネイティブ関数をオーバーライドできたらどんなにすばらしいだろう、などとぼやいていたのです。
そんなときに、@takimoにPHP 5.3ならオーバーライドできるよねって言われて、ハッと思い立ってテストに組み込む方法を考えてみたところ、割とスマートに実現できそうな方法が見つかったので、方法論の一提案として記事に書いてみます。
割とシンプルな話なので、もう既に誰かが確立しているやり方かもしれませんが、久々にブログをちゃんと書くという目的*1も含めて書いたところもあるので車輪の再発明だったらご容赦を。
また、PHP 5.4で盛り上がってるご時世にPHP 5.3な話をしちゃってて相変わらず空気読めてないけどそこはスルーで。
Thanks inspiration for @takimo !!
リソースにアクセスする関数を含むテストの問題点
たとえば、以下のようなロジックを含むオブジェクトを作成したとします。
- file_get_contents()関数でHTTPリソースを取得する。
- date()/time()関数やDateTimeオブジェクトを使って時間計測をする。
- system()関数によってシステムコマンドを実行し、結果を得る。
これらの処理に共通する点として、いずれも正しい実行結果を得るためには実際にシステムリソース・外部リソースに対してアクセスした結果を取得する必要がある、という側面があります。
このような処理を含むクラスに対してテストケースを作成した場合、該当部分の結果が実行環境に依存したものとなるため、全体として正しい結果を得るためには本当に外部リソースにアクセスせざるを得ません。
結果として、それらのテスト自体が環境に依存したものとなり、テスト実行時間の増加や余計な負荷、テスト実行自体の難易度の上昇、ひいては自動テストの再現性に支障をきたす場合すら出てきます。
これらの問題は、自動テストを行う際に厄介な障害となってしまいます。
いかにして回避するか
元々、PHPではこれらの問題に対処するために、コードそのものの抽象度を不必要に上げたり*2、無理矢理やるならrunkitのようなエクステンションでトリッキーな対応*3を行うことによって挙動を差し替える、等の対処法しかありませんでした。
現実的には、システムリソースに依存したまま実行することがほとんどだと思います。
ですが、PHP 5.3で名前空間が導入されたことで、名前空間のもとであればネイティブ関数と同名の関数を定義することが可能となりました。
これをうまく応用することで、ネイティブ関数を含むコードでもシステムとの疎結合を保ちつつ、ロジック側に余計な負担をかけずに適切なテストがかけるようになります。*4
ここでは、その方法について、さくっとやる場合ときちんとやる場合の2種類のパターンを考えます。
ケース1: 1枚のライブラリにさくっとテストを付与する方法
PHPで1枚の書き捨てに近いライブラリを書く場合、debug_backtrace()関数を使って同一ファイル内にテスト*5を記述する、という方法をとる方は多いのではないかと思います。*6
<?php namespace Hoge; class Foo { public function load($file) { return file_get_contents($file); } } if (debug_backtrace()) return; // test code. $foo = new Foo; var_dump($foo->load('http://www.example.com/')); // テストなのにwww.example.comにアクセスしてしまう!!なんとかしたい!
このような場合に、テストのときだけ関数をオーバーライドしたいと思ってdebug_backtrace()の行より後方に新しい関数を定義したとしても、関数のロードは読み込み時に行われてしまうため、通常利用の場合にまで問答無用でオーバーライドされてしまいます。
<?php namespace Hoge; class Foo { public function load($file) { return file_get_contents($file); } } if (debug_backtrace()) return; $foo = new Foo; var_dump($foo->load('http://www.example.com/')); // ここで定義しても、意図と反してテスト時以外もオーバーライドしてしまう。 function file_get_contents($name) { return 'this is test result!!'; }
これは望ましい結果ではありません。テスト実行時のみオーバーライドするには、どうすればよいでしょうか。
スコープを区切る
これを実現するためには、関数定義部分のスコープを切ります。
オーバーライドする関数の定義部分だけブロックで囲んでしまうことで、関数定義が実行時に評価されることになり、テストとして実行した場合のみオーバーライドすることができます。
<?php namespace Hoge; class Foo { public function load($file) { return file_get_contents($file); } } if (debug_backtrace()) return; { // これで、テスト時のみ関数定義が有効になる。 function file_get_contents($name) { return 'this is test result!!'; } } $foo = new Foo; var_dump($foo->load('http://www.example.com/')); // this is test result!! という結果がかえってくる。
これは特別な命令をしている訳ではなく、ブロックでさえ区切れていればどんな方法でもかまいません。
無名関数に関数定義を閉じ込める
ブロックで区切ってしまう場合に、特に個人的におすすめなのは、無名関数に閉じ込める記述方法です。
この方法の利点は、無名関数の実行部分をコメントアウトするだけでオーバーライドするかどうかを切り替えられるので、実リソースにアクセスする場合/しない場合のテストを手元で手軽に切り替えできるところです。
<?php namespace Hoge; class Foo { public function load($file) { return file_get_contents($file); } } if (debug_backtrace()) return; $override = function() { function file_get_contents($name) { return 'this is test result!!'; } }; $override(); // ここをコメントアウトすると、オーバーライドする/しないを切り替えられる。 $foo = new Foo; var_dump($foo->load('http://www.example.com/'));
蛇足
ちなみに。。。ブロックで区切る、という件で身もふたもない話をしてしまうと、実はif(!debug_backtrace()) { ... } と全体をかこっておけば最初から問題はないのです。
が、このためにテストコード全体を1段ネストするのはかっこ悪いので、定義部分だけを囲う方がそれっぽくてよいかな、なんて思ってます。
ケース2: キチンとしたテストに組み込む方法
さて、まずはさくっと書く場合について紹介しましたが、世の中の大半のテストはPHPUnit等を使ってテストファイル群を作って書くものが大勢を占めています。
仕事で書く、なんてことになった場合もそうでしょう。
きちんとしたテストとして実行する場合、上記のような1ファイル内でのトリックは使えません。
ではどうすればよいのか?
名前空間の多重定義
このような場合には、名前空間の多重定義が利用できます。
名前空間の多重定義とは、同一の名前空間を複数のファイルに分割して、別々の場所で定義することです。*7
<?php // fileA.php namespace Hoge; class Foo { public function load($file) { return file_get_contents($file); } } ?> ========== <?php // fileB.php namespace Hoge; class Bar { // ... } // 定義したファイルは違うが、FooもBarもHoge名前空間に属する。
上記のように、複数のファイルで同一名の名前空間を複数定義できる仕様を活用することで、テスト実行時のみネイティブ関数をオーバーライドすることができるようになります。
具体的には、ライブラリ側の名前空間とおなじ名前空間をテストファイル側にも用意し、そちら側で新たにオーバーライドしたい関数を定義することで、テストファイルを実行した場合だけ関数をオーバーライドできます。
<?php // Lib.php namespace Hoge; class Foo { public function load($file) { return file_get_contents($file); } } ?> ========== <?php // TestCase.php namespace Test\Hoge; require_once 'Lib.php'; use Hoge; $foo = new Foo; var_dump($foo->load('http://www.example.com/')); // オーバーライドされた結果がかえってくる! var_dump(file_get_contents('http://www.example.com/')); // www.example.comにとりにいく namespace Hoge; // useの後に書いてあっても問題ない。 function file_get_contents($name) { return 'this is test result!!'; } var_dump(file_get_contents('http://www.example.com/')); // オーバーライドされた結果がかえってくる!
このように書くと、ライブラリ単体で利用されているときには普通に利用することができ、テスト実行時のみfile_get_contents()がオーバーライドされます。
さらに、オーバーライドの定義部分を別ファイルとして扱い、テストの再必要に応じて組み込む、という使い方も可能です。
<?php // Lib.php namespace Hoge; class Foo { public function load($file) { return file_get_contents($file); } } ?> ========== <?php // Override.php namespace Hoge; function file_get_contents($name) { return 'this is test result!!'; } ?> ========== <?php //TestCase.php namespace Test\Hoge; require_once 'Lib.php'; require_once 'Override.php'; use Hoge; $foo = new Foo; var_dump($foo->load('http://www.example.com/')); // オーバーライドされた結果がかえってくる! var_dump(file_get_contents('http://www.example.com/')); // www.example.comにとりにいく
加えて、以下のようにラッピングする形で利用する方法もあり得ます。
<?php // Lib.php namespace Hoge; class Foo { public function load($file) { return file_get_contents($file); } } ?> ========== <?php // TestWrapper.php namespace Hoge; require_once 'Lib.php'; function file_get_contents($name) { return 'this is test result!!'; } ?> ========== <?php //TestCase.php namespace Test\Hoge; require_once 'TestWrapper.php'; use Hoge; $foo = new Foo; var_dump($foo->load('http://www.example.com/')); // オーバーライドされた結果がかえってくる! var_dump(file_get_contents('http://www.example.com/')); // www.example.comにとりにいく
どの方法がより良いかは、状況に合わせて&今後のノウハウの積み重ねによって、ベストプラクティスが見えてくるだろうと思います。
さいご
このようにnamespaceを活用することで、これまでが外部リソースや環境にに依存せざるを得なかった部分のテストを環境から切り離することができるようになります。
もちろん、実装の仕方次第では必ずしも切り離せるパターンばかりではない*8と思いますが、わざわざ既存の関数を書き換えてまでしっかりしたテストを書こうとする皆さんなら、きっと極力「テストできる形」で実装を試みるはずだと思う*9ので、心配はないはずです。
さてさて、そんな訳で大変長くなってしまいましたが、システムに依存しないテストを書いて、心安らかな日々をお過ごしください。
Happy Testing Life!!
*1:文体でわかると思うけどこっちがメインだったりする。。。
*2:たとえば、対象となるネイティブ関数の呼び出し部分だけオブジェクト化して、モックと差し替える、など。
*3:runkitを要求するテストなんて見たことも書いたことないけどね!できるよ!ってだけです。
*4:なお、本記事で「ネイティブ関数のオーバーライド」と呼んでいるのは、名前空間の中でネイティブ関数と同名の関数を定義・利用することを指します。本当にグローバル空間の関数がオーバーライドできる訳ではないので、あしからず。
*5:テストともとれぬサンプルソースであることがほとんどでしょうが。
*6:Pythonの__name__ == "__main__"と似たようなもので、該当のファイルが直接実行された場合にはdebug_backtrace()下部のテストコードが読み込まれ、外部からinclude等で読み込まれた場合には、該当行以降のコードが実行されなくなります。
*7:多重定義という呼び方が正しいかはわかりません。明確な用語が定義されていない気がします。
*8:たとえば、file_get_contents()を使ってHTTPリソースを取りにいく処理を実装するときに、HTTPのレスポンスヘッダ取得を$http_response_headerローカル変数から取得するようにしているような場合には、オーバーライドした関数を使っても正しい処理結果を得られないでしょう。
*9:先の例で言えば、ローカル変数の代わりにstream_get_meta_data()関数を使って取得する形で実装する、など。