👷テストツール dredd👷 openapi の内容でテストを実行する

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2291204/8682712f-2deb-d190-7bd3-d4d3b427b46a.png)


前職で使っていたツールの dredd について書いてみたいと思います。

dredd 公式サイト:
[Dredd — HTTP API Testing Framework — Dredd latest documentation](https://dredd.org/en/latest/index.html)

公式サイトより、issue のほうがいろいろ情報あるかも:
[Issues · apiaryio/dredd](https://github.com/apiaryio/dredd/issues)

dredd は openapi の仕様書をもとに、テストを自動で行ってるツールです。
(※api blueprint で書かれた仕様書でもOK)
すでに、openapi の仕様書を管理しているプロジェクトであれば、すぐに導入してテスト自動化ができます。

※openapi は v2 も v3 も対応していますが、v3 はまだ試験的な導入らしいので、場合によっては正しく動作しない可能性あり。詳しくは公式のドキュメント参照。
[Dredd — HTTP API Testing Framework — Dredd latest documentation](https://dredd.org/en/latest/#:~:text=OpenAPI%203%20(experimental%2C%20contributions%20welcome!))

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` は下記です。

```yaml: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 を動かすといろいろ問題が出てくると思います。

1. ベーシック認証などがサーバーに実装されていると、すべてのテストが `Unauthorized` で失敗してしまう
2. `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 を書くことで対応できます。

```javascript:hooks.js
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 を書いて実行してみます。

```javascript:hooks.js
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 にできます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2291204/44a424f8-12b1-5a9f-7f30-c4bd83b8b551.png)

## 以上です

dredd は openapi がすでにあれば、それですぐテスト自動化して、CI等に組み込めそうなので、便利なツールかなと思います。