OpenAPIドキュメントから全APIの自動テストを生成する

# はじめに
[グロースエクスパートナーズ Advent Calendar 2020](https://qiita.com/advent-calendar/2020/gxp) 3日目。
yitoです。

API開発をしている中で、テストするのに毎回クライアントツールにリクエストを用意してAPIを呼び出すのが手間でした。
それを、コマンド一発で、API仕様書に定義した全てのAPIをテストできるようにしたので、その方法を紹介します。


# 概要
OpenAPIドキュメントをAPIクライアントツールで利用可能なJSONに変換し、それをCLIライブラリで実行します。
これを実現する、以下について順番に説明していきます。

- Postmanについて
- OpenAPI 3.0 to Postman Collection v2.1.0 Converter
- Postman Collection SDK
- newman


# Postmanについて
APIリクエストのクライアントツールに[Postman](https://www.postman.com/)があります。
Postmanは、以下のようなことができます。

- 呼び出したいAPIのリクエスト情報を書き出して保存
    - => Postman Collections
- APIのテストスクリプト実装
    - ステータスコード、レスポンスボディのプロパティのテストなどが可能
    - https://learning.postman.com/docs/writing-scripts/test-scripts/

![スクリーンショット 2020-11-26 0.00.35.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/889950/639f1058-ad97-8333-c95b-91f57197072c.png)

書き出したリクエスト情報を保存すると、画面左側にある[Postman Collections](https://www.postman.com/collection/)に追加され、フォルダでの管理ができます。
このPostman Collectionsは、Webに公開する(制限をかけることも可能？)か、JSONでのexport/importで、開発チーム内で共有が可能です。


# OpenAPI 3.0 to Postman Collection v2.1.0 Converter
[OpenAPI 3.0 to Postman Collection v2.1.0 Converter](https://github.com/postmanlabs/openapi-to-postman)は、OpenAPIドキュメントをPostman CollectionのJSONに変換する、NodeJSで実行可能なライブラリです。
使い方は簡単で、OpenAPIのyamlを読み込んで、このライブラリのクラスに渡して実行するだけです。

```javascript

const fs = require('fs');
const readFileData = fs.readFileSync('OpenAPIドキュメントのファイルパス', {encoding: 'UTF8'});

const Converter = require('openapi-to-postmanv2');
Converter.convert({type: 'string', data: readFileData}, {},
    (err, conversionResult) => {
      if (!conversionResult.result) {
        console.error('API定義をPostman Collectionに変換できませんでした',
            conversionResult.reason);
        return;
      }

      const convertData = conversionResult.output[0].data;

      // Postman CollectionsのJSONファイルを生成する
      fs.writeFile('postman-collections.json', JSON.stringify(convertData, null, 4), (err) => {
        if(err) console.error(err)
      });

    }
);

```

この生成されたJSONファイルを読み込むと、Postmanにて利用ができます。

![スクリーンショット 2020-11-26 0.43.57.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/889950/e1a21a32-01e8-8782-d03d-dccc3fa02ed8.png)

ただ、このままだと、各APIのリクエストパラメータが `<long>` `<string>` のようになっていて、このままだと呼び出したいAPIのパラメータとしては不適切なので、使う際は書き換えが必要です。


手で１つ１つ直すのは面倒なので、Postman Collection SDKを使います


# Postman Collection SDK
[Postman Collection SDK](https://github.com/postmanlabs/postman-collection)は、Postman Collectionの生成・更新のための、NodeJSで実行可能なSDKです。
OpenAPI 3.0 to Postman Collection v2.1.0 Converterにて生成したPostman CollectionのJSONを、このSDKを使って編集します。

Postman CollectionのJSONファイルを読み込み、SDKにてCollectionをnewすると、以下のようなクラスが生成されます。
※[クラスについての詳細](https://www.postmanlabs.com/postman-collection/index.html)

- Collection
    - Postman Collectionの1つに相当
    - メンバーにItemGroupを持つ
- ItemGroup
    - Postman Collectionの1フォルダに相当
    - メンバーにItemを持つ
- Item
    - Postman Collectionの1つのAPIに相当
    - メンバーにRequestを持つ
- Request
    - APIのURL、リクエストパラメータに関する値
    - メンバーにHeaderList, EventList, VariableListなどを持つ

`<long>` `<string>` のような値を実際に使いたい値に変えるため、 `Request` の値を書き換えます。
[こちら](https://github.com/yito0000/openapi-postman-test-example)にあるOpenAPIドキュメント(api-spec.yml)、Postman Collection SDKを使った実装(postman-test/index.js)を使いながら説明します。

先ほどの実装に以下を追加します。

```javascript

...
      const convertData = conversionResult.output[0].data;

      const collection = new Collection(convertData);

      collection.items.all().forEach(item => updateItem(item))

      // Postman CollectionsのJSONファイルを生成する
      fs.writeFile('postman-collections.json', JSON.stringify(collection, null, 4), (err) => {
        if(err) console.error(err)
      });

...
```

`collection.items` に `ItemGroup` または `Item` があります。そこから、 `Request` を取り出して、 `<long>` `<string>` のような値を、使いたい値に書き換えます。

```javascript

...

const { v4: uuidv4 } = require('uuid');

const updateRequestVariable = (request, key, value) => {
  const findVariable = request.url.variables.find(
      variable => variable.key === key);
  if (findVariable) {
    findVariable.update({
      key: key,
      value: value
    })
  }
}

const updateRequestQuery = (request, key, value) => {
  const findQueryParam = request.url.query.find(
      queryParam => queryParam.key === key);
  if (findQueryParam) {
    findQueryParam.update({
      key: key,
      value: value
    })
  }
}

const updateRequest = (request) => {

  // Authorization
  if (request.auth && request.auth.type === 'apikey') {
    request.auth.update({
      "key": "api_key",
      "value": `api-key-${uuidv4()}`
    }, 'apikey')
  }

  // 書籍関連のAPI
  if (request.url.getPath().includes('books')) {
    updateRequestVariable(request, 'bookId', '100000001');
  }

  // ユーザー関連のAPI
  if (request.url.getPath().includes('users')) {
    updateRequestVariable(request, 'username', 'user-XXXX');
    updateRequestQuery(request, 'username', 'user-XXXX');
    updateRequestQuery(request, 'password', 'pass-XXXX');

  }

  return request;
}

const updateItem = (item) => {
  if (item.items) {
    item.items.all().forEach(item => updateItem(item));
  } else {
    item.request = updateRequest(item.request);

  }

  return item;
}

...
```

レスポンスに対するテストも実行したいので、 `updateItem` メソッドに以下のコードを追加します。

```javascript

...

const updateItem = (item) => {
  if (item.items) {
    item.items.all().forEach(item => updateItem(item));
  } else {
    // 追加
    item.events.add({
      listen: 'test',
      script: {
        exec: "pm.test('response 200 test', () => {\n"
            + "    pm.response.to.have.status(200);\n"
            + "});"
      },
      type: 'text/javascript'
    })

...
```

これでリクエストパラメータの書き換えがされた状態で、Postman CollectionのJSONが生成されます。

毎回JSONをimportしてAPIを呼び出すのは面倒なので、CLIだけで実行できるように、newmanを使います.


# newman
[newman](https://github.com/postmanlabs/newman)は、CLIでPostman CollectionのJSONを読み込みAPIをリクエストする、NodeJSで実行可能なライブラリです。

先ほどのコードで生成したJSONを、newmanで実行するように変えます。

```javascript
...
      const collection = new Collection(convertData);

      collection.items.all().forEach(item => updateItem(item))
      fs.writeFile('postman-collections.json', JSON.stringify(collection, null, 4), (err) => {
        if(err) console.error(err)
      });

      newman.run({
        collection: collection,
        reporters: 'cli',
        environment: require('./local.postman_environment.json')
      }, (err) => {
        if (err) console.error(err);
      });

...

```


- `newman.run()`
    - collection: Postman CollectionのJSONを指定
    - reports: 実行結果の出力方法の指定。CLI、Junit、JSON等が可能
    - envioroment: Postmanには環境ごとの変数の指定があり、生成されたJSONには `baseUrl` の指定が必要なため、向き先を指定したJSONを用意して読み込む

実行すると以下のようになります。

```terminal
% npm run start
> node index.js

newman

Book Management

❏ books
↳ 書籍更新API
  PUT http://localhost:8080/v1/books [200 OK, 123B, 235ms]
  ✓  response 200 test

↳ 書籍登録API
  POST http://localhost:8080/v1/books [200 OK, 123B, 19ms]
  ✓  response 200 test

↳ 書籍一覧取得API
  GET http://localhost:8080/v1/books [200 OK, 800B, 11ms]
  ✓  response 200 test

↳ タグ絞り込み検索API
  GET http://localhost:8080/v1/books/findByTags?tags=<string>&tags=<string> [200 OK, 800B, 14ms]
  ✓  response 200 test

❏ books / {book Id}
↳ 書籍詳細取得API
  GET http://localhost:8080/v1/books/100000001 [200 OK, 290B, 10ms]
  ✓  response 200 test

↳ 書籍削除API
  DELETE http://localhost:8080/v1/books/100000001 [200 OK, 123B, 7ms]
  ✓  response 200 test

❏ users
↳ ユーザー登録API
  POST http://localhost:8080/v1/users [200 OK, 123B, 8ms]
  ✓  response 200 test

↳ ユーザー一覧取得API
  GET http://localhost:8080/v1/users [200 OK, 123B, 6ms]
  ✓  response 200 test

↳ ログインAPI
  GET http://localhost:8080/v1/users/login?username=user-XXXX&password=pass-XXXX [200 OK, 123B, 8ms]
  ✓  response 200 test

↳ ログアウトAPI
  GET http://localhost:8080/v1/users/logout [200 OK, 123B, 6ms]
  ✓  response 200 test

❏ users / {username}
↳ ユーザー詳細取得APi
  GET http://localhost:8080/v1/users/user-XXXX [200 OK, 123B, 6ms]
  ✓  response 200 test

↳ ユーザー更新API
  PUT http://localhost:8080/v1/users/user-XXXX [200 OK, 123B, 5ms]
  ✓  response 200 test

↳ ユーザー削除API
  DELETE http://localhost:8080/v1/users/user-XXXX [200 OK, 123B, 7ms]
  ✓  response 200 test

→ タグ一覧取得API
  GET http://localhost:8080/v1/tags [200 OK, 123B, 6ms]
  ✓  response 200 test

┌─────────────────────────┬───────────────────┬──────────────────┐
│                         │          executed │           failed │
├─────────────────────────┼───────────────────┼──────────────────┤
│              iterations │                 1 │                0 │
├─────────────────────────┼───────────────────┼──────────────────┤
│                requests │                14 │                0 │
├─────────────────────────┼───────────────────┼──────────────────┤
│            test-scripts │                14 │                0 │
├─────────────────────────┼───────────────────┼──────────────────┤
│      prerequest-scripts │                 0 │                0 │
├─────────────────────────┼───────────────────┼──────────────────┤
│              assertions │                14 │                0 │
├─────────────────────────┴───────────────────┴──────────────────┤
│ total run duration: 654ms                                      │
├────────────────────────────────────────────────────────────────┤
│ total data received: 1.37KB (approx)                           │
├────────────────────────────────────────────────────────────────┤
│ average response time: 24ms [min: 5ms, max: 235ms, s.d.: 58ms] │
└────────────────────────────────────────────────────────────────┘
```


以上で、コマンド一発でAPI全てをテストできるようになりました。


# 参考
- https://learning.postman.com/docs/writing-scripts/test-scripts/
- https://www.postman.com/collection/
- https://github.com/postmanlabs/openapi-to-postman
- https://www.postmanlabs.com/postman-collection/index.html

# サンプルコード
https://github.com/yito0000/openapi-postman-test-example