Laravel のテストを describe-it でいい感じに書く

# はじめに

PHP のテストといえば [PHPUnit](https://phpunit.de) が有名ですが、xUnit なフレームワーク故に xSpec の民には辛いものがあります。本記事では [Rspec](http://rspec.info/) や [Jest](https://jestjs.io/) のように、(個人的に)消耗しない PHP のテストと Laravel への導入方法を紹介します。

# Kahlan

![logo.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/54402/9a140456-eac2-803a-8296-2bd239005451.png)


テストを describe-it で書くために Kahlan を導入します。Kahlan は PHP の BDD テスティングフレームワークです。

[kahlan/kahlan](https://github.com/kahlan/kahlan)

## インストール

    $ composer require --dev kahlan/kahlan

## 使用方法

`spec` ディレクトリにテストコードを配置して、Kahlan を実行します。

```php:spec/Example.spec.php
<?php

describe('Example', function() {
    it('passes if true === true', function() {
        expect(true)->toBe(true);
    });
});
```

    $ ./vendor/bin/kahlan

実行結果は、次のように表示されます。

```
            _     _
  /\ /\__ _| |__ | | __ _ _ __
 / //_/ _` | '_ \| |/ _` | '_ \
/ __ \ (_| | | | | | (_| | | | |
\/  \/\__,_|_| |_|_|\__,_|_| |_|

The PHP Test Framework for Freedom, Truth and Justice.

src directory  :
spec directory : /spec

.                                                                   1 / 1 (100%)



Expectations   : 1 Executed
Specifications : 0 Pending, 0 Excluded, 0 Skipped

Passed 1 of 1 PASS in 0.820 seconds (using 18MB)
```

上記のテストコードを

```
-        expect(true)->toBe(true);
+        expect(true)->toBe(false);
```

のように変更して Kahlan を実行すると、失敗例が確認できます。

```
            _     _
  /\ /\__ _| |__ | | __ _ _ __
 / //_/ _` | '_ \| |/ _` | '_ \
/ __ \ (_| | | | | | (_| | | | |
\/  \/\__,_|_| |_|_|\__,_|_| |_|

The PHP Test Framework for Freedom, Truth and Justice.

src directory  :
spec directory : /spec

F                                                                   1 / 1 (100%)


Example
  ✖ it passes if true === true
    expect->toBe() failed in `./spec/Example.spec.php` line 5

    It expect actual to be identical to expected (===).

    actual:
      (boolean) true
    expected:
      (boolean) false


Expectations   : 1 Executed
Specifications : 0 Pending, 0 Excluded, 0 Skipped

Passed 0 of 1 FAIL (FAILURE: 1) in 0.823 seconds (using 18MB)
```

## 構文

基本的な構文は、他の xSpec ライブラリと同様です。`describe` と `it` で骨格を築いて、`expect` とマッチャで検証します。

例えば、FizzBuzz の結果を返す `FizzBuzz::run` のテストは、

```php
describe('FizzBuzz::run', function() {
    it('returns 1 when 1', function() {
        expect(FizzBuzz::run(1))->toEqual('1');
    });

    it('returns Fizz when 3', function() {
        expect(FizzBuzz::run(3))->toEqual('Fizz');
    });

    it('returns Buzz when 5', function() {
        expect(FizzBuzz::run(5))->toEqual('Buzz');
    });

    it('returns FizzBuzz when 15', function() {
        expect(FizzBuzz::run(15))->toEqual('FizzBuzz');
    });
});
```

のように書けます。

`describe` で「テストグループ」を示し、`it` で「テスト単位」を示し、`expect` で「検証値」を示し、`to*` で「検証方法」を示します。`to*` に該当するマッチャは [Matchers](https://kahlan.github.io/docs/matchers.html) で確認できます。

テストグループのネストや、状態などを示す `context` を利用すると、

```php
describe('FizzBuzz', function() {
    describe('::run', function(){
        context('when 1', function(){
            it('returns 1', function() {
                expect(FizzBuzz::run(1))->toEqual('1');
            });
        });

        context('when 3', function(){
        …
```

のように書けます。これで、`FizzBuzz` に関する `::run` 以外のテストも管理しやすくなり、`context` で条件が明確になりました。

また、テスト単位の前処理に `beforeEach` を利用すると、

```php
describe('FizzBuzz', function() {
    describe('::run', function(){
        context('when 1', function(){
            beforeEach(function() {
                $this->result = FizzBuzz::run(1);
            });

            it('returns 1', function() {
                expect($this->result)->toEqual('1');
            });
        });

        context('when 3', function(){
        …
```

のように書けます。`context` の条件を `beforeEach` で整えておくと、同グループへのテストの追加が楽です。このほか、`afterEach` でテスト単位の後処理、`beforeAll` や `afterAll` でグループの前処理や後処理が書けます。

# Laravel + Kahlan

Laravel のプロジェクトに対して、

    $ composer require --dev kahlan/kahlan

で Kahlan をインストールして、

    $ ./vendor/bin/kahlan

で spec のテストは実行できますが、Laravel 提供のヘルパが使えないため不便です。

例えば、次のような Laravel のレスポンスやファクトリが実行できません。

```php
$response = $this->get('/');
```

```php
$user = factory(App\User::class)->make();
```

PHPUnit で使えていた機能を利用するためには、Laravel と Kahlan の連携が必要です。

## 連携方針

Laravel のテストを PHPUnit で書く際には `tests/TestCase.php` を継承しますが、これは `PHPUnit\Framework\TestCase` のサブクラスです。Laravel は、`PHPUnit\Framework\TestCase` にメソッドを生やすことで、`get()` などのヘルパを提供をしています。そして、Laravel 向けに整えたクラスが `Illuminate\Foundation\Testing\TestCase` なので、これを Kahlan から利用すれば `get()` などが使えます。

連携のためにはあと一点、`tests/CreatesApplication.php` の処理が必要です。これは Laravel アプリケーションである `Illuminate\Foundation\Application` を稼働させているため、`bootstrap/app.php` を利用して、

```php
$app = require 'bootstrap/app.php';
$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();
```

を Kahlan と共に実行すれば解決します。

## laravel-kahlan4

連携には laravel-kahlan4 を使用します。laravel-kahlan にインスパイアされて作成した、Laravel のテストを Kahlan で書くためのライブラリです。基のライブラリが動作せず放置状態だったため、Kahlan 4 向けに書くと共に機能を拡充しました。

[midnightSuyama/laravel-kahlan4](https://github.com/midnightSuyama/laravel-kahlan4)

### インストール

    $ composer require --dev midnightsuyama/laravel-kahlan4

### 使用方法

Kahlan は実行時に [kahlan-config.php](https://kahlan.github.io/docs/config-file.html) を読み込むため、

```php:kahlan-config.php
<?php

LaravelKahlan4\Config::bootstrap($this);
```

をプロジェクトルートに用意して、Kahlan を実行するだけです。環境変数は、PHPUnit と同様に `.env.testing` を優先します。

### Laravel テストメソッド

`Illuminate\Foundation\Testing\TestCase` ベースのインスタンスを `$this->laravel` に格納しているため、`Illuminate\Foundation\Testing\Concerns` のテストメソッドが呼べます。

```php
$response = $this->get('/');
```

のような PHPUnit は、Kahlan で、

```php
$response = $this->laravel->get('/');
```

のように書けます。

### Laravel テストトレイト

`beforeAll` で `$this->laravel->useTrait()` を呼ぶことで、特定のトレイトを使用できます。

```php
use Illuminate\Foundation\Testing\RefreshDatabase;

describe('User table', function() {
    beforeAll(function() {
        $this->laravel->useTrait(RefreshDatabase::class);
    });

    it('has no records', function() {
        $count = App\User::count();
        expect($count)->toEqual(0);
    });
});
```

Laravel のテストトレイトは、次の 6 種類です。

* Illuminate\Foundation\Testing\RefreshDatabase
* Illuminate\Foundation\Testing\DatabaseMigrations
* Illuminate\Foundation\Testing\DatabaseTransactions
* Illuminate\Foundation\Testing\WithoutMiddleware
* Illuminate\Foundation\Testing\WithoutEvents
* Illuminate\Foundation\Testing\WithFaker

### Laravel アサーション対応 マッチャ

Laravel のアサーションに対応したマッチャを用意しています。命名規則は `toPass*` と `assert*` で対にしているため、改めて覚える必要はありません。

```php
$response->assertStatus(200);
```

のような PHPUnit は、Kahlan で、

```php
expect($response)->toPassStatus(200);
```

のように書けます。

#### レスポンス

```php
describe('Response', function() {
    it('has a 200 status code', function() {
        $response = $this->laravel->get('/');
        expect($response)->toPassStatus(200);
    });
});
```

|マッチャ|説明|
|:---|:---|
|toPassSuccessful()|レスポンスが successful ステータスコードを持っている。|
|toPassOk()|レスポンスが 200 ステータスコードを持っている。|
|toPassNotFound()|レスポンスが not found ステータスコードを持っている。|
|toPassForbidden()|レスポンスが forbidden ステータスコードを持っている。|
|toPassUnauthorized()|レスポンスが Unauthorized ステータスコードを持っている。|
|toPassStatus(_$status_)|レスポンスが指定したステータスコードを持っている。|
|toPassRedirect(_$uri_ = null)|レスポンスが指定した URI へリダイレクトする。|
|toPassHeader(_$headerName_, _$value_ = null)|レスポンスに指定したヘッダが含まれている。|
|toPassHeaderMissing(_$headerName_)|レスポンスに指定したヘッダが含まれていない。|
|toPassLocation(_$uri_)|Location ヘッダと指定した URI が一致している。|
|toPassPlainCookie(_$cookieName_, _$value_ = null)|レスポンスに指定したクッキーが含まれている。|
|toPassCookie(_$cookieName_, _$value_ = null, _$encrypted_ = true, _$unserialize_ = false)|レスポンスに指定したクッキーが含まれている。|
|toPassCookieExpired(_$cookieName_)|レスポンスに指定したクッキーが含まれており、期限切れである。|
|toPassCookieNotExpired(_$cookieName_)|レスポンスに指定したクッキーが含まれており、期限切れでない。|
|toPassCookieMissing(_$cookieName_)|レスポンスに指定したクッキーが含まれてない。|
|toPassSee(_$value_)|指定した文字列がレスポンスに含まれている。|
|toPassSeeInOrder(array _$values_)|指定した文字列が順番通りにレスポンスに含まれている。|
|toPassSeeText(_$value_)|指定した文字列がレスポンステキストに含まれている。|
|toPassSeeTextInOrder(array _$values_)|指定した文字列が順番通りにレンスポンステキストに含まれている。|
|toPassDontSee(_$value_)|指定した文字列がレスポンスに含まれていない。|
|toPassDontSeeText(_$value_)|指定した文字列がレスポンステキストに含まれていない。|
|toPassJson(array _$data_, _$strict_ = false)|レスポンスに指定した JSON データが含まれている。|
|toPassExactJson(array _$data_)|レスポンスに指定した JSON データと完全一致するデータが含まれている。|
|toPassJsonFragment(array _$data_)|レスポンスに指定した JSON の一部が含まれている。|
|toPassJsonMissing(array _$data_, _$exact_ = false)|レスポンスに指定した JSON の一部が含まれていない。|
|toPassJsonMissingExact(array _$data_)|レスポンスに指定した JSON データと完全一致するデータが含まれていない。|
|toPassJsonStructure(array _$structure_ = null, _$responseData_ = null)|レスポンスが指定した JSON の構造を持っている。|
|toPassJsonCount(int _$count_, _$key_ = null)|レスポンス JSON が指定したキーのアイテムを期待数持っている。|
|toPassJsonValidationErrors(_$errors_)|レスポンスが指定した JSON バリデーションエラーを持っている。|
|toPassJsonMissingValidationErrors(_$keys_ = null)|レスポンスが指定したキーの JSON バリデーションエラーを持っていない。|
|toPassViewIs(_$value_)|レスポンスビューと指定したビューが一致する。|
|toPassViewHas(_$key_, _$value_ = null)|レスポンスビューが指定したデータを持っている。|
|toPassViewHasAll(array _$bindings_)|レスポンスビューが指定したリストのデータを持っている。|
|toPassViewMissing(_$key_)|レスポンスビューが指定したデータを持っていない。|
|toPassSessionHas(_$key_, _$value_ = null)|セッションが指定したデータを持っている。|
|toPassSessionHasAll(array _$bindings_)|セッションが指定したリストの値を持っている。|
|toPassSessionHasInput(_$key_, _$value_ = null)|セッションが指定した値をフラッシュデータが持っている。|
|toPassSessionHasErrors(_$keys_ = [], _$format_ = null, _$errorBag_ = 'default')|セッションが指定したエラーを持っている。|
|toPassSessionDoesntHaveErrors(_$keys_ = [], _$format_ = null, _$errorBag_ = 'default')|セッションが指定したエラーを持っていない。|
|toPassSessionHasNoErrors()|セッションがエラーを持っていない。|
|toPassSessionHasErrorsIn(_$errorBag_, _$keys_ = [], _$format_ = null)|セッションが指定したエラーを持っている。|
|toPassSessionMissing(_$key_)|セッションが指定したキーを持っていない。|

#### 認証

```php
describe('User', function() {
    it('is authenticated', function() {
        $user = factory(App\User::class)->make();
        $this->laravel->actingAs($user);
        expect($this->laravel)->toPassAuthenticated();
    });
});
```

|マッチャ|説明|
|:---|:---|
|toPassAuthenticated(_$guard_ = null)|ユーザが認証されている。|
|toPassGuest(_$guard_ = null)|ユーザが認証されていない。|
|toPassAuthenticatedAs(_$user_, _$guard_ = null)|指定したユーザが認証されている。|
|toPassCredentials(array _$credentials_, _$guard_ = null)|指定した認証情報が有効である。|
|toPassInvalidCredentials(array _$credentials_, _$guard_ = null)|指定した認証情報が無効である。|

#### データベース

```php
describe('User table', function() {
    it('has records', function() {
        $user = factory(App\User::class)->create();
        expect($this->laravel)->toPassDatabaseHas('users', ['email' => $user['email']]);
    });
});
```

|マッチャ|説明|
|:---|:---|
|toPassDatabaseHas(_$table_, array _$data_, _$connection_ = null)|指定したデータがデータベースに存在する。|
|toPassDatabaseMissing(_$table_, array _$data_, _$connection_ = null)|指定したデータがデータベースに存在しない。|
|toPassSoftDeleted(_$table_, array _$data_ = [], _$connection_ = null)|指定したレコードが論理削除されている。|