Edited at

BEAR.Sundayから学ぶテストプラクティス

More than 3 years have passed since last update.


前置き

先日BEAR.Sunday作者の koriym さん とSymfony勉強会でご一緒して話す機会があり、


  • echo を テストするにはどうしたいいのか?

  • HTTPレスポンスヘッダーに想定通りのものが出力されることを確認(header() 関数をテスト)するにはどうしたらいいのか?

※ ここで言うテストとはユニットテストを書くということです

という話をしました。

自分自身気づきもあったし、興味深かったので書くことにしました。


解説

まず、テスト対象であるHttpResponderというクラスについて見てみましょう。


プロダクトコード HttpResponderクラス

https://github.com/koriym/BEAR.Sunday/blob/develop-2/src/Provide/Transfer/HttpResponder.php

<?php

/**
* This file is part of the *** package
*
* @license http://opensource.org/licenses/bsd-license.php BSD
*/

namespace BEAR\Sunday\Provide\Transfer;

use BEAR\Resource\ResourceObject;
use BEAR\Sunday\Extension\Transfer\TransferInterface;

class HttpResponder implements TransferInterface
{
public function __invoke(ResourceObject $resourceObject)
{
// code
http_response_code($resourceObject->code);

// header
foreach ($resourceObject->headers as $label => $value) {
header("{$label}: {$value}", false);
}
// body
echo (string) $resourceObject;
}
}

このクラスは名前の通りHTTPレスポンスの「HTTPステータスコード」「ヘッダー」「ボディー」の出力に責任を持つクラスとなっています。実装はシンプルですね。

ただしここで問題。


  • http_response_code()や、header()関数が呼ばれること

  • echo で文字列が出力されたこと

は果たしてどのようにしてテストすればよいのでしょうか?

みなさんこのテストコードが書けるでしょうか?

もし書くことができれば、普段からテストコードをバリバリ書いているTDDの猛者に違いありません(笑)

それでは順番に解説していきます。


テストコード HttpResponderTestクラス

テストコードはこちらです。

https://github.com/koriym/BEAR.Sunday/blob/develop-2/tests/Provide/Transfer/HttpResponderTest.php

<?php

namespace BEAR\Sunday\Provide\Transfer;

use BEAR\Sunday\Fake\Resource\FakeResource;

class HttpResponderTest extends \PHPUnit_Framework_TestCase
{
/**
* @var HttpResponder
*/

private $responder;

public function setUp()
{
parent::setUp();
$this->responder = new FakeHttpResponder;
FakeHttpResponder::reset();
}

public function testTransfer()
{
$ro = (new FakeResource)->onGet();
$ro->transfer($this->responder);
$expectedArgs = [['Cache-Control: max-age=0', false]];
$this->assertEquals($expectedArgs, FakeHttpResponder::$headers);
$expect = '{"greeting":"hello world"}';
$actual = FakeHttpResponder::$content;
$this->assertSame($expect, $actual);
}
}

FakeHttpResponderというクラスを使うことで問題を解決していることが分かります。

勘の鋭い方はこの時点で気付かれているかもしれませんね。

実はFakeHttpResponderというクラスは、本来のテスト対象であるHttpResponderクラスを継承しています。

テスト対象のクラスを継承したテスト用のクラスを用意し、それに対してテストを実行することで間接的にテストを行います。

これは直接テストしにくいクラスのテストを書く場合に、よく用いられるテストテクニックの1つです。


FakeHttpResponderクラス

それでは実際にコードを見てみましょう。

https://github.com/koriym/BEAR.Sunday/blob/develop-2/tests/Provide/Transfer/FakeHttpResponder.php

<?php

namespace BEAR\Sunday\Provide\Transfer;

use BEAR\Resource\ResourceObject;

require_once __DIR__ . '/header.php';

class FakeHttpResponder extends HttpResponder
{
// スタティックフィールドを追加しています
public static $headers = [];
public static $content;

public static function reset()
{
static::$headers = [];
static::$content = null;
}

// 元のメソッドをオーバーライドしてます
public function __invoke(ResourceObject $resourceObject)
{
ob_start();
parent::__invoke($resourceObject);
$body = ob_get_clean();
self::$content = $body;
}
}

この継承後のFakeHttpResponderクラスでは、publicな$headers$contentの2つのスタティックフィールドを定義しています。

オブジェクトの内部状態にpublicに公開しアクセスすることができるようにし、header関数や echo が呼び出された時に、このスタティックフィールドに保持している値を検証することでテストできるようになります。


echoのテスト

それではechoのテストコードを実装する解説に入ります。

先ほどのFakeHttpResponderのコードをもう一度見てみます。

    // FakeHttpResponder クラスのコードから抜粋

public function __invoke(ResourceObject $resourceObject)
{
ob_start();
parent::__invoke($resourceObject);
$body = ob_get_clean();
self::$content = $body;
}

HttpResponderクラスの__invoke()メソッドをオーバーライドし、親のメソッドを呼び出す際に、ob_start()ob_get_cleanで囲っています。

ob_start() を使うことで本来ならecho文で出力される出力内容をバッファリングし、ob_get_clean()でその値を取り出します。そしてその値を$contentフィールドに保持するようにしています。

そうすれば、あとはテストコードで確認するだけです。

class HttpResponderTest extends \PHPUnit_Framework_TestCase

{
public function testTransfer()
{
// ここより上は省略
$expect = '{"greeting":"hello world"}';
$actual = FakeHttpResponder::$content;
$this->assertSame($expect, $actual);
}
}

簡単ですね。


headerのテスト

さて、headerのテストです。

headerのテストですが、ここではphp標準関数であるheader関数を呼び出していてPHPUnitではどう頑張ってもHTTPヘッダーが想定通りに返っているのかテストできそうにありません。

この問題はどう解決すればよいのか?

ここでは更に別のテクニックを利用することで問題を解決します。

これはPHPならではですが「クラスと同一の名前空間に関数を定義するとそちらの関数が優先で使われる」というPHPの言語仕様を利用してテストを行います。

この仕様を利用することでPHPの標準関数をオーバーライドすることができ、自由にテストを行うことができます。

【参考】

PHP版レガシーコード改善に役立つ新パターン

http://www.slideshare.net/techblogyahoo/php-wewlcjp

PHPでネイティブ関数を含むコードのテスタビリティを上げる2つの方法

http://yudoufu.hatenablog.jp/entry/20110808/1312828535

それでは実際のコードを見ていきましょう。

これまで読み飛ばしてきましたが、何やらFakeHttpResponder.phpファイルの上部でheader.phpというファイルを読み込んでいました。

中身は以下のとおり

https://github.com/koriym/BEAR.Sunday/blob/develop-2/tests/Provide/Transfer/header.php

<?php

namespace BEAR\Sunday\Provide\Transfer;

function header($string, $replace = true, $http_response_code = null)
{
FakeHttpResponder::$headers[] = func_get_args();
}

ここでは、BEAR\Sunday\Provide\Transferという名前空間に、header関数を定義しPHP標準関数のであるheader関数を置き換えています。

このheader関数が呼び出された際にFakeHttpResponder$headers[]フィールドにheader()関数が呼び出された時の引数を保持するようにしています。

テストコードを再度みてみます。

class HttpResponderTest extends \PHPUnit_Framework_TestCase

{
public function testTransfer()
{
$ro = (new FakeResource)->onGet();
$ro->transfer($this->responder);
$expectedArgs = [['Cache-Control: max-age=0', false]];
$this->assertEquals($expectedArgs, FakeHttpResponder::$headers);
}
}

FakeResourceの実装を追ってもらうと分かりますが、$expectedArgsで書いている通り確かにheader('Cache-Control: max-age=0', false)で呼び出しを行っています。

あとは、assertEqualsで確認するだけです。


まとめ


  • テストしにくいクラスはテスト用のクラスを用意し、テスト可能になるようにメソッドをオーバーライドしたり、フィールドなどを定義したりしましょう。

  • PHPの標準関数を置き換えないとどうしてもテストできない場合には同一の名前空間に同一のメソッドを定義しましょう

  • (稀なケースだとは思いますが) echo文をテストしたい場合にはob_start()を利用しましょう

以上、BEAR.Sundayのテストコードから実践的なテスト手法を学びました。


最後に非常に参考になった話

これは koriym さんからお聞きした話ですが、「よくテストコードの名前空間に『Tests』と付けていることが多いけどプロダクトコードは同一の名前空間がよい」とのことです。

理由は2つ


  • 別の名前空間で書いてしまった場合、プロダクトコードのテストを書くためにわざわざuse文を使わないいけない

  • 今回のようにphp標準関数を置き換えたい場合など、別の名前空間だと面倒だということ

なるほど!と思わされる内容でした。

テストのテクニックを学ぶにはテストコードを読むのが一番です。

有名なライブラリーや、フレームワークのテストコードはどんどん読むようにしてみてください。