YAMLとpytestで動くAPIテストフレームワーク「Tavern」でWebAPIの自動テストが簡単に書けた

# 前置き
データ連携のお仕事にも携わることになりまして、契約した企業へと自社データを提供するAPIを作りました。


画面で動く既存サービスもAPI構成にはなっており、そっちのWebAPIのテストはFrisby.jsで書いてみたりしたのですが、新たにWebAPI対応をするにあたり
- そもそも自分はそんなNode.jsっ子ではないな...
- 社内でPythonが浸透してきているのでPythonで書けないか
と思いました。

pytestをある程度調べたあたりで、[Tavern](https://tavern.readthedocs.io/en/latest/#)というAPI testing frameworkの存在を知り、これが良さそうだったので使ってみました。

# 前置き2：テスト対象とするAPI
こんなAPIをテストしていきます。

- 契約すると発行されるAPIキーをHTTPヘッダに埋め込む形で認証を行う
- RESTではない
- リクエストヘッダをContent-Type:application/json とし、JSONでやり取りをする。



# Tavernを便利に感じた点
- YAML形式で記述するだけでWebAPIのテストが出来る
- POSTリクエストも簡単に書ける
- HTTPリクエストヘッダーへの追記も簡単に出来る
- YAMLで書いたハッシュと配列がそのままJSONリクエストのbodyとして飛ばせる
- 外部ファイル読み込みなどが柔軟に行える
- pytestで動くのでpytestのオプションも使える

とにかく直感的でシンプルに書けました。
活用しきれていないオプションもありますが、今時点でやりたかったことはほぼ出来てしまいました。

なおTavernとはヨーロッパにおける居酒屋のことのようです。良い名前ですね:beers:

# 環境
- Python 3.6.5
- pytest 4.5.0
- tavern-0.26.4

tavern自体は **Tavern only supports Python 2.7/3.4 and up.** ですが、Python3推奨とのことです。
この記事ではWindows10 のGit Bashで実行しています。

# インストール

```
pip install tavern
```

# 使い方
1. 決められた規則のファイル名でyamlを書く
2. pytestコマンド実行

です。シンプル！

`test_*.tavern.yaml`という命名規則で置いておくとテスト対象となります。
あとは適宜後述のオプションを付けて

```
$ pytest --tb=line --tavern-global-cfg=staging.yaml
============================= test session starts =============================
platform win32 -- Python 3.6.5, pytest-4.5.0, py-1.8.0, pluggy-0.11.0
rootdir: C:\（ｘ＿ｘ）, inifile: setup.cfg
plugins: tavern-0.26.4, cov-2.7.1
collected 2 items

test_minimal.tavern.yaml ..                                              [100%]
========================== 2 passed in 1.34 seconds ===========================

```
といったコマンドで動きます。
オプションはすべてsetup.cfgに記載可能です。

ちなみにtavern-ciという、pytestを介さない実行方法もありますが、当然pytest側のオプションなどは利用できないので、推奨されている通りpytestを使いました。

# YAML記述方法
まず、こういうリクエストを書く場合はこう！みたいなのを載せます。


## 送るヘッダとbodyの例
今回のAPIでは、例えば以下のような形でリクエストをすると、qiita.comの2019年1月～3月のユーザー数の推移が取得できます。

↓リクエストヘッダ

| 項目       |    値  |
|:----------------:|:------------------:|
| Content-Type|   application/json        |
| X-API-KEY|  XXXXXXXXXXXXXXXXXXXXXXXXXXXXX       |

↓リクエストボディ

```
{
	"filters": {
		"site": {"value": "qiita.com"},
		"range": {
			"startyyyymm": 201901,
			"endyyyymm": 201903
		}
	},
	"options": {
		"device": "PC",
		"sort": ["-sessions"]
	},
	"dimensions": ["site","yyyymm"],
	"metrics": ["users", "sessions", "pageviews"]
}
```

## Tavernで書くとこうなる
これをTavernで書くと以下の通りになります。

```staging.yaml
---
name: Common test information for Staging Server
description: define http header and API endpoint.

variables:
  url: https://xxx.xxx.xxx/endpoint
  apikey: XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
```

```test_sample.tavern.yaml
---
test_name: site testcase 01
stages:
  - name: site testcase
    request:
      url: "{url:s}"
      json:
        filters:
          site:
            value: qiita.com
          range:
            startyyyymm: 201901
            endyyyymm: 201903
        options:
          device: PC
          sort: ["-sessions"]
        dimensions: ["site","yyyymm"]
        metrics: ["users", "sessions", "pageviews"]
      method: POST
      headers:
        content-type: application/json
        X-API-KEY: "{apikey:s}"
    response:
      status_code: 200
      body:
        result:
          - metrics:
              users: xxxxxxx
            information:
              title: "Qiita"
```

APIのエンドポイントとAPIキーなど、環境によって異なるパラメータは共通ファイルで読み込ませています。便利！
また、これは先ほどさらっと実行例で書いた --tavern-global-cfg= というTavernのオプションで指定することが出来ます。

## 解説
### test_name
pytestにおけるテスト名（def test_XXX）に相当します。

### request
リクエストヘッダやリクエストボディの定義をします。
ここは特に直感的だと思います。

### response
ここに書いたレスポンス内容が、テスト内容となります。
APIにリクエストをすると、以下のようなJSONが返却されます。

```
{
    "result": [
        {
            "information": {
                "title": "Qiita"
            },
            "metrics": {
                "users": xxxxxxx
            }
        }
    ]
}
```

これをyamlで表現すると

```
body:
  result:
    - metrics:
        users: 999999999
      information:
        title: "Qiita"
```
という形になります。入れ子前提のJSONをかなりシンプルに記載することが出来て良い感じです。
上記の場合、実際に返ってきたusersの値と、yamlに書いたusersの値が一致しているか、および返却されたtitleの値と実際に返ってきたtitleの値が一致しているかのassertionが行われます。

# その他の書き方

## 1ファイルに複数のテストを入れる
1つのyamlファイルに複数のテストを書く場合（そうなる場合が多いと思います）--- で区切ります。

```test_200.tavern.yaml
---
test_name: test01
stages:
  - name: test01
    request:
      url: "{url:s}"
      json:
        filters:
          site:
            value: qiita.com
          range:
            startyyyymm: 201901
            endyyyymm: 201901
        options:
          device: PC
        dimensions: ["site"]
        metrics: ["users", "sessions", "pageviews"]
      method: POST
      headers: &headers
        content-type: application/json
        X-VLS-API-KEY: "{apikey:s}"
    response:
      status_code: 200
      body:
        httpStatusCode: 200
        result:
          - metrics:
              users: xxxxxxx
            information:
              title: "Qiita"


--- 
test_name: test02
stages:
  - name: test02
    request:
      url: "{url:s}"
      json:
        filters:
          site:
            value: qiita.com
          range:
            startyyyymm: 201901
            endyyyymm: 201903
        options:
          device: PC
          maxresults: 1
          sort: ["yyyymm","-sessions"]
        dimensions: ["site","yyyymm"]
        metrics: ["users", "sessions", "pageviews"]
      method: POST
      headers:
        <<: *headers
    response:
      status_code: 200
      body:
        httpStatusCode: 200
        result:
          - metrics:
              users: xxxxxxx
            dimensions:
              yyyymm: "201901"
            information:
              title: "Qiita"
          - metrics:
              users: xxxxxxx
            dimensions:
              yyyymm: "201903"
            information:
              title: "Qiita"
          - metrics:
              users: xxxxxxx
            dimensions:
              yyyymm: "201902"
            information:
              title: "Qiita"
```

headersのところで、最初に出てきたものをアンカーとして定義し、2回目以降はエイリアスとして簡略化しています。



# 実行する

## pytestのオプション
デフォルトだとFAILURESの時にtavern自体のログが大量に出てしまうようです。
以下のいずれかのオプションで、良い感じに量が減らせました。

```
 --tb=short
 --tb=line
 --tb=no
```

参考：[Modifying Python traceback printing](https://docs.pytest.org/en/4.5.0/usage.html#modifying-python-traceback-printing)

**--tavern-beta-new-traceback** というtavern側のオプションもありましたが、あまり期待したものにならなかったので、一旦は上記のtb=lineまたはtb=shortで実行しています。


## 外部ファイル読み込み
外部ファイルを読み込むことが出来ます。

### includes
テストごとに指定したファイルを読み込むことが出来ます。これによって、パラメータの組み合わせをテストケースとして外部ファイルに切り出すことが出来るのが良い感じです。これらはテスト実行ディレクトリに置いておけば良いようです。

```
includes:
  - !include testcase01.yaml
```

### 共通ファイル (global configuration ) の利用
今回の場合、対象となるAPIエンドポイントや、トークンは全てのテストケースで同じものを使います。
これをglobalに管理することが出来ます。
実行時に
```
  --tavern-global-cfg=common.yaml
```
というオプションを付けることで全てのテストで利用できます。
今回は
staging.yaml
product.yaml
と開発環境用、本番環境用の2つを用意して使い分けています。

## とりあえず何個か作ってやってみたケース
試しに何個かベースとなるものを作ってみました。
ファイルの分け方迷いますが、とりあえずHTTPステータスコードで分けて、200については想定する機能別に分けてみました。

```
$ pytest  --duration=0
============================= test session starts =============================
platform win32 -- Python 3.6.5, pytest-4.5.0, py-1.8.0, pluggy-0.11.0
rootdir: C:\（ｘ＿ｘ）, inifile: setup.cfg
plugins: tavern-0.26.4, cov-2.7.1
collected 5 items

test_200_userattributes.tavern.yaml .                                    [ 20%]
test_200_usertrans.tavern.yaml ...                                       [ 80%]
test_400.tavern.yaml .                                                   [100%]

=========================== slowest test durations ============================
1.14s call     test_200_usertrans.tavern.yaml::site_yyyymm2
0.82s call     test_200_userattributes.tavern.yaml::basetest
0.46s call     test_200_usertrans.tavern.yaml::site_yyyymm
0.22s call     test_200_usertrans.tavern.yaml::basetest
0.09s call     test_400.tavern.yaml::ApikeyNotAvailableError

(0.00 durations hidden.  Use -vv to show these durations.)
========================== 5 passed in 2.91 seconds ===========================
```
durationオプションにより、APIのレスポンスタイムも分かるのが良い感じです。


# 課題：Rate Limitどうすんのという話
ところで、WebAPIにはRate Limitというものが存在します。
[こんな記事を調べて書いている](https://qiita.com/yuji_saito/items/2834ab921d9d4f0639a8)くらいなので、当然自分で作ったAPIにもRate Limitをつけており、普通にテストをしていると引っ掛かってしまいます。

手段としては

a) 適度にsleepさせる
b) テスト用トークンを大量に発行する
c) Rate Limitを無視するテスト用トークンを作成する。

という感じかなと思います。
WebAPIのテストを、テストにおいてどこに位置付けるかによるかなとも思います。
より単体テストに近い側ではb)を検討、システムテストに近い側ではa)を検討、といった感じでしょうか。
c)は実装が必要なのと、機能があると人は本番環境でも使ってしまいたくなるものなので不採用にしました。


# 所感
とにかく書きやすかったです。
ただ、WebAPIにおいて「この条件だと何レコード返ってくる」みたいな、レスポンスのレコード数の多いテストは書くのが辛かったりするので通常のPythonコードによるpytestとの併用が良いかなと思います。
今回はAPIの性質上利用していませんが、[1つのstageに複数のテストがかける](https://tavern.readthedocs.io/en/latest/examples.html#multi-stage-tests)のも、テストにおいて相当威力が高そうです。


# 参考
- [Tavern](https://tavern.readthedocs.io/en/latest/#)
- [How to test a RESTful API](https://apagiaro.it/tavern-test-api/)
- [Modifying Python traceback printing](https://docs.pytest.org/en/4.5.0/usage.html#modifying-python-traceback-printing)
- [たった1人から始める社内テストコード文化](https://qiita.com/haminiku/items/9f1b9a2fb773c732c494)
- [逆引きpytest](https://qiita.com/FGtatsuro/items/0efebb9b58374d16c5f0)
- [pytestのとりあえず知っておきたい使い方](https://qiita.com/kg1/items/4e2cae18e9bd39f014d4)