Laravelでテストコードを書くためのチュートリアル

  • 72
    いいね
  • 0
    コメント
この記事は最終更新日から1年以上が経過しています。

こんにちは皆さん

以前にPHPerもテストコード書こうぜ!みたいな記事を書いたわけですが、いざテスト書こうとするといろんな問題に突き当たります。
特に大きな問題は可読性だったりしますが、この可読性の問題を大幅に解決してくれたのが、Laravelのテストでした。

Laravelのユニットテスト

テストの「可読性」問題

テストの可読性は大きな課題です。
というのも、テストというのは仕様と実装があっていることを確かめるものであるため、テストの可読性が低い<=>テストが何の仕様を確認しているかわからない状態では、そのテストの存在意義に疑問符がつくでしょう。もちろん、そんな状態では仕様変更が発生しても、どの箇所のテストを直せばわからなくなり、「結果としてテストコードが邪魔」になるというジレンマを生み出してしまいます。
また、可読性が低いということは、書きにくいということと同義ですので、「テストを書くには技術力が必要」という状況を引き起こし、その結果初心者に敬遠される要因ともなっています。

Laravelのユニットテスト

上述した可読性問題に対し、Laravelはユニットテストを提供することで、解決手段を提示しています。
Laravelのユニットテストは、PHPUnitの拡張ですが、いわゆるURIに対するテストを直感的に( 公式が言うには「スラスラと」 )書けるように設計されています。
詳しい説明に関しては公式の説明に譲るとしますが、次のように書けます

public function testBoard()
{
    $this->visit('/boards')//  掲示板のトップページにアクセスしてみる
    ->see('掲示板')//           「掲示板」という文字列が見える
    ->see('新規投稿')//         「新規投稿」という文字列もある
    ->click('新規投稿')//        新規投稿リンクをクリックしてみる
    ->seePageIs('/boards/new')// 新規投稿ページに遷移する
    ->see('新規記事投稿');//     新規投稿ページには「新規記事投稿」という文字列がある
}

このように、ブラウザで行うようなテストを視覚的に理解することができます。

ところでこのテストの書き方は、おそらくJava畑出身の方からすると、違和感を覚えるかもしれません。
Javaでは一つのクラスに対して一つのユニットテストを作るのが一般的ですが、上述したテストは、各「リクエストごと」にテストを用意しています。
とはいえ、PHPは基本的にはWebアプリのサーバサイド言語なので、リクエスト・レスポンスのセットを一つの処理系と考えるのが自然なので、私としては割としっくり来ています。

Laravelで掲示板を作る

Laravelを使って掲示板を作り、その過程でどのようにテストを作るか見てみましょう。
プロジェクトの作成については以前書いた記事を参照してください。
今回の結果に関しては、以下のリポジトリにあります。
https://github.com/niisan-tokyo/laravel_board

準備

今回はDBとしてmysqlを使用するので、以下のように二つのコンテナを起動しておきましょう。

docker-compose up -d mysql workspace

適当な名前でlaravelのプロジェクトを作成したら、早速ワークスペース上でartisanを使って前準備を始めてしまいましょう。

$ php artisan make:migration create_boards_table
$ php artisan make:controller BoardController
$ php artisan make:model Board
$ php artisan make:test BoardTest

更に設定を進めていきます。
まず、laradockの設定値は.envというファイルに格納されています。
ここにmysqlの接続設定があるのですが、このmysqlの向き先はデフォルトでは127.0.0.1になっているので、これをdockerのコンテナのエイリアスに書き換えます。

DB_CONNECTION=mysql
- DB_HOST=127.0.0.1
+ DB_HOST=mysql
DB_PORT=3306
DB_DATABASE=homestead

次に、マイグレーションファイルを使って、データベースの定義を作ってみましょう。
database/migration/ディレクトリに、先ほど作成したマイグレーションファイルがあると思います。ファイル名は「生成日時+生成ファイル名」となっていると思います。
ここに掲示板のデータ定義を入れてみましょう。

xxxx_xx_xx_xxxxx_create_boards_table.php
/** 略 */
    /**
     * Run the migrations.
     *
     * @return void
     */
     public function up()
     {
         Schema::create('boards', function (Blueprint $table) {
             $table->increments('id');
             $table->string('title');
             $table->text('content');
             $table->timestamps();
         });
     }

     /**
      * Reverse the migrations.
      *
      * @return void
      */
     public function down()
     {
         Schema::drop('boards');
     }
}

とりあえずこんなものでよいでしょう。
一旦マイグレーション、つまりデータベースの生成がうまくいくかテストしてみましょう

$ php artisan migrate
Migration table created successfully.
Migrated: 2014_10_12_000000_create_users_table
Migrated: 2014_10_12_100000_create_password_resets_table
Migrated: 2016_08_04_232938_create_boards_table

うまく行ったようです。

掲示板の初期ページの作成

次に、コントローラとビューを設定してしまいます。
コントローラはapp/Http/Controllers/BoardController.phpにあるので、ここにgetIndexメソッドを追加してみましょう。

app/Http/Controllers/BoardController.php
<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;

use App\Http\Requests;
use App\Board;

class BoardController extends Controller
{

    public function getIndex()
    {
        $boards = Board::all();

        return view('board.index', ['boards' => $boards]);
    }
}

次にビューとして以下のbladeテンプレートを用意しましょう。

resources/views/board/index.blade.php
<html>
<head>
    <meta charset="utf-8"/>
</head>
<body>
    <h1>掲示板のテスト</h1>
    @foreach($boards as $board)
      <h3>{{ $board->title }}</h3>
      <p>{{ $board->content }}</p>
    @endforeach

</body>
</html>

最後にrouterにurlとコントローラのひも付けをさせましょう。app/Http/routes.phpに以下のコードを追加しておきます。

Route::controller('boards', 'BoardController');

ここまでできたら、一旦動作を確認してみましょう。

$ docker-compose stop
$ docker-compose up -d nginx php-fpm mysql

この状態でhttp://127.0.0.1/boardsにアクセスすると、掲示板のトップ画面が表示されます。(docker toolbox使用時はドメインの部分はdocker-machineのIP使ってください)

テストを入れる

では、この状況についてテストを書いてみましょう。
現在作ったのはトップページだけですので、とりあえずこのページのテストを書いてみましょう。
先ほど生成した中にテストがありますので、こちらに以下のコードを追加してみましょう。

tests/BoardTest.php
/** 中略 */
    /**
     * 掲示板トップページの表示内容のテスト
     *
     * @return void
     */
    public function testIndex()
    {
        $this->visit('/boards')
        ->see('掲示板のテスト');
    }
}

これをワークスペース上で動かしてみます。

$ docker exec -it laradock_workspace_1 /bin/bash
$ phpunit
PHPUnit 4.8.27 by Sebastian Bergmann and contributors.

...

Time: 4.93 seconds, Memory: 12.00MB

OK (3 tests, 5 assertions)

こんな感じで、テストが通ったことが確認できます。

トップページのテストを追加する

トップページのテストとしては当然これでは不十分です。
これではただ単にトップページが表示されて、ページタイトルが想定通りであることしかテストしていません。
この掲示板トップページのテストに、「掲示板の内容が一覧表示されている」という仕様を追加してみましょう。
しかし、このテストをするためにはテストデータを用意しなければなりません。
そこで、factoryを利用しましょう。
以下のようにモデルファクトリーにコードを追加しておきます。

database/factories/ModelFactory.php
$factory->define(App\Board::class, function (Faker\Generator $faker) {
    return [
        'title' => $faker->name,
        'content' => str_random(15),
    ];
});

Fakerはランダムなテストデータを作るためのユーティリティとして作用しています。
これを導入した状態で、テストコードを修正しましょう。

tests/BoardTest.php
/** 中略 */
    /**
     * 掲示板トップページの表示内容のテスト
     *
     * @return void
     */
    public function testIndex()
    {
        //テストデータが二つ存在するとする
        $first = factory(App\Board::class)->create();
        $second = factory(App\Board::class)->create();
        $first->save();
        $second->save();

        //テスト開始
        $this->visit('/boards')
        ->see('掲示板のテスト')
        ->see($first->title)->see($first->content)
        ->see($second->title)->see($second->content);
    }
}

これを追加したらテストを走らせてみましょう。

$ phpunit
PHPUnit 4.8.27 by Sebastian Bergmann and contributors.

..

Time: 7.04 seconds, Memory: 14.00MB

OK (2 tests, 8 assertions)

問題なくテストが通りました。
ランダムデータを使っている理由は、固定データを使ってしまうと、テストを通すためだけに実コードにその固定文字列を書いてしまう、みたいな自体を防ぐためです。
しかし、ランダムデータの使用は可読性が低下する原因になりますので、確実性と可読性のどちらかを取るか、ということになります。
個人的には可読性が下がらない程度であればランダムデータを使い、可読性が下がりそうであれば固定データを使うという方が良いように思います。

機能追加をテスト駆動開発してみる

次に、掲示板に記事を追加するための機能を追加するのですが、これをテスト駆動開発してみましょう。
テスト駆動開発は、「テストファースト」の原則に従い、まずテストコードを作ってから、そのテストが通るように実装する、という開発手法です。

まず掲示板の記事追加の仕様を以下のように定義しましょう。
「掲示板トップページに「新規投稿」というリンクがあって、それを押すと新規投稿ページに遷移するので、titleとcontentに値を入力し、送信ボタンを押すと、記事が投稿される」
これをテストコードに反映すると以下のようになります。

tests/BoardTest.php
/** 中略 */
    /**
     * 掲示板内容追加のテスト
     * 
     * @return void
     */
    public function testCreate()
    {
        $this->visit('/boards')
        ->click('新規投稿')
        ->seePageIs('/boards/new')
        //フォームの入力
        ->type('タイトル1', 'title')
        ->type('本文1', 'content')
        ->press('送信')
        // テストデータの確認
        ->seePageIs('/boards')
        ->see('タイトル1')
        ->see('本文1');
    }

この程度なら、実コードがなくても書けそうです。

で、こいつをまずテストします。

$ phpunit
PHPUnit 4.8.27 by Sebastian Bergmann and contributors.

.E.

Time: 5.77 seconds, Memory: 14.00MB

There was 1 error:

1) BoardTest::testCreate
InvalidArgumentException: Could not find a link with a body, name, or ID attribute of [新規投稿].

/var/www/laravel/vendor/laravel/framework/src/Illuminate/Foundation/Testing/Concerns/InteractsWithPages.php:464
/var/www/laravel/tests/BoardTest.php:31

FAILURES!
Tests: 3, Assertions: 9, Errors: 1.

まあ、当然動くわけもないですね。
では、実装を進めてみましょう。

まず、テンプレートを修正および実装します。

resources/views/board/index.blade.php
    @endforeach

+ <a href="/boards/new">新規投稿</a>
</body>
resources/views/board/new.blade.php
<html>
<head>
    <meta charset="utf-8"/>
</head>
<body>
    <h1>掲示板の投稿</h1>
    <form action="/boards/create" method="post" enctype="multipart/form-data">
        {!! csrf_field() !!}
        タイトル<input type="text" name="title"><br>
        本文<textarea name="content"></textarea>
        <input type="submit" value="送信">
    </form>

</body>
</html>

次にコントローラに追記していきます。

app/Http/Controllers/BoardController.php
// 以下のメソッドをクラス定義の末尾に追加
    public function getNew()
    {
        return view('board.new');
    }

    public function postCreate(Request $req)
    {
        $board = new Board();
        $board->title = $req->input('title');
        $board->content = $req->input('content');
        $board->save();

        return redirect('/boards');
    }

これでもう一度テストを通してみましょう。

$ phpunit
PHPUnit 4.8.27 by Sebastian Bergmann and contributors.

...

Time: 5.26 seconds, Memory: 14.00MB

OK (3 tests, 18 assertions)

テストが通りましたので、仕様通りに実装されていることが確認できました。

テストを独立させる

以上でチュートリアルは終わりですが、今回のテスト方法は独立性の観点でよろしくありません。
まず、我々が目見で確認する環境と同じDBを使用しているので、テストがもろに自分の確認環境に影響を与えてきます。
また、各々のテストでDBに投入した内容が別のテストにそのまま持ち越されるため、テスト間で影響を与え合っている可能性があります。

テスト間の影響を排除するためには、各テストでデータベースをリセットする必要があります。
そのために、Laravelでテストする場合は、以下のtraitを使う事ができます。

app/Http/Controllers/BoardController.php
class BoardTest extends TestCase
{

+    use DatabaseMigrations;

しかし、今のままでこれを使ってテストを実行すると、確認環境のDBがすっ飛ぶので、別のDBを用意しましょう。
laradockのdocker-compose.ymlに以下の項目を追加してください。

docker-compose.yml
### MySQL for TestCase
    mysql_test:
        build: ./mysql
        ports:
            - "3306"
        environment:
            MYSQL_DATABASE: homestead
            MYSQL_USER: homestead
            MYSQL_PASSWORD: secret
            MYSQL_ROOT_PASSWORD: root

更に、プロジェクトルートにあるphpunit.xmlに以下の項目を追加します。

phpunit.xml
        <env name="SESSION_DRIVER" value="array"/>
        <env name="QUEUE_DRIVER" value="sync"/>
 +      <env name="DB_HOST" value="mysql_test"/>
    </php>

あとはいつもどおりdocker-composeを使えばオッケーです。

$ docker-compose up -d mysql_test workspace
$ docker exec -it laradock_workspace_1 /bin/bash
$ phpunit
PHPUnit 4.8.27 by Sebastian Bergmann and contributors.

...

Time: 6.89 seconds, Memory: 16.00MB

OK (3 tests, 18 assertions)

こんな感じでテストができます。

まとめ

PHPUnitを始めとしてテストコードというもの素で使うととても書きにくく、敬遠しがちです。
しかし、フレームワークの中には今回取り上げたLaravelのようにテストコードの書きやすさを追求し、テストコードを書くことに対する敷居を下げているものもあります。
また、見やすいテストコードは自分で書きたくなるので、作業が捗るように思います。

今回はLaravelを例にとってテストコードを書くチュートリアルをしてみました。
フレームワーク選定には是非ともテストのしやすさを基準に入れるといいんじゃないかと思います。

あ、コードは自分で動かしながらコピペしてますが、もし失敗しているようでしたらお知らせください。

参考

PHPUnit
Laravelのテスト
テストコードの期待値はDRYを捨ててベタ書きする ~テストコードの重要な役割とは?~
(テストコードのデータをどうすべきかについてわかりやすくまとまっています。)
テスト駆動開発
(振る舞い駆動がテスト駆動の上位のように書いてあるのはいただけないけど。。。)