Spectatorを使ってLaravelで実装したAPIとOpenAPI Schemaとの剥離を防ぐ
Spectatorとは
OpenAPIで定義したAPIのリクエスト内容・レスポンス内容を、実装コードが満たしているかを簡単に確認できるようにしてくれます。
LaravelのHTTPテストに組み込むことで、必須項目の有無や各項目の型があっているかをチェックしてくれます。
リポジトリはこちら
動作環境
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で変更します。
SPEC_PATH=openapi
この他、Spectatorでの検証対象となるミドルウェアグループをapiのみではなくwebも含めるような設定にします。
これでどちらのミドルウェアに属していてもSpectatorでの検証が可能になります。
/*
|--------------------------------------------------------------------------
| Middleware Groups
|--------------------------------------------------------------------------
|
| Specify the groups that spectator's middleware should be prepended to.
|
*/
'middleware_groups' => ['api', 'web'],
OpenAPIでスキーマを定義
例として、ユーザーログイン用のAPIが以下のように定義されているとします。
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