PHP
PHPUnit
laravel

PHPでsnapshot testingをやりませんか?

はじめに

既存のコードに対するテストを書く時など、テストを書くこと自体の工数を削減できないかという観点にて、スナップショットテストと言うものを試しました。PHPUnitにもスナップショットテストのライブラリが提供されているのでその使い方についてまとめます。

そもそもスナップショットテストとは

もともとは、Facebook製のJSテストフレームワークJestの機能として提供されているものです。

また、スナップショットテストのすゝめという@kenttさんのスナップショットテストについての記事が概要に知るにはとても参考になるのでおすすめです。

上記の記事を引用させていただくと、概要はこんな感じです。

スナップショットテスト

  • 最初のテスト実行時にテストケースのアウトプットが保存(スナップショット)される
  • スナップショットを目視で確認して期待通りならOK
  • 2回目以降はアウトプットとスナップショットを比較して一致していればOK
  • スナップショットファイルをgit管理する

例えば、APIのテストであれば普通は、期待値となるjsonをテストケース内で定義しますよね。大きいレスポンス値であれば期待値だけで結構な行数になってしまったり、テストケース内に書いていくこと自体も地味に大変な作業だったりします。
それが、スナップショットテストを行えば、初回実行時点の値が正しいことが確認できれば、以降は保存されたスナップショットとの比較によるテストになるので、最初にテストケース内で期待値を定義する手間・時間がだいぶ削減できます。

どういうときに便利なのか

  • テストコードが無い箇所にリグレッションテストを書きたい時
  • テストを書く時間が取れないけど、最低限アウトプット内容は保証したい時

PHPUnitでスナップショットテストをするには

PHPUnitでスナップショットテストをするには、spatie/phpunit-snapshot-assertions
を使います。
composer.jsonに定義されているrequirementは下記のバージョンのようです。

    "require": {
        "php": "^7.0",
        "phpunit/phpunit": "^5.7|^6.0|^7.0"
    },

PHP7系・PHPUnit5.7以上の環境であれば使えるライブラリですね。

spatie/phpunit-snapshot-assertionsの使い方

準備

spatie/phpunit-snapshot-assertionsを使うには、下記の設定をするだけです。

  • composer install
$ composer require --dev spatie/phpunit-snapshot-assertions
  • テストクラスに読み込み
<?php

+ use Spatie\Snapshots\MatchesSnapshots;

class ExampleTest extends TestCase
{
    + use MatchesSnapshots;
}

これだけやれば準備完了です。

使い方

Jsonの比較

APIの仕様ではよくあるJson形式のレスポンステストの場合を考えます。以降、laravel/lumenを用いた簡単なAPIのケースで進めます。

実装内容

hello worldをメッセージに含めるjsonレスポンスを返すメソッドだとします。

$router->get('hello', function () {
    return response()->json(
        [
            'application_name' => 'lumen-blog-app',
            'message' => 'hello world.'
        ]
    );
});

普通にテストを書く場合

スナップショットテストを使わない場合のテストケースは下記のようになりますね。

    public function testGetHello()
    {
        $this->get('/hello');
        $this->assertJson(
            json_encode(
                [
                    'application_name' => 'lumen-blog-app',
                    'message' => 'hello world.'
                ]
            ),
            $this->response->getContent()
        );
    }

スナップショットテストを書く場合

このようにテストケースを書き換えます。

    public function testGetHello()
    {
        $this->get('/hello');
        $this->assertMatchesJsonSnapshot($this->response->getContent());
    }

assertMatchesJsonSnapshot()とにて、json形式のレスポンスに対するスナップショットとの比較を行います。
書き換えたテストを実行してみます。

-> % ./vendor/bin/phpunit
PHPUnit 7.0.0 by Sebastian Bergmann and contributors.

.I..                                                                4 / 4 (100%)

Time: 88 ms, Memory: 4.00MB

OK, but incomplete, skipped, or risky tests!
Tests: 4, Assertions: 3, Incomplete: 1.

OK, but incomplete, skipped, or risky tests!というテスト結果になりました。初回実行時点なのでこの段階でスナップショットを保存するだけなので何もテストはしていない状態です。
ここで、__snapshots__というディレクトリが作られ、ExampleTest__testGetHello__1.jsonというファイルが作られることになります。

スクリーンショット 2018-02-10 19.33.17.png
(Laravel/lumenの場合は、tests配下に__snapshot__が作られています。)

ExampleTest__testGetHello__1.jsonの中身を見るとこのようになっています。

{
    "application_name": "lumen-blog-app",
    "message": "hello world."
}

今回テスト対象にしたメソッドのJsonレスポンスがスナップショットとしてファイルに保存されています。(ここで、目視で結果に問題がないことを注意深く確認します。)

もう一度実行するとテスト結果は下記のようになります。

-> % ./vendor/bin/phpunit
PHPUnit 7.0.0 by Sebastian Bergmann and contributors.

....                                                                4 / 4 (100%)

Time: 92 ms, Memory: 4.00MB

OK (4 tests, 6 assertions)

OK (4 tests, 6 assertions)という結果になりました。2回目以降の実行の場合は保存されたスナップショットとの比較が行われ等しい場合、テスト結果OKとなります。

テストが失敗する場合

スナップショットテストが失敗した場合の結果は下記のようになります。

-> % ./vendor/bin/phpunit
PHPUnit 7.0.0 by Sebastian Bergmann and contributors.

.F..                                                                4 / 4 (100%)

Time: 97 ms, Memory: 4.00MB

There was 1 failure:

1) ExampleTest::testGetHello
Failed asserting that '{"application_name":"lumen-blog-app_1","message":"hello world."}' matches JSON string "{
    "application_name": "lumen-blog-app",
    "message": "hello world."
}
".

Snapshots can be updated by passing `-d --update-snapshots` through PHPUnit's CLI arguments.
--- Expected
+++ Actual
@@ @@
 {
-    "application_name": "lumen-blog-app",
+    "application_name": "lumen-blog-app_1",
     "message": "hello world."
 }

すでに保存されているスナップショットとの差分が結果として表示されています。もし、レスポンスが変わることが期待値ではない場合は、コード自体を直します。
また、レスポンスが変わることを期待する場合は、snapshots自体を差し替えるという事が可能です。-d --update-snapshotsというオプションをつけることでスナップショットの更新が行われます。

-> % ./vendor/bin/phpunit -d --update-snapshots
PHPUnit 7.0.0 by Sebastian Bergmann and contributors.

.I..                                                                4 / 4 (100%)

Time: 91 ms, Memory: 4.00MB

OK, but incomplete, skipped, or risky tests!
Tests: 4, Assertions: 6, Incomplete: 1.

このように、スナップショットテストに書き換えることで、テストコード行も減りレスポンスの値をスナップショットとの比較という形で保証することが出来ます。今回は2行程度のjsonですが、10行以上の大きいレスポンスだとしたらかなり有効ですよね。

配列が返り値の時

配列が返り値の場合、assertMatchesSnapshotを用いてテスト可能です。

例えば、下記のような配列が返ってくる場合、

$router->get('user/{id}', function ($id) {
    return ['1' => 'User', '2' => 'Admin'];
});

同じく返り値を引数に渡すだけでテスト可能。

    public function testGetUser()
    {
        $this->get('/user/1');
        $this->assertMatchesSnapshot($this->response->getContent());
    }

その際の、スナップショットの中身はこんな感じになっています。

<?php return '{"1":"User","2":"Admin"}';

ライブラリが返り値を配列形式を保った文字列としてスナップショットを保存してくれているので、毎回のテスト実行時にはこれと比べればOKです。

その他

Json形式以外でも下記のメソッドが用意されています。

method type
assertMatchesSnapshot($actual) string
assertMatchesJsonSnapshot($actual) json
assertMatchesXmlSnapshot($actual) xml
assertMatchesFileSnapshot($filePath) file
assertMatchesFileHashSnapshot($filePath) file(hash)

string

以下のように文字列を返すメソッドの場合は、

$router->get('user/{id}', function ($id) {
    return 'User Id: '.$id;
});

以下のように、assertMatchesSnapshotというメソッドで比較します。

    /**
     * get user id
     *
     * @return void
     */
    public function testGetUser()
    {
        $this->get('/user/1');
        $this->assertMatchesSnapshot($this->response->getContent());
    }

失敗した場合は、このようなテスト結果になります。

1) ExampleTest::testGetUser
Failed asserting that two strings are equal.

Snapshots can be updated by passing `-d --update-snapshots` through PHPUnit's CLI arguments.
--- Expected
+++ Actual
@@ @@
-'User Id: 1'
+'User Id+: 1'

おわりに

今回は、スナップショットテストについて紹介させていただきました。PHP7系を使ったプロジェクトを行っている方は一度使ってみてはいかがでしょうか?