Expressで実装したREST APIのresponseスキーマはjest-openapiで期待通りか?テストできる
はじめに
こんにちは、SHIFT の開発部門に所属しているKatayamaです。
今回は前回のREST APIをExpressで実装する際にrequestのスキーマvalidation・sanitizationをするには?の続きで、APIから返されるresponseスキーマがOpenAPIの定義と一致していることをどのようにテストするのか?について理解を深めていこうと思います。これはjest-openapiで簡単に実装でき、その実装を行っていく中で理解した事を備忘録のようなものとしてまとめておこうと思います。 (今後、mockデータではなくDBに接続してCRUDを処理、の1編を続編として投稿予定です。)
※本投稿ではAPIの実装とそのテストの実装までを扱っていますが、jestフレームワークのライブラリを使う事で後回しになりがちなテストも簡単に書けるというようなフレームワークのメリットも体感頂ければ幸いです。
responseスキーマが期待通りか?テストする
REST APIの実装
今回は簡単にmockを返す実装を行う(目的はあくまでresponseスキーマがOpenAPIの定義と一致しているか?をテストする事なため)。
コードしては以下のようなイメージ。
const resObj = {
id: 1,
title: '牛乳買ってきて',
description: '牛乳なくなったから帰りに2本買ってきて',
is_complete: false,
created_at: 1633480000
};
router.get(
'/todo/:id',
// 省略
(req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ state: 'BadRequest', message: errors.array().toString() });
}
if (req.params.id === 10) {
return res.status(404).json({
state: 'TodoNotFound',
message: `id:${req.params.id}に該当するTodoは見つかりませんでした`
});
}
return res.status(200).json(resObj);
}
);
※message: errors.array().toString()
実装としてよいかは別として、今回はOpenAPIのスキーマの定義が以下のようになっているので、stringにしている。
Error:
type: object
title: Error
description: エラーオブジェクトの共通スキーマ
properties:
state:
type: string
description: エラーの種別
example: TodoNotFound
maxLength: 255
message:
type: string
description: エラーメッセージ
maxLength: 255
example: Todoが1件も存在しません
readOnly: true
required:
- state
- message
REST APIのテスト(Jest)の実装
jest-openapiを使って、実装したREST APIがOpenAPIの記述ルールに基づいて定義したREST APIの設計と一致しているか?をテストする。
テストのコードは以下。
// 省略
import jestOpenAPI from 'jest-openapi';
import appRoot from 'app-root-path';
jestOpenAPI(appRoot.resolve('/src/openapi/todo.yaml'));
describe('Todos API ', () => {
const host = config.get('jest.restapiEndpoint');
describe('Test Block', () => {
// GETリクエストの結果、ステータス200が返ってくる+そのresponseBodyのスキーマがOpenAPIの定義と一致する事をテスト
test('GET:/api/todo/{id}', async () => {
const res = await axios.get(`${host}/api/todo/1`);
expect(res.status).toEqual(200);
expect(res).toSatisfyApiSpec();
});
});
});
// 省略
import jestOpenAPI from 'jest-openapi';
import appRoot from 'app-root-path';
jestOpenAPI(appRoot.resolve('/src/openapi/todo.yaml'));
describe('Todos API', () => {
const host = config.get('jest.restapiEndpoint');
describe('Test Block', () => {
// GETリクエストの結果、requestのvalidation/sanitizationでエラーになりステータス400が返ってくる事+そのエラーのresponseBodyのスキーマがOpenAPIの定義と一致する事をテスト
test('GET:/api/todo/{id} id is not over 1(Bad Request)', async () => {
let res;
try {
res = await axios.get(`${host}/api/todo/0`);
} catch (error) {
res = error.response;
}
expect(res.status).toEqual(400);
expect(res).toSatisfyApiSpec();
});
// GETリクエストの結果、requestのvalidation/sanitizationでエラーになりステータス400が返ってくる事+そのエラーのresponseBodyのスキーマがOpenAPIの定義と一致する事をテスト
test('GET:/api/todo/{id} id is not int(Bad Request)', async () => {
let res;
try {
res = await axios.get(`${host}/api/todo/test`);
} catch (error) {
res = error.response;
}
expect(res.status).toEqual(400);
expect(res).toSatisfyApiSpec();
});
// GETリクエストの結果、requestのvalidation/sanitizationでエラーになりステータス400が返ってくる事+そのエラーのresponseBodyのスキーマがOpenAPIの定義と一致する事をテスト
test('GET:/api/todo/{id} id is not match in DB Data(Todo Not Found)', async () => {
let res;
try {
res = await axios.get(`${host}/api/todo/10`);
} catch (error) {
res = error.response;
}
expect(res.status).toEqual(404);
expect(res).toSatisfyApiSpec();
});
});
});
// 省略
import jestOpenAPI from 'jest-openapi';
import appRoot from 'app-root-path';
jestOpenAPI(appRoot.resolve('/src/openapi/todo.yaml'));
describe('ToDo API:POST', () => {
const host = config.get('jest.restapiEndpoint');
describe('Test Block', () => {
// 省略
// POSTリクエストの結果、重複エラーになりステータス409が返ってくる事+そのエラーのresponseBodyのスキーマがOpenAPIの定義と一致する事をテスト
test('same title todo is already exits(Todo Already Exits)', async () => {
let res;
try {
res = await axios.post(`${host}/api/todo`, {
title: 'Buy Milk',
assigin_person: '山田 太郎'
});
} catch (error) {
res = error.response;
}
expect(res.status).toEqual(409);
expect(res).toSatisfyApiSpec();
});
});
});
ソースコードについて一部補足をすると、、、
●expect(res).toSatisfyApiSpec();
ここでOpenAPIの定義と、responseBodyのスキーマが一致しているか?の検証を行っている。もし一致していなければエラー(例えば以下のような)になり、Jestが失敗する。
● Todos API › Test Block › GET:/api/todo/{id} id is not over 1(Bad Request)
expect(received).toSatisfyApiSpec() // Matches 'received' to a response defined in your API spec, then validates 'received' against it
expected received to satisfy the '400' response defined for endpoint 'GET /api/todo/{id}' in your API spec
received did not satisfy it because: response must have required property 'state'
received contained: { body: { test: 'BadRequest', message: '[object Object]' } }
The '400' response defined for endpoint 'GET /api/todo/{id}' in API spec: {
'400': {
description: 'Bad Request',
content: {
'application/json': { schema: { '$ref': '#/components/schemas/Error' } }
}
}
}
・参考:In API tests, validate the status and body of HTTP responses against your OpenAPI spec:
●import appRoot from 'app-root-path';
複雑な相対パスを書かずに済むようにできる便利パッケージ
import jestOpenAPI from 'jest-openapi';
import appRoot from 'app-root-path';
jestOpenAPI(appRoot.resolve('/src/openapi/todo.yaml'));
jestOpenAPI(__dirname.replace('/tests', '') + '/src/openapi/todo.yaml'); // <- 自分で頑張るとこんな感じになるのが上記のようにかけるので楽
## 一部省略
# tree -I node_modules
.
├── src
│ └── openapi
│ └── todo.yaml
├── tests
│ └── normal_GET_todo.js
・参考:https://github.com/inxilpro/node-app-root-path
●import jestOpenAPI from 'jest-openapi';
HTTPのresponseがOpenAPI仕様を満たしていることを確認できる便利パッケージ
詳細は公式のリファレンスを参照だが、expect(res).toSatisfyApiSpec();でOpenAPIの仕様とresponseが合致しているか?を検証している
・参考:https://github.com/openapi-library/OpenAPIValidators/tree/master/packages/jest-openapi
※スキーマで1点注意として、スキーマのresponsesやrequestBodyにexamplesキーが含まれているているとエラーになるので含めないようにする(propertiesの方でexampleを設定していれば、RedocでOpenAPIの定義書を作った時に自動でresponseやrequestBodyのサンプルでpropertiesのexampleで例が作成される。)。
openapi: 3.1.0
paths:
"/api/todo/{id}":
# 省略
get:
# 省略
responses:
"200":
content:
application/json:
schema:
$ref: "#/components/schemas/ToDo"
examples: # <- これがあるとエラーになる
Get Todo:
value:
id: 1
title: Buy Book
# 省略
components:
schemas:
ToDo:
type: object
title: ToDo
description: Todoオブジェクトの共通スキーマ
# examples: []
properties:
id:
type: integer
description: ユニークID
example: 1 # <- ここで定義しておけば、Redocで定義書作成をすると例が自動で書かれる
# 省略
required:
- id
- title
## エラー
Invalid OpenAPI spec: [
{
instancePath: '/components/schemas/ToDo',
schemaPath: '#/additionalProperties',
keyword: 'additionalProperties',
params: { additionalProperty: 'examples' },
message: 'must NOT have additional properties'
},
{
instancePath: '/components/schemas/ToDo',
schemaPath: '#/required',
keyword: 'required',
params: { missingProperty: '$ref' },
message: "must have required property '$ref'"
},
# 省略
今回実装したAPIのOpenAPI定義について
OpenAPIでREST APIを設計してみたに書かれているものと同一なのでそちらを参照。
まとめとして
今回はExpressでREST APIを実装した際に、そのresponseスキーマがOpenAPIの定義と一致するか?をテストする方法について実際に実装してみて理解を深めてみた。しっかりOpenAPIの設計をやっておくことで、テストを書くだけでAPIの仕様が意図通りのものになっているのか?のテストができるので便利だなと感じた。
_________________________________
お問合せはお気軽に
https://service.shiftinc.jp/contact/
SHIFTについて(コーポレートサイト)
https://www.shiftinc.jp/
SHIFTのサービスについて(サービスサイト)
https://service.shiftinc.jp/
SHIFTの導入事例
https://service.shiftinc.jp/case/
お役立ち資料はこちら
https://service.shiftinc.jp/resources/
SHIFTの採用情報はこちら
https://recruit.shiftinc.jp/career/