Laravel と PHPUnit でテスト駆動開発してみた ~ 第3章 : コントローラのテスト

PHPUnit console その2

まででモデルの実装とテスト DB の作成を終えました。
今回はコントローラの作成を行なっていきます。


ところでコントローラの役目といえば、モデルとルーティングを利用して適切なビューを呼び出すこと。逆に言えば、コントローラ単独では何もできません。
お気づきでしょうか、もう単体テストの範疇を超え、結合テストの範囲に入ってきます。
とは言っても、ファイルの置き場所が tests\Unit から tests\Feature に変わるだけで、書き方は一切変わりません。

テストを書く

今回は --unit の指定を消します。

>php artisan make:test HostControllerTest
Test created successfully.
<?php

namespace Tests\Feature;

use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\WithFaker;
use Tests\TestCase;

class HostControllerTest extends TestCase
{
    public function testPing()
    {
        $response = $this->get('/ping/riina-k.me');
        $response->assertStatus(200);
    }
}
>vendor\bin\phpunit
PHPUnit 8.5.0 by Sebastian Bergmann and contributors.

.....F                                                              6 / 6 (100%)


Time: 1.3 seconds, Memory: 18.00 MB

There was 1 failure:

1) Tests\Feature\HostControllerTest::testExample
Expected status code 200 but received 404.
Failed asserting that false is true.

C:\xampp\htdocs\_practice\laravel-tdd\vendor\laravel\framework\src\Illuminate\Fo
undation\Testing\TestResponse.php:183
C:\xampp\htdocs\_practice\laravel-tdd\tests\Feature\HostControllerTest.php:15

FAILURES!
Tests: 6, Assertions: 14, Failures: 1.

コントローラを作成していないので 404 が返ってきたというエラーです。

クラスの実装

artisan コマンドでコントローラを作成します。

>php artisan make:controller HostController
Controller created successfully.

リクエストで投げられたホスト名をそのまま投げ返すだけのコントローラを作りました。

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;

class HostController extends Controller
{
    public function ping(Request $request)
    {
        return $request->host;
    }
}

ルーティングを設定します。

<?php

/*
|--------------------------------------------------------------------------
| Web Routes
|--------------------------------------------------------------------------
|
| Here is where you can register web routes for your application. These
| routes are loaded by the RouteServiceProvider within a group which
| contains the "web" middleware group. Now create something great!
|
*/

Route::get('/', function () {
    return view('welcome');
});
Route::get('ping/{host}', 'HostController@ping');

そしてテストを実行。

>vendor\bin\phpunit
PHPUnit 8.5.0 by Sebastian Bergmann and contributors.

......                                                              6 / 6 (100%)


Time: 1.13 seconds, Memory: 18.00 MB

OK (6 tests, 14 assertions)

おっと、テストが通ってしまいました。 TDD では「意図せずテストがパスすること」は避けたいのです。
とりあえず今回はテストケースの追加で対応しましょう。
ping/riina-k.me という URL でアクセスされたら ping の実行結果を返すので、レスポンスには「Ping」という文字列と IP アドレスが含まれるはずです。
テストケースはこうなります。

<?php

namespace Tests\Feature;

use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\WithFaker;
use Tests\TestCase;

class HostControllerTest extends TestCase
{
    public function testPing()
    {
        $response = $this->get('/ping/riina-k.me');
        $response->assertStatus(200);
        $response->assertSee('Ping');
        $response->assertSee('153.120.20.164');
    }
}
>vendor\bin\phpunit
PHPUnit 8.5.0 by Sebastian Bergmann and contributors.

.....F                                                              6 / 6 (100%)


Time: 1.06 seconds, Memory: 18.00 MB

There was 1 failure:

1) Tests\Feature\HostControllerTest::testPing
Failed asserting that 'riina-k.me' contains "Ping".

C:\xampp\htdocs\_practice\laravel-tdd\vendor\laravel\framework\src\Illuminate\Fo
undation\Testing\TestResponse.php:395
C:\xampp\htdocs\_practice\laravel-tdd\tests\Feature\HostControllerTest.php:15

FAILURES!
Tests: 6, Assertions: 15, Failures: 1.

よかった、エラーが出ました。
通常の開発と TDD で決定的に違うのは「想定通りのエラーが出たときの安心感」ですね。
ということで、テストをパスするようコントローラを修正。
何度も言いますが、このタイミングではロジックは組みません。

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;

class HostController extends Controller
{
    public function ping(Request $request)
    {
        return 'Ping 153.120.20.164';
    }
}
>vendor\bin\phpunit
PHPUnit 8.5.0 by Sebastian Bergmann and contributors.

......                                                              6 / 6 (100%)


Time: 1.03 seconds, Memory: 18.00 MB

OK (6 tests, 16 assertions)

そしてテストケースを追加。

<?php

namespace Tests\Feature;

use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\WithFaker;
use Tests\TestCase;

class HostControllerTest extends TestCase
{
    public function testPing()
    {
        $response = $this->get('/ping/riina-k.me');
        $response->assertStatus(200);
        $response->assertSee('Ping');
        $response->assertSee('153.120.20.164');
        
        $response = $this->get('/ping/riina-k.net');
        $response->assertStatus(200);
        $response->assertSee('Ping');
        $response->assertSee('210.172.183.32');
        
        $response = $this->get('/ping/old.riina-k.info');
        $response->assertStatus(200);
        $response->assertSee('Ping');
        $response->assertSee('153.120.17.157');
        
        $response = $this->get('/ping/new.riina-k.info');
        $response->assertStatus(200);
        $response->assertSee('Ping');
        $response->assertSee('133.242.68.102');
        
        $response = $this->get('/ping/example.org');
        $response->assertStatus(404);
    }
}
>vendor\bin\phpunit
PHPUnit 8.5.0 by Sebastian Bergmann and contributors.

.....F                                                              6 / 6 (100%)


Time: 1.02 seconds, Memory: 18.00 MB

There was 1 failure:

1) Tests\Feature\HostControllerTest::testPing
Failed asserting that 'PING 153.120.20.164' contains "210.172.183.32".

C:\xampp\htdocs\_practice\laravel-tdd\vendor\laravel\framework\src\Illuminate\Fo
undation\Testing\TestResponse.php:395
C:\xampp\htdocs\_practice\laravel-tdd\tests\Feature\HostControllerTest.php:21

FAILURES!
Tests: 6, Assertions: 19, Failures: 1.

しつこいようですが エラーは良いものです。
ここからがコントローラのロジック実装の段階ですが、まだ Network クラスに ping 実行のメソッドを組み込んでいませんでした。ここも TDD でさらっと実装していきましょう。

Network クラスの機能拡張

ping メソッドで実装すべきは以下のテストの通り。そう、仕様書になるのです。

<?php

namespace Tests\Unit;

use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\WithFaker;
use Tests\TestCase;

use App\Libs\Network;

class NetworkTest extends TestCase
{
    public function testIp()
    {
        $this->assertSame(Network::ip('riina-k.me'), '153.120.20.164');
        $this->assertSame(Network::ip('riina-k.net'), '210.172.183.32');
        $this->assertSame(Network::ip('old.riina-k.info'), '153.120.17.157');
        $this->assertSame(Network::ip('new.riina-k.info'), '133.242.68.102');
    }
    
    public function testPing()
    {
        $target = 'riina-k.me';
        $result = Network::ping($target, 3, $output);
        $this->assertRegexp('/Ping/', $output);
        $this->assertRegexp('/153\.120\.20\.164/', $output);
    }
}

コントローラのテストがパスしないままなので、邪魔をしないようファイル名を指定して Network クラスのテストだけを実行します。

>vendor\bin\phpunit tests\Unit\NetworkTest.php

PHPUnit 8.5.0 by Sebastian Bergmann and contributors.

.E                                                                  2 / 2 (100%)


Time: 417 ms, Memory: 12.00 MB

There was 1 error:

1) Tests\Unit\NetworkTest::testPing
Error: Call to undefined method App\Libs\Network::ping()

C:\xampp\htdocs\_practice\laravel-tdd\tests\Unit\NetworkTest.php:29

ERRORS!
Tests: 2, Assertions: 4, Errors: 1.

テストをパスするよう最低限の実装をして。

<?php

namespace App\Libs;

class Network
{
    public static function ip($host)
    {
        $ip = gethostbyname($host);
        return ( filter_var( $ip, FILTER_VALIDATE_IP ) ? $ip : false );
    } // function ip()
    
    public static function ping($host, $count, &$output)
    {
        $output = 'Ping 153.120.20.164';
    } // function ping()
} // class Network

パスすることを確認。

>vendor\bin\phpunit tests\Unit\NetworkTest.php

PHPUnit 8.5.0 by Sebastian Bergmann and contributors.

..                                                                  2 / 2 (100%)


Time: 419 ms, Memory: 12.00 MB

OK (2 tests, 7 assertions)

テストケースを増やしてエラーを確認。

<?php

namespace Tests\Unit;

use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\WithFaker;
use Tests\TestCase;

use App\Libs\Network;

class NetworkTest extends TestCase
{
    public function testIp()
    {
        $this->assertSame(Network::ip('riina-k.me'), '153.120.20.164');
        $this->assertSame(Network::ip('riina-k.net'), '210.172.183.32');
        $this->assertSame(Network::ip('old.riina-k.info'), '153.120.17.157');
        $this->assertSame(Network::ip('new.riina-k.info'), '133.242.68.102');
    }
    
    public function testPing()
    {
        $target = 'riina-k.me';
        $result = Network::ping($target, 3, $output);
        $this->assertRegexp('/Ping/', $output);
        $this->assertRegexp('/153\.120\.20\.164/', $output);
        
        $target = 'riina-k.net';
        $result = Network::ping($target, 3, $output);
        $this->assertRegexp('/Ping/', $output);
        $this->assertRegexp('/210\.172\.183\.32/', $output);
        
        $target = 'no-exists.domain';
        $result = Network::ping($target, 3, $output);
        $this->assertFalse($result);
    }
}
>vendor\bin\phpunit tests\Unit\NetworkTest.php

PHPUnit 8.5.0 by Sebastian Bergmann and contributors.

.F                                                                  2 / 2 (100%)


Time: 476 ms, Memory: 12.00 MB

There was 1 failure:

1) Tests\Unit\NetworkTest::testPing
Failed asserting that 'Ping 153.120.20.164' matches PCRE pattern "/210\.172\.183
\.32/".

C:\xampp\htdocs\_practice\laravel-tdd\tests\Unit\NetworkTest.php:38

FAILURES!
Tests: 2, Assertions: 10, Failures: 1.

パスするようリファクタリング。

<?php

namespace App\Libs;

class Network
{
    protected static $_ping_option = [
        'Linux' => '-c',  // linux
        'Darwin' => '-c', // macOS
        'WINNT' => '-n',  // windows
    ];
    
    public static function ip($host)
    {
        $ip = gethostbyname($host);
        return ( filter_var( $ip, FILTER_VALIDATE_IP ) ? $ip : false );
    } // function ip()
    
    public static function ping($host, $count, &$output)
    {
        // ホストをチェック
        if ( strpos(' ', $host) !== false || strpos('|', $host) !== false ) {
            throw new \Exception('Invalid host');
        }

        // 回数をチェック
        if ( ! is_numeric($count) || $count <= 0 || $count >= 10) {
            throw new \Exception('Invalid count');
        }
        $count = (int)$count;

        // 回数を指定するオプション名を取得
        $option = static::$_ping_option[ PHP_OS ];

        $ip = self::ip($host);
        if ( ! $ip) {
            return false;
        }

        $count = escapeshellarg($count);
        $ip = escapeshellarg($ip);

        // ping を実行
        exec("ping {$option} {$count} {$ip}", $output, $result);

        // 文字コード変換
        $output = implode(PHP_EOL, $output);
        $encoding = mb_detect_encoding($output, 'JIS, eucjp-win, sjis-win, UTF-8');
        $output = mb_convert_encoding($output, 'UTF-8', $encoding);
        $output = trim($output);

        return $result;
    } // function ping()
} // class Network

いきなりコードが増えたように見えますが気にしない。
以前のエントリで脆弱性を組み込んでしまい書き直したメソッドをそのまま置きました。
既存のプログラムだからテストは済んでいる、と解釈してください。
ここまで実装したら、テストをパスするかチェック。

>vendor\bin\phpunit tests\Unit\NetworkTest.php

PHPUnit 8.5.0 by Sebastian Bergmann and contributors.

..                                                                  2 / 2 (100%)


Time: 4.94 seconds, Memory: 12.00 MB

OK (2 tests, 8 assertions)

オッケーです、機能拡張が完了しました。コントローラに戻りましょう。

コントローラのリファクタリング

ようやくちゃんと実装ができるようになりました。

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;

class HostController extends Controller
{
    public function ping(Request $request)
    {
        $name = $request->host;
        $host = Host::where('name', '=', $name)->first();
        if ( ! $host ) {
            \App::abort(404, 'Host not found');
        }
        
        Network::ping($name, 3, $output);
        
        return $output;
    } // function ping()
}

これでテストをパスできれば……

>vendor\bin\phpunit

PHPUnit 8.5.0 by Sebastian Bergmann and contributors.

.......                                                             7 / 7 (100%)


Time: 21.83 seconds, Memory: 18.00 MB

OK (7 tests, 31 assertions)

完成です!!

まとめ

テスト失敗→パスするだけのコードを書く→テスト成功→テストケース追加→テスト失敗→リファクタリングと、一見回りくどいような手順を踏みますが、テスト駆動開発に慣れていけば、漠然と開発しているより「自分は何を書いているのか」が明白になり、効率も良くなります。
そして何より、テストケースがそのまま仕様書になるということ。
いちいち仕様をコメントやドキュメントにまとめるのがダルいという方も多いでしょう、その呪縛から解放されます。

それでは、よき TDD ライフを!

12/12 22:25 追記

第1章と第2章で、assertSame の使い方を間違えていました……
$this->assertSame(期待する値, 実際の値);
が正しい定義で、リテラルは基本的に第1引数に書くのが正解のようです。

コメントを残す

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です