最近、
echo 'Result=OK';
exit(0);
みたいなコードが含まれたメソッドを改善してPHPUnitでテストできるようにしました。ただ、最初から実装のほうに手を加えたので、最初にテストが書ければもっと良かったかもしれない、と思い調べてみるといくつか発見があったのでまとめてみます。サンプルコードはCakePHP3アプリですが、他のフレームワークでも似たような手順で改善できるのではないでしょうか。
改善対象のコード
<?php
namespace App\Controller;
use Cake\Validation\Validation;
class SomethingController extends AppController
{
public function execFoo()
{
if (!Validation::email($this->request->getData('email'))) {
echo "Result=NG\n";
exit;
}
echo "Result=OK\n";
exit;
}
}
ここでは主な処理がバリデーションになってますが、本来はここでアプリケーションで重要なことを処理しているとします。その結果 "Result=NG\n"
か "Result=OK\n"
を返します。テンプレートファイルを用意するまでもない場合には確かにこんな風に書きたい。
HTTP 経由での動作は問題ないですが、ユニットテストに含めることができません。
以下のようなテストコードを書いても、テストが途中で中断してしまいます。
<?php
namespace App\Test\TestCase\Controller;
use Cake\TestSuite\IntegrationTestCase;
class SomethingControllerTest extends IntegrationTestCase
{
public function testExecFooSucceed()
{
$this->post('/something/exec-foo', ['email' => 'test@example.com']);
$this->assertResponseCode(200);
$this->assertResponseEquals("Result=OK\n");
}
public function testExecFooFailed()
{
$this->post('/something/exec-foo', ['email' => 'test@example']);
$this->assertResponseCode(200);
$this->assertResponseEquals("Result=NG\n");
}
}
$ vendor/bin/phpunit tests/TestCase/Controller/SomethingControllerTest.php
PHPUnit 6.5.6 by Sebastian Bergmann and contributors.
Result=OK
テストが途中で終了して、パスしたのかわかりません。
この echo して exitするコードをテストしながら改善してみます。
(1)最初からテストを書いてみる場合
プロジェクト作成
$ composer create-project --prefer-dist cakephp/app exit_code_improve
$ composer require --dev phpunit/phpunit:"^5.7|^6.0"
2コマンドで準備完了です。
最初のテスト
SampleController.php
は一番最初の「改善対象のコード」を保存して、テストコードは以下のように書きます。
<?php
namespace App\Test\TestCase\Controller;
use Cake\Http\Client;
use Cake\Http\Client\Request;
use Cake\TestSuite\IntegrationTestCase;
class SomethingControllerTest extends IntegrationTestCase
{
public function testExecFooSucceed()
{
$request = new Request(
'http://localhost:8765/something/exec-foo',
Request::METHOD_POST,
[],
['email' => 'test@example.com']
);
$client = new Client();
$response = $client->send($request);
$this->assertSame(200, $response->getStatusCode());
$this->assertSame("Result=OK\n", (string)$response->getBody());
}
public function testExecFooFailed()
{
$request = new Request(
'http://localhost:8765/something/exec-foo',
Request::METHOD_POST,
[],
['email' => 'test@example']
);
$client = new Client();
$response = $client->send($request);
$this->assertSame(200, $response->getStatusCode());
$this->assertSame("Result=NG\n", (string)$response->getBody());
}
}
HTTP経由でテストすれば、exit
しててもテストできます。
ビルトインサーバーを起動して、テスト実行してみます。
$ bin/cake server
別のコンソールで
$ vendor/bin/phpunit tests/TestCase/Controller/SomethingControllerTest.php
PHPUnit 6.5.6 by Sebastian Bergmann and contributors.
.. 2 / 2 (100%)
Time: 85 ms, Memory: 8.00MB
OK (2 tests, 4 assertions)
実装を改善する
テストで振る舞いを確認できるようになったので、実装を直して、exit
を取り除きます。
<?php
namespace App\Controller;
use Cake\Validation\Validation;
class SomethingController extends AppController
{
public function execFoo()
{
if (!Validation::email($this->request->getData('email'))) {
return $this->response->withStringBody("Result=NG\n");
}
return $this->response->withStringBody("Result=OK\n");
}
}
先ほどと同様にテスト実行するとパスすることが確認できます。
echo "Result=NG\n";
exit;
を
return $this->response->withStringBody("Result=NG\n");
に変更しました。CakePHP2以降では Response
オブジェクトを返すと、ビューレンダリングせずに応答を返すことができます。
テストコードを改善する
実装から exit
がなくなりました。テスト時にビルトインサーバーを起動する必要があるのもつらいので、次はテストを改善します。
<?php
namespace App\Test\TestCase\Controller;
use Cake\TestSuite\IntegrationTestCase;
class SomethingControllerTest extends IntegrationTestCase
{
public function testExecFooSucceed()
{
$this->post('/something/exec-foo', ['email' => 'test@example.com']);
$this->assertResponseCode(200);
$this->assertResponseEquals("Result=OK\n");
}
public function testExecFooFailed()
{
$this->post('/something/exec-foo', ['email' => 'test@example']);
$this->assertResponseCode(200);
$this->assertResponseEquals("Result=NG\n");
}
}
最初に書きたかったテストコードです。テストにもパスします。
$ vendor/bin/phpunit tests/TestCase/Controller/SomethingControllerTest.php
PHPUnit 6.5.6 by Sebastian Bergmann and contributors.
.. 2 / 2 (100%)
Time: 76 ms, Memory: 10.00MB
OK (2 tests, 10 assertions)
以上、振る舞いが変わらないことをテストで確認しながら実装を改善しました。
(2)HTTP経由でのテストが難しい場合・もう少し手順を細かくする
最初、こちらのパターンで手順を考えていました。HTTP経由でのテストは確実ですがフィクスチャーなどの設定が難しい場合もあるかもしれません。その場合の方法です。
先にテストを書くことをあきらめて、実装を最小限変更する
まず、直接 exit
を呼び出さないようにしましょう。
<?php
namespace App\Controller;
use Cake\Validation\Validation;
class SomethingController extends AppController
{
public function execFoo()
{
if (!Validation::email($this->request->getData('email'))) {
echo "Result=NG\n";
$this->autoRender = false;
$this->response->stop();
return;
}
echo "Result=OK\n";
$this->autoRender = false;
$this->response->stop();
return;
}
}
echo ...
はそのままに exit;
を
$this->autoRender = false;
$this->response->stop(0);
return;
に変更しました。HTTP Clientなどで振る舞いが変わってないことが確認できるかと思います。
テストを追加する
<?php
namespace App\Test\TestCase\Controller;
use Cake\TestSuite\IntegrationTestCase;
class SomethingControllerTest extends IntegrationTestCase
{
public function controllerSpy($event, $controller = null)
{
parent::controllerSpy($event, $controller);
$this->_controller->response = $this->getMockBuilder('Cake\Http\Response')
->setMethods(['stop'])
->getMock();
}
public function testExecFooSucceed()
{
$this->post('/something/exec-foo', ['email' => 'test@example.com']);
echo $this->_getBodyAsString();
$this->assertResponseCode(200);
$this->expectOutputString("Result=OK\n");
}
public function testExecFooFailed()
{
$this->post('/something/exec-foo', ['email' => 'test@example']);
echo $this->_getBodyAsString();
$this->assertResponseCode(200);
$this->expectOutputString("Result=NG\n");
}
}
最初の例と違い、PHPプロセス内でテストを行えるようになっています。
controllerSpy()
をオーバーライドすると、コントローラーの依存コンポーネントをモック化できますので、Response::stop()
で何もしないようにします。
テスト結果の検証では expectOutputString()
を使っています。内部で出力バッファリング関数 を使っているので echo
した文字列も検証できます。
検証の前に echo $this->_getBodyAsString();
と書いています。次の手順で、 echo
による出力を Response
オブジェクトに変更してもテストが通るようにする準備です。expectOutputString()
での検証だと、テストコード中のdebug()
などもテスト結果に影響してしまうので、echo
も取り除いた方が良いです。1
テスト実行してパスすることを確認します。
$ vendor/bin/phpunit tests/TestCase/Controller/SomethingControllerTest.php
PHPUnit 6.5.6 by Sebastian Bergmann and contributors.
.. 2 / 2 (100%)
Time: 74 ms, Memory: 10.00MB
OK (2 tests, 10 assertions)
実装を改善する
1つ目の例と同じコードになります。
<?php
namespace App\Controller;
use Cake\Validation\Validation;
class SomethingController extends AppController
{
public function execFoo()
{
if (!Validation::email($this->request->getData('email'))) {
return $this->response->withStringBody("Result=NG\n");
}
return $this->response->withStringBody("Result=OK\n");
}
}
$ vendor/bin/phpunit tests/TestCase/Controller/SomethingControllerTest.php
PHPUnit 6.5.6 by Sebastian Bergmann and contributors.
.. 2 / 2 (100%)
Time: 71 ms, Memory: 10.00MB
OK (2 tests, 10 assertions)
テストコードを改善する
こちらも1つ目の例の最終形と同じです。
<?php
namespace App\Test\TestCase\Controller;
use Cake\TestSuite\IntegrationTestCase;
class SomethingControllerTest extends IntegrationTestCase
{
public function testExecFooSucceed()
{
$this->post('/something/exec-foo', ['email' => 'test@example.com']);
$this->assertResponseCode(200);
$this->assertResponseEquals("Result=OK\n");
}
public function testExecFooFailed()
{
$this->post('/something/exec-foo', ['email' => 'test@example']);
$this->assertResponseCode(200);
$this->assertResponseEquals("Result=NG\n");
}
}
$ vendor/bin/phpunit tests/TestCase/Controller/SomethingControllerTest.php
PHPUnit 6.5.6 by Sebastian Bergmann and contributors.
.. 2 / 2 (100%)
Time: 80 ms, Memory: 10.00MB
OK (2 tests, 10 assertions)
まとめ
テストしやすいコードにするために、テストで振る舞いを確認しながらexit
とecho
を取り除きました。
-
ビューテンプレート内の
echo
を除く。 ↩