🛠️

Spectatorを使ってLaravelで実装したAPIとOpenAPI Schemaとの剥離を防ぐ

2024/10/03に公開

Spectatorとは

OpenAPIで定義したAPIのリクエスト内容・レスポンス内容を、実装コードが満たしているかを簡単に確認できるようにしてくれます。
LaravelのHTTPテストに組み込むことで、必須項目の有無や各項目の型があっているかをチェックしてくれます。

リポジトリはこちら
https://github.com/hotmeteor/spectator

動作環境

PHP 8.2.19
Laravel 10.48.11
Spectator 2.0

インストール

Composerでインストールします。

composer require hotmeteor/spectator --dev

インストール後、設定ファイルを編集可能な場所に生成します。

php artisan vendor:publish --provider="Spectator\SpectatorServiceProvider"

生成されたconfig/spectator.phpを環境にあわせて修正します。
デフォルトではローカルにあるOpenAPIのスキーマファイルを読み込むようになっているので、定義場所が別のGitリポジトリにあるような場合はSPEC_SOURCEやSPEC_PATHの設定を変更してください。
今回は同じリポジトリ内にスキーマファイルがある設定で、OpenAPIの定義ファイルのある場所を示すSPEC_PATHの値を.envで変更します。

.env
SPEC_PATH=openapi

この他、Spectatorでの検証対象となるミドルウェアグループをapiのみではなくwebも含めるような設定にします。
これでどちらのミドルウェアに属していてもSpectatorでの検証が可能になります。

config/spectator.php
/*
|--------------------------------------------------------------------------
| Middleware Groups
|--------------------------------------------------------------------------
|
| Specify the groups that spectator's middleware should be prepended to.
|
*/

'middleware_groups' => ['api', 'web'],

OpenAPIでスキーマを定義

例として、ユーザーログイン用のAPIが以下のように定義されているとします。

openapi.yml
paths:
  /api/login:
    post:
      summary: ログイン
      tags:
        - Auth
      operationId: login
      description: |
        ユーザーをログイン状態にする
      requestBody:
          content:
            application/json:
              schema:
              type: object
                properties:
                  email:
                    type: string
                    description: "メールアドレス"
                  password:
                    type: string
                    description: "パスワード"
                  remember:
                    type: boolean
                    description: "認証情報を記憶するか(デフォルトはfalse)"
                required:
                  - email
                  - password
        responses:
          "200":
            content:
              application/json:
                schema:
                  type: object
                  properties:
                    id:
                      type: integer
                    name:
                      type: string
                    email:
                      type: string
                  required:
                    - id
                    - name
                    - email

テストを実装

READMEにあるサンプルのように各テストメソッドの先頭で Spectator::using('仕様書ファイル名'); と書いてもいいのですが、継承元クラスがあればそちらの setUp() に書いておくだけでも問題ありません。

use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
use Spectator\Spectator;

abstract class TestCase extends BaseTestCase
{
    protected function setUp(): void
    {
        parent::setUp();
        ...
        // 仕様書の場所を指定します
        Spectator::using('openapi.yml');
    }

リクエストのチェック内容

assertValidRequest()でリクエスト内容と仕様の剥離がないか検証を行います。
次のテストは仕様通りメールアドレスとパスワードを送信しているため、テスト成功となります。

    public function testLogin()
    {
        $password = $this->faker->word;
        $user = User::factory()->create(['password' => Hash::make($password)]);

        $this->postJson('/api/login', [
            'email' => $user->email,
            'password' => $password,
        ])
        ->assertValidRequest();

        $this->assertAuthenticated();
    }

同じAPIに対してemailを含めずにテストを実行すると、

    public function testLogin()
    {
        $password = $this->faker->word;
        $user = User::factory()->create(['password' => Hash::make($password)]);

        $this->postJson('/api/login', [
            //'email' => $user->email,
            'password' => $password,
        ])
        ->assertValidRequest();

        $this->assertAuthenticated();
    }

パラメータ不足を指摘されます。

  ---

The required properties (email) are missing

object++ <== The required properties (email) are missing
    email*: string
    password*: string
    remember: boolean

任意パラメータであるrememberは省略可能でしたが、定義と異なる型を指定するとこれも指摘されます。

    public function testLogin()
    {
        $password = $this->faker->word;
        $user = User::factory()->create(['password' => Hash::make($password)]);

        $this->postJson('/api/login', [
            'email' => $user->email,
            'password' => $password,
            'remember' => 1,
        ])
        ->assertValidRequest();

        $this->assertAuthenticated();
    }
  ---

The properties must match schema: remember
The data (integer) must match the type: boolean

object++ <== The properties must match schema: remember
    email*: string
    password*: string
    remember: boolean <== The data (integer) must match the type: boolean

このようにバリデーションのテストのうち、必須チェックと型チェックは別途行わなくても良いことになります。

レスポンスのチェック内容

assertStatus() によるステータスコードチェックと assertJsonStructure() によるレスポンスの構造チェックは assertValidResponse() を使ってまとめて対応できます。

    public function testLogin()
    {
        $password = $this->faker->word;
        $user = User::factory()->create(['password' => Hash::make($password)]);

        $response = $this->postJson('/api/login', [
            'email' => $user->email,
            'password' => $password,
        ])
        ->assertValidRequest()
        ->assertValidResponse(200);
        // 以下は assertValidResponse() で検証済み
        // $response->assertJsonStructure([
        //     'success',
        //     'code',
        //     'data' => [
        //         'id',
        //         'name',
        //         'email',
        //         'email_verified_at',
        //         'created_at',
        //         'updated_at',
        //     ],
        // ]);

        $this->assertAuthenticated();
    }

加えて、仕様としてレスポンス内に存在必須となる項目が新たに追加された場合、assertJsonStructure()ではそのままでもチェック通ってしまいますが、assertValidResponse()では厳密に必須項目までチェックしてくれます。

例えばレスポンスに birthday が加わった場合、

        $response = $this->postJson('/api/login', [
            'email' => $user->email,
            'password' => $password,
        ])
        ->assertValidRequest()
        ->assertValidResponse(200);
        // 以下は birthday の記載がなくても通ってしまう
        // $response->assertJsonStructure([
        //     'success',
        //     'code',
        //     'data' => [
        //         'id',
        //         'name',
        //         'email',
        //         'email_verified_at',
        //         'created_at',
        //         'updated_at',
        //     ],
        // ]);

assertValidResponse()でレスポンスのチェックを行うとエラーになりました。

  ---

The properties must match schema: data
The required properties (birthday) are missing

object++ <== The properties must match schema: data
    success*: boolean
    code*: integer
    aaa: string
    data*: object++ <== The required properties (birthday) are missing
        id*: integer
        name*: string
        birthday*: string?
        email*: string
        email_verified_at*: string?
        created_at*: string
        updated_at*: string

注意点

OpenAPIの定義を満たしていればOKになるので、OpenAPI側にない項目がリクエストやレスポンスに含まれていても上記のテストは通ってしまいます。
仮にログインAPIのレスポンスにbirthdayが追加になったとして、実装コードのレスポンスだけbirthdayが追加されていてOpenAPIの定義に追加がない状態ではSpectatorは差異を検知してくれません。
また、具体的な値の内容まではチェックしていないので、バリデーションエラーのメッセージが正確かどうかやレスポンスが意図した値になっているかは別途検証する必要があります。
メールなど通知関連やDB関連のレスポンスに含まれない仕様についての検証も同様です。

pathパラメータの型について

下記のような仕様定義してテストしたところ、エラーになってしまいました。

  /api/test/{id}:
    get:
      operationId: test
      description: |
        テスト
      parameters:
        - in: path
          name: id
          schema:
            type: integer
          required: true
      responses:
        "200":
          description: "Success response"
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  code:
                    type: integer
                  data:
                    type: object
                    properties:
                      id:
                        type: integer
                    required:
                      - id
                required:
                  - success
                  - code
                  - data

実装

    Route::get('test/{id}', function ($id) {
        return response()->success(['id' => $id]);
    });

テスト

public function testTest()
{
    $response = $this->getJson('/api/test/123')
    ->assertValidRequest()
    ->assertValidResponse(200);
}
  ---

The properties must match schema: data
The properties must match schema: id
The data (string) must match the type: integer

object++ <== The properties must match schema: data
    success*: boolean
    code*: integer
    data*: object++ <== The properties must match schema: id
        id*: integer <== The data (string) must match the type: integer

pathパラメータはstring扱いになるので、レスポンスに利用する場合は仕様側の型をstringにするか適宜キャストするといった対応が必要です。

Route::get('test/{id}', function ($id) {
    return response()->success(['id' => (int)$id]);
});

まとめ

いくつか注意点はあるものの、ほぼ既存のアサートメソッドを置き換えるだけでより厳密なアサートができるようになるのは大きな利点と感じました。
今回紹介したassertJsonStructure()assertValidResponse()以外にも追加のメソッドがあるようなので、目的に応じて使い分けてみたいと思います。

Discussion