echo + exit しているPHPコードをユニットテストで保護しながら改善する

最近、

```php
echo 'Result=OK';
exit(0);
```

みたいなコードが含まれたメソッドを改善してPHPUnitでテストできるようにしました。ただ、最初から実装のほうに手を加えたので、最初にテストが書ければもっと良かったかもしれない、と思い調べてみるといくつか発見があったのでまとめてみます。サンプルコードはCakePHP3アプリですが、他のフレームワークでも似たような手順で改善できるのではないでしょうか。

# 改善対象のコード

```src/Controller/SomethingController.php
<?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 経由での動作は問題ないですが、ユニットテストに含めることができません。
以下のようなテストコードを書いても、テストが途中で中断してしまいます。

```tests/TestCase/Controller/SomethingControllerTest.php
<?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");
    }
}
```

```console
$ vendor/bin/phpunit tests/TestCase/Controller/SomethingControllerTest.php
PHPUnit 6.5.6 by Sebastian Bergmann and contributors.

Result=OK
```

テストが途中で終了して、パスしたのかわかりません。
この echo して exitするコードをテストしながら改善してみます。

# （１）最初からテストを書いてみる場合

## プロジェクト作成

```console
$ composer create-project --prefer-dist cakephp/app exit_code_improve
$ composer require --dev phpunit/phpunit:"^5.7|^6.0"
```
2コマンドで準備完了です。

## 最初のテスト

`SampleController.php` は一番最初の「改善対象のコード」を保存して、テストコードは以下のように書きます。

```tests/TestCase/Controller/SomethingControllerTest.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` しててもテストできます。
ビルトインサーバーを起動して、テスト実行してみます。

```console
$ bin/cake server
```
別のコンソールで

```console
$ 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`を取り除きます。

```src/Controller/SomethingController.php
<?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");
    }
}
```

先ほどと同様にテスト実行するとパスすることが確認できます。

```php
echo "Result=NG\n";
exit;
```
を

```php
return $this->response->withStringBody("Result=NG\n");
```
に変更しました。CakePHP2以降では `Response`オブジェクトを返すと、ビューレンダリングせずに応答を返すことができます。


## テストコードを改善する

実装から `exit` がなくなりました。テスト時にビルトインサーバーを起動する必要があるのもつらいので、次はテストを改善します。

```tests/TestCase/Controller/SomethingControllerTest.php
<?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");
    }
}
```

最初に書きたかったテストコードです。テストにもパスします。

```console
$ 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` を呼び出さないようにしましょう。

```src/Controller/SomethingController.php
<?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;` を

```php
$this->autoRender = false;
$this->response->stop(0);
return;
```

に変更しました。HTTP Clientなどで振る舞いが変わってないことが確認できるかと思います。

## テストを追加する

```tests/TestCase/Controller/SomethingControllerTest.php
<?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()` を使っています。内部で[出力バッファリング関数](http://php.net/manual/ja/ref.outcontrol.php) を使っているので `echo`した文字列も検証できます。

検証の前に `echo $this->_getBodyAsString();` と書いています。次の手順で、 `echo` による出力を `Response` オブジェクトに変更してもテストが通るようにする準備です。`expectOutputString()`での検証だと、テストコード中の`debug()`などもテスト結果に影響してしまうので、`echo`も取り除いた方が良いです。[^1]

[^1]: ビューテンプレート内の`echo`を除く。

テスト実行してパスすることを確認します。

```console
$ 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つ目の例と同じコードになります。

```src/Controller/SomethingController.php
<?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");
    }
}
```

```console
$ 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つ目の例の最終形と同じです。

```tests/TestCase/Controller/SomethingControllerTest.php
<?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");
    }
}
```
```console
$ 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`を取り除きました。