前職で使っていたツールの dredd について書いてみたいと思います。
dredd 公式サイト:
Dredd — HTTP API Testing Framework — Dredd latest documentation
公式サイトより、issue のほうがいろいろ情報あるかも:
Issues · apiaryio/dredd
dredd は openapi の仕様書をもとに、テストを自動で行ってるツールです。
(※api blueprint で書かれた仕様書でもOK)
すでに、openapi の仕様書を管理しているプロジェクトであれば、すぐに導入してテスト自動化ができます。
※openapi は v2 も v3 も対応していますが、v3 はまだ試験的な導入らしいので、場合によっては正しく動作しない可能性あり。詳しくは公式のドキュメント参照。
Dredd — HTTP API Testing Framework — Dredd latest documentation
dredd を使うメリットは、ざっくりこんな感じかと思います 👇
dredd を使うメリット 👍
- テストコードを書かなくていい(PHPUnit とか)
- CLI なので、CI に組み込みやすい
- openapi などの仕様書をメンテナンスする動機ができる
- ドキュメントのメンテは放置されがち・・・
- テストが簡単に行えるなら、ドキュメントちゃんと書きたくなる
- 他の開発メンバーも、ドキュメントからサーバーの挙動が把握できるようになる
dredd を使ってみる
事前準備
dredd を使うため、下記のエンドポイントを提供するサーバーを用意します。
# ユーザーのリストを取得
GET /users
# ユーザーを作成
POST /users
# 特定のユーザーを取得
GET /users/{userId}
# 特定のユーザーの削除
DELETE /users/{userId}
# 特定のユーザーの更新
PATCH /users/{userId}
上記のエンドポイントを定義した openapi の sample-app-api.yml
は下記です。
openapi: 3.0.3
info:
version: 1.0.0
title: ただのサンプルAPI
servers:
- url: http://localhost:{port}
variables:
port:
default:
"5000"
enum:
- "5000"
components:
schemas:
userObject:
type: object
properties:
firstName:
type: string
lastName:
type: string
age:
type: integer
id:
type: string
responseObject:
type: object
properties:
message:
type: string
body:
type: object
properties:
firstName:
type: string
lastName:
type: string
age:
type: integer
id:
type: string
responseObjectOnError:
type: object
properties:
message:
type: string
body:
type: object
updateCreateUserRequestObject:
type: object
properties:
firstName:
type: string
lastName:
type: string
age:
type: integer
parameters:
userIdInPath:
in: path
name: userId
schema:
type: string
required: true
example: 9999999999-999999999-99999999
securitySchemes:
basicAuth:
type: http
scheme: basic
security:
- basicAuth: []
paths:
/users:
get:
responses:
'200':
description: ユーザーのリストを返す
content:
application/json; charset=utf-8:
schema:
"$ref": "#/components/schemas/userObject"
example:
- firstName: Jorge
lastName: Washignton
age: 89
id: 9999999999-999999999-99999999
post:
requestBody:
content:
application/json:
schema:
"$ref": "#/components/schemas/updateCreateUserRequestObject"
example:
firstName: hoge
lastName: fuga
age: 33
responses:
'201':
description: ユーザーが作成された
content:
application/json; charset=utf-8:
schema:
"$ref": "#/components/schemas/responseObject"
example:
message: "ユーザーID: 19f64be0-fd1a-4e47-a569-65970abf2827 のユーザーを作成しました"
body:
firstName: hoge
lastName: fuga
age: 33
id: f9675c3b-fef0-4db6-9728-17e8fb0a6145
'415':
description: リクエストで送信するフォーマットが不正
content:
application/json; charset=utf-8:
schema:
"$ref": "#/components/schemas/responseObjectOnError"
/users/{userId}:
get:
parameters:
- "$ref": "#/components/parameters/userIdInPath"
responses:
'200':
description: ユーザーを取得した
content:
application/json; charset=utf-8:
schema:
"$ref": "#/components/schemas/responseObject"
'400':
description: パラメータのフォーマット不正
content:
application/json; charset=utf-8:
schema:
"$ref": "#/components/schemas/responseObjectOnError"
'404':
description: ユーザーが見つからなかった
content:
application/json; charset=utf-8:
schema:
"$ref": "#/components/schemas/responseObjectOnError"
delete:
parameters:
- "$ref": "#/components/parameters/userIdInPath"
responses:
'200':
description: ユーザーを削除した
content:
application/json; charset=utf-8:
schema:
"$ref": "#/components/schemas/responseObject"
'404':
description: ユーザーが見つからなかった
content:
application/json; charset=utf-8:
schema:
"$ref": "#/components/schemas/responseObjectOnError"
patch:
parameters:
- "$ref": "#/components/parameters/userIdInPath"
requestBody:
content:
application/json:
schema:
"$ref": "#/components/schemas/updateCreateUserRequestObject"
example:
firstName: patch
lastName: patch
age: 2
responses:
'200':
description: ユーザーを更新した
content:
application/json; charset=utf-8:
schema:
"$ref": "#/components/schemas/responseObject"
'404':
description: ユーザーが見つからなかった
content:
application/json; charset=utf-8:
schema:
"$ref": "#/components/schemas/responseObjectOnError"
dredd は、上記の様な openapi の内容を読み込み、paths
のそれぞれの内容に対してサーバーにリクエストを送り、そのレスポンスが openapi で定義された内容になっているかをチェックしてくれます。
例えば、/users
の get
を見て、dredd は、サーバーにこのエンドポイントへリクエストを投げます。
返ってきたレスポンスと、/users
の get
の responses
に定義されている、schema
の内容を比較して、定義通りのレスポンスが返ってくるかチェックします。
では、実際に dredd をどうやって使っていくかを書いていきます。
dredd インストール
npm install -g dredd
dredd の実行
dredd sample-app-api.yml http://localhost:5000/
- 第一引数:
- dredd に読み込ませるapiのドキュメント
- 第二引数:
- テストを行うサーバー
これだけで、動きます。
しかし、実際に dredd を動かすといろいろ問題が出てくると思います。
- ベーシック認証などがサーバーに実装されていると、すべてのテストが
Unauthorized
で失敗してしまう -
DELETE /users/{userId}
のテストをするときに、削除対象のユーザーがわからない(もしくは、そもそもユーザーが存在しないかもしれない)。また、PATCH /users/{userId}
などの、既存のユーザーの存在に依存するエンドポイントで同様に問題になります。
1
については、dredd 実行時のオプションで --header="Authorization: Basic aG9nZTpmdWdh"
のように指定してあげることで解決できます。
2
は、リクエストするときに、userId
がわからないことが原因で問題になります。そのため、リクエスト時に userId
を指定できれば解決できます。
テスト実行の際に、リクエストの内容を変えるための機能として、hooks があります。
hooks を使うことで、柔軟にリクエストの内容を編集したり、レスポンスの期待値を変えたりすることができます。
hooks を使って、リクエスト内容をいじる
hooks は dredd に付属している機能なので、dredd をインストールしたらすぐに使えます。
hooks は各種言語(Nodejs ,Go ,Perl ,PHP ,Python ,Ruby ,Rust) で書くことができます。
前述の通り、DELETE で削除するべきユーザーがわからない、もしくは存在しないのが問題なので、dredd がテストを実行する前に、hooks を使って削除されるためだけのユーザーを作成したいです。
このような場合、下記のような hooks を書くことで対応できます。
const hooks = require("hooks");
const axios = require("axios");
hooks.before("/users/{userId} > DELETE > 200 > application/json; charset=utf-8", async (transaction, done) => {
let deleteRequestPath = "";
const newUser = {
firstName: "dummy-user",
lastName: "dummy-user",
age: 1,
};
const response = await axios.post("http://localhost:5000/users", newUser);
hooks.log(`テスト実行前に、${response.data.message} (対象: ${transaction.name})`);
deleteRequestPath = `/users/${response.data.body.id}`;
transaction.fullPath = deleteRequestPath;
transaction.request.uri = deleteRequestPath;
done();
});
まずは、hooks
を読み込みます。
hooks には、before
や beforeEach
、after
afterEach
など、テストの実行ライフサイクルに対応する api があり、ライフサイクルのタイミング毎で実行されるコードを定義できます。
上記の場合、before
を使って DELETE /users/{userId}
がテストされる前に実行するコードをコールバック関数で定義しています。
DELETE /users/{userId}
のエンドポイントは、hooks の before の第一引数で下記のように表します。
/users/{userId} > DELETE > 200 > application/json; charset=utf-8
この表し方は、dredd がそれぞれのエンドポイントを識別するために使う API 名で、下記の dredd コマンドで確認できます。
dredd sample-app-api.yml http://localhost:5000/ --names
第二引数のコールバック関数の中身では、テスト対象のサーバーに対して、POST /users
を行って、それで作られたユーザーのユーザーIDを利用して、DELETE /users/{userId}
の userId
の部分を設定しています。
コールバック関数の引数には、dredd がリクエストする内容を表す transaction
オブジェクトが渡されます。リクエスト先のパスと、uri をこのコールバック関数内で作成したユーザーIDを設定したものに更新することで、作ったばかりのユーザーに対して、DELETE /users/{userId}
が行えます。
最後の done()
は、このコールバック関数が非同期でAPIリクエストを実行しているので、必要になります。コールバック関数が非同期の場合、done()
が実行されるタイミングで、テストが実行されます。
非同期の処理を行わない場合は、この done()
は不要です。
hooks を使うと、こんな感じで、テストの実行前にデータを用意したり、また、テストが終わるときに、不要なデータを削除といったこともできます。
OAuth などの認証を利用している場合も、hooks を使うことで、対応できると思います。
最後に、hooks を完成させて、テスト実行してみる
テストを通すために、下記のような hooks を書いて実行してみます。
const hooks = require("hooks");
const axios = require("axios");
// POST /users のリクエストを投げるときに、サーバーは application/json を期待しているので、
// あえて、違う content-type を指定している。
hooks.before("/users > POST > 415 > application/json; charset=utf-8", (transaction) => {
transaction.request.headers["Content-Type"] = "application/x-www-form-urlencoded";
});
// GET /users/{userId} のリクエストを投げるとき、{userId} には、英数字とハイフンしか受け付けていないので、
// 対象外の文字が入っている場合、バッドリクエストのエラーを返すようにサーバー側で実装。
// それをテストするために、テスト実行前にパスパラメータには誤った内容を設定している。
hooks.before("/users/{userId} > GET > 400 > application/json; charset=utf-8", (transaction) => {
const dummyPathParameter = "/users/@";
transaction.fullPath = dummyPathParameter;
transaction.request.uri = dummyPathParameter;
});
// 404 のエラーをテストしたいので、存在しないユーザーのIDをパスパラメータにセット
hooks.before("/users/{userId} > GET > 404 > application/json; charset=utf-8", (transaction) => {
const dummyPathParameter = "/users/1234567890";
transaction.fullPath = dummyPathParameter;
transaction.request.uri = dummyPathParameter;
});
// ユーザー削除のテストをするために、事前に削除されるだけのユーザーを作成
hooks.before("/users/{userId} > DELETE > 200 > application/json; charset=utf-8", async (transaction, done) => {
let userIdToBeDeleted = "";
const newUser = {
firstName: "dummy-user",
lastName: "dummy-user",
age: 1,
};
const response = await axios.post("http://localhost:5000/users", newUser);
hooks.log(`テスト実行前に、${response.data.message} (対象: ${transaction.name})`);
userIdToBeDeleted = `/users/${response.data.body.id}`;
transaction.fullPath = userIdToBeDeleted;
transaction.request.uri = userIdToBeDeleted;
done();
});
// 404 のエラーをテストしたいので、存在しないユーザーのIDをパスパラメータにセット
hooks.before("/users/{userId} > DELETE > 404 > application/json; charset=utf-8", (transaction) => {
const dummyPathParameter = "/users/1234567890";
transaction.fullPath = dummyPathParameter;
transaction.request.uri = dummyPathParameter;
});
// ユーザー情報を更新するテストをするために、テスト実行前に、ユーザーを作成してから、そのユーザーに対して更新のテストを行う
hooks.before("/users/{userId} > PATCH > 200 > application/json; charset=utf-8", async (transaction, done) => {
let userIdToBeDeleted = "";
const newUser = {
firstName: "dummy-user",
lastName: "dummy-user",
age: 2,
};
const response = await axios.post("http://localhost:5000/users", newUser);
hooks.log(`テスト実行前に、${response.data.message} (対象: ${transaction.name})`);
userIdToBeDeleted = `/users/${response.data.body.id}`;
transaction.fullPath = userIdToBeDeleted;
transaction.request.uri = userIdToBeDeleted;
done();
});
// 404 のエラーをテストしたいので、存在しないユーザーのIDをパスパラメータにセット
hooks.before("/users/{userId} > PATCH > 404 > application/json; charset=utf-8", (transaction) => {
const dummyPathParameter = "/users/1234567890";
transaction.fullPath = dummyPathParameter;
transaction.request.uri = dummyPathParameter;
});
dredd 実行時に、上記の hooks.js
を指定して、実行します。
dredd sample-app-api.yml http://localhost:5000/ --hookfiles=./hooks.js --header="Authorization: Basic aG9nZTpmdWdh"
結果↓
(node:4517) Warning: Accessing non-existent property 'padLevels' of module exports inside circular dependency
(Use `node --trace-warnings ...` to show where the warning was created)
pass: GET (200) /users duration: 75ms
pass: POST (201) /users duration: 15ms
pass: POST (415) /users duration: 12ms
pass: GET (200) /users/9999999999-999999999-99999999 duration: 12ms
pass: GET (400) /users/9999999999-999999999-99999999 duration: 15ms
pass: GET (404) /users/9999999999-999999999-99999999 duration: 11ms
hook: テスト実行前に、ユーザーID: 375dd003-fc93-4c07-9450-f5c35af8cd44 のユーザーを作成しました (対象: /users/{userId} > DELETE > 200 > application/json; charset=utf-8)
pass: DELETE (200) /users/9999999999-999999999-99999999 duration: 15ms
pass: DELETE (404) /users/9999999999-999999999-99999999 duration: 12ms
hook: テスト実行前に、ユーザーID: 3c492a37-3ae2-4142-8ad8-893ecebb81aa のユーザーを作成しました (対象: /users/{userId} > PATCH > 200 > application/json; charset=utf-8)
pass: PATCH (200) /users/9999999999-999999999-99999999 duration: 17ms
pass: PATCH (404) /users/9999999999-999999999-99999999 duration: 12ms
complete: 10 passing, 0 failing, 0 errors, 0 skipped, 10 total
complete: Tests took 342ms
実行されたそれぞれのテストに対して pass
と表示されて、openapi で定義されたとおりのレスポンスが返ってきたことが確認できました。
failing の数も 0 なので問題無さそうです!
もし、返ってきたレスポンスが、openapi とは異なる内容だった場合は fail
と表示されて、テスト実行の詳細が表示されるので、
それをみて、ドキュメントを修正するなり、サーバーを修正するなりして対応していきます。
下記の画像は fail
が起きた例です。 idd
という項目が openapi に書かれていたのでエラー。正しくは id
なので openapi のドキュメントを直すことで pass にできます。
以上です
dredd は openapi がすでにあれば、それですぐテスト自動化して、CI等に組み込めそうなので、便利なツールかなと思います。