runnを使ったWebバックエンドのe2eテスト

こちらは [HITOTSU株式会社 Advent Calendar 2022](https://qiita.com/advent-calendar/2022/hitotsu) の5日目の記事になります。

こんにちは、[HITOTSU株式会社](https://hitotsu.co.jp/)にて、業務委託でプロジェクトに参画させていただいている [こうりん](https://twitter.com/Kourin1996) と申します。

HITOTSU内では、現在進めているプロダクトの技術選定から関わらせて頂いており、主にWebバックエンド開発に携わっています。(たまに、Reactのフロントエンドのコードを書いたりもします)

HITOTSUのプロダクトのWebバックエンドはGoを使っており、軽量Webフレームワークとして [labstack/echo](https://github.com/labstack/echo) を使用しています。

この記事では、Webバックエンド向けのツールとして [runn](https://github.com/k1LoW/runn) をご紹介させていただきます。本記事で使用するサンプルコードは [github.com/Kourin1996/go-api-e2e-sample](https://github.com/Kourin1996/go-api-e2e-sample) にて公開しています。

# APIのe2eテスト

これまで当プロジェクトのバックエンドのテストは、ユニットテストを中心に行ってきました。実際のシナリオに則して複数のAPIをコールした時 (e.g. 作成→ステータスアップデートなど) に期待通りに動作するかの確認については、シンプルに記述・管理できるためe2eテストの拡充を進めています。

e2eテスト向けのフレームワークとしては様々なものがありますが、HITOTSUでは [k1LoW](https://twitter.com/k1LoW) 氏によって開発された [runn](https://github.com/k1LoW/runn) を使用することにしました。その理由としては
* OpenAPIのファイルを元にスキーマチェックができる
* リクエストや期待するレスポンスをJSONファイルで記述ができる
* 複数のAPIコールが伴うテストを記述するのが簡単
* 実装がGoである
    * テストを動かすのに新たに環境構築する必要がない
    * Goのテストとして動かせる
    * 関数や事前処理をGoのコードで柔軟に記述できる

などがありました

それ以外にも、GitHubのリポジトリに記載されている特徴として、以下のものが挙げられます。

* シナリオベース向けのテスト用のツール
* HTTP/gRPCのテストが可能
* テスト中にDBへ直接アクセスができる
* OpenAPIのスキーマに類似したテストケースを記述が可能

# runnのはじめかた

早速 runn を使ったAPIのe2eテストを実装する方法について説明していきます。runn を使ったテストの方法には、(1) CIツールを動かす (2) Goのテストとして組み込む の2種類がありますが、まずは前者の方について説明します。

使用するにはGo 1.19以上が必要になりますので、実際に試す場合にはGoの実行環境をセットアップしてください。また、runnのGitHubのリポジトリにはバイナリやDockerコンテナも用意されているので、そちらを使用することもできます。

```bash
$ go install github.com/k1LoW/runn/cmd/runn@latest
```

## APIコード説明

テストの説明に入る前に、記事中で行うテストの対象APIを簡単に説明します。詳しくは、[github.com/Kourin1996/go-api-e2e-sample](https://github.com/Kourin1996/go-api-e2e-sample)を参照してください。

今回のAPIは本を登録するAPIになり、本を登録するAPIとIDを元に本を1件取得するAPIの2種類持ちます。  

ちなみに、APIのコードは簡易的なものであるためDBには接続せず、インメモリでデータを管理しています。

### POST /books

名前と著者をbodyに指定して本を1件登録します。レスポンスは登録したレコードのデータを返します。

```post_book_request.json
{
  "name": "走れメロス",
  "author": "太宰治"
}
```

```post_book_response.json
{
  "id": 0,
  "name": "走れメロス",
  "author": "太宰治",
  "created_at": "2022-12-05T22:25:02.66881+09:00"
}
```

### GET /books/{id}

パスパラメータで指定したIDとマッチする本のデータを返します。無い場合は`null`を返します。

```get_book_response.json
{
  "id": 0,
  "name": "走れメロス",
  "author": "太宰治",
  "created_at": "2022-12-05T22:25:02.66881+09:00"
}
```

## テストの実装

早速runnを使ったテストのコードを記述していきます。runnのテストはYAML形式で記述します。

### POSTのテスト

以下のYAMLはPOSTのAPIをテストするコードになります。

```e2e/books/create_book.yaml
# テストの説明
desc: Create a book
runners:
  req:
    endpoint: http://localhost:8080 # APIのベースパス
debug: true # ログとしてデバッグデータを表示するか
vars:
  createBookReq: "json://create_book_req.json" # JSONデータの読み込み
steps:
  createBook:
    req:
      /books: # APIのパス
        post:
          body:
            application/json: "{{ vars.createBookReq }}" # リクエストボディ
    test: |
      steps.createBook.res.status == 201
```

```e2e/books/create_book_req.json
{
  "name": "走れメロス",
  "author": "太宰治"
}
```

テストを行うAPIのURLは、`runners.req.endpoint`と`steps`の`req`直下で指定します。ここでは、ベースパスとして`http://localhost:8080`とstepで`/books`を指定しているので、`http://localhost:8080/books`を呼ぶテストになります。また、`/books`直下が`post`になっているため、POSTで呼び出します。

```yaml
runners:
  req:
    endpoint: http://localhost:8080 # APIのベースパス
...
steps:
  createBook:
    req:
      /books: # APIのサブパス
        post: # POSTで呼ぶ
```

リクエストボディは`body`以下で指定します。この時、直接文字列を渡すことが出来ますが、別ファイルで定義したJSONファイルを指定することも出来ます。JSONファイルを読み込むには`vars`の部分で`json://path_to_json`で指定すると指定したパスのJSONファイルを読み込み、`vars.hoge`などで任意の部分に展開できます。

```yaml
vars:
  # JSONデータの読み込み
  createBookReq: "json://create_book_req.json"
steps:
  createBook:
    req:
      /books: # APIのパス
        post:
          body:
            # リクエストボディをvarsから展開
            application/json: "{{ vars.createBookReq }}" 
```

APIの実行を踏まえて値のチェックをするには`test`のところで指定します。ここでは、ステータスコードのみをチェックしています。

```yaml
test: |
  steps.createBook.res.status == 201
```

テストの実行は`runn run [yamlファイル]`コマンドで指定できます。テスト中で`debug:true`を指定しているため、APIの呼び出し詳細などが表示されます。

```bash
$ runn run ./e2e/books/create_book.yaml

Run 'req' on 'Create a book'.steps.createBook
-----START HTTP REQUEST-----
POST /books HTTP/1.1
Host: localhost:8080
Content-Type: application/json

{"author":"太宰治","name":"走れメロス"}
-----END HTTP REQUEST-----
-----START HTTP RESPONSE-----
HTTP/1.1 201 Created
Content-Length: 103
Content-Type: application/json; charset=UTF-8
Date: Mon, 05 Dec 2022 13:55:00 GMT

{"id":1,"name":"走れメロス","author":"太宰治","created_at":"2022-12-05T22:55:00.065074+09:00"}

-----END HTTP RESPONSE-----
Run 'test' on 'Create a book'.steps.createBook
-----START TEST CONDITION-----
steps.createBook.res.status == 201

├── steps.createBook.res.status => 201
└── 201 => 201
-----END TEST CONDITION-----
Create a book ... ok

1 scenario, 0 skipped, 0 failures
```

ちなみにテストが失敗するような場合は以下のような出力になります。

```bash
Create a book ... failed to run e2e/books/create_book.yaml: test failed on 'Create a book'.steps.createBook: (steps.createBook.res.status == 200
) is not true
steps.createBook.res.status == 200

├── steps.createBook.res.status => 201
└── 200 => 200


1 scenario, 0 skipped, 1 failure
```

### GETのテスト

次にGETの方のテストも書いていきます。

GETの方も同様に書いていきます。パスの指定でも、`vars`で設定した値を展開することができます。また、テストでは`compare`という組み込み関数を使って、レスポンスが`vars`にセットしたJSONのデータと一致するかチェックしています。

```e2e/books/get_book.yaml
desc: Get a book
runners:
  req:
    endpoint: http://localhost:8080
debug: true
vars:
  id: 1
  getBookRes: "json://get_book_res.json"
steps:
  getBook:
    req:
      /books/{{ vars.id }}:
        get:
          body: null
    test: |
      steps.getBook.res.status == 200
      && compare(steps.getBook.res.body, vars.getBookRes)
```

以下が期待するJSONデータです。 `created_at`の値は`POST`を実行したタイミングに依存するので気をつけてください。特定のフィールドのチェックをスキップする方法については後述します。

```e2e/books/get_book_res.json
{
  "id": 0,
  "name": "走れメロス",
  "author": "太宰治",
  "created_at": "2022-12-05T22:25:02.66881+09:00"
}
```

## includeを使用した複数テストの実行

テストファイルの`steps`では複数のリクエストを指定して順番に実行できる他、別ファイルで定義したテストファイルを読み込んで実行できます。

以下のテストでは、本の登録と取得を順番にテストします。各テストの詳細については、今までの定義したファイルに基づきます。`include`を使用することで他のテストファイルを指定でき、また`vars`でそのサブテストに渡す値を指定できます。この`vars`で指定した値は今までと同様に`vars.hoge`などで、そのテスト中に参照できます。

呼び出し元のファイル中の`vars`で参照できるものであっても、呼び出し先に渡したい値は必ず`include.vars`以下で指定する必要があります。

```e2e/books/create_and_get_book.yaml
desc: Create and get a book
runners:
  req:
    endpoint: http://localhost:8080
debug: true
vars:
  createBookReq: "json://create_book_req.json"
  id: 1
  getBookRes: "json://get_book_res.json"
steps:
  createBook:
    include:
      path: create_book.yaml
      vars:
        createBookReq: "{{ vars.createBookReq }}"
  getBook:
    include:
      path: get_book.yaml
      vars:
        id: {{ vars.id }}
        getBookRes: "{{ vars.getBookRes }}"
```

`create_book.yaml`と`get_book.yaml`を少しだけ修正します。`vars`は呼び出し元から指定されるため消します。また、`if: included`を指定することで、このファイルを起点にテストを実施しないことを指定し、`runn ./**/*.yaml`などで全てのYAMLファイルのテストを実施しようとした際に、このファイルを直接テストとして実行しなくなります。

```e2e/books/create_book.yaml
# テストの説明
desc: Create a book
runners:
  req:
    endpoint: http://localhost:8080 # APIのベースパス
debug: true
if: included
steps:
  createBook:
    req:
      /books: # APIのパス
        post:
          body:
            application/json: "{{ vars.createBookReq }}" # リクエストボディ
    test: |
      steps.createBook.res.status == 201
```

```e2e/books/get_book.yaml
desc: Get a book
runners:
  req:
    endpoint: http://localhost:8080
debug: true
if: included
steps:
  getBook:
    req:
      /books/{{ vars.id }}:
        get:
          body: null
    test: |
      steps.getBook.res.status == 200
      && compare(steps.getBook.res.body, vars.getBookRes)
```

実際に、すべてのYAMLファイルを対象にテストを実行してみると、`create_book.yaml`と`get_book.yaml`は単独ではスキップされていることが確認でき、テストが通ります。

```bash
# ./e2e/以下のすべてのyamlファイルを指定
$ runn run ./e2e/**/*.yaml

...

steps.getBook.res.status == 200
&& compare(steps.getBook.res.body, vars.getBookRes)

Create and get a book ... ok
Skip Create a book
Create a book ... skip
Skip Get a book
Get a book ... skip

3 scenarios, 2 skipped, 0 failures
```

# Goテストへのrunnの組み込み

runnのもう一つ大きな特徴はgoのテスト中に呼び出すことができることだと思います。これにより、既存のコードを流用してテストをもっと簡潔に記述したり、柔軟なアサーションが可能になります。

Goのテストで使用するには、`runn.Load`関数でテストを読み込み、返り値オブジェクトの`RunN`メソッドで読み込んだテストを実行します。

`Load`関数のオプションとして、`vars`や関数を指定できます。HITOTSUのプロジェクトではテスト前にJWTを取得して`vars`に設定したり、タイムスタンプの比較をスキップするオブジェクトの比較関数や整数IDから文字列のハッシュIDに変換する関数をテスト中から呼び出せるようにしています。

```main_test.go
package main_test

import (
	"context"
	"testing"

	"github.com/google/go-cmp/cmp"
	"github.com/google/go-cmp/cmp/cmpopts"
	"github.com/k1LoW/runn"
)

// タイムスタンプの比較を無視した比較関数
func CompareModelObject(x, y interface{}) bool {
	return cmp.Diff(x, y, cmp.Options{
		cmpopts.IgnoreMapEntries(func(k string, v interface{}) bool {
			return k == "created_at" || k == "updated_at"
		}),
	}) == ""
}

func TestE2E(t *testing.T) {
　　 // 呼び出しにJWTを使用する際には、テスト前にJWTを取得してテストで使用できるようにする
	// jwt, err := helper.GetAuth0JWT(
	// )

	// if err != nil {
	// 	t.Errorf("failed to fetch JWT: %v", err)

	// 	return
	// }

	runner, err := runn.Load(
		"e2e/**/*.yaml",
		runn.T(t),
        // ベースパスの指定
		runn.Runner("req", "http://localhost:8080"),
        // run中のテストで、customcompare(x, y)とかで使えるようにする
		runn.Func("customcompare", CompareModelObject),
        // run中のテストで、vars.tokenでJWTを参照できるようにする
		// runn.Var("token", jwt),
	)

	if err != nil {
		t.Fatal(err)
	}

	ctx := context.TODO()
	if err := runner.RunN(ctx); err != nil {
		t.Fatal(err)
	}
}
```

# まとめ

runnを使用したテストはJSONファイルのインポートやGoからのテスト実行により、柔軟性がありつつ簡潔にテストを記述することができます。本記事では紹介しませんが、DBにアクセスしてSQLを実行もできるため保存したデータと比較したりすることができます。また、Chrome DevTools Protocolを使用してWebフロントエンドのテストもできるようです。  

バックエンドの実装やユニットテストの方針の知見については、別の記事にてご紹介したいと思います。

最後まで読んでいただきありがとうございました。

# Appendix

以下は投稿後に追記した項目になります。

## A. JSONデータへの値の埋め込み

記事中ではリクエストや期待するレスポンスの指定にはJSONファイルを読み込む形にしました。これはテストファイルを簡潔に書けて素晴らしいですが、`id`など事前に定義する難しいデータがリクエストやレスポンスに含めることが不可能です。しかし、runnではロード時にJSONファイルに値を埋め込む機能が実装されています。

ここでは、GETの方のテストのレスポンスチェックにおいて、期待値のIDを動的に変更できるようにします。

まず、テストを修正します。読み込むJSONファイルを`.json.template`形式にします。また、ここではサブテストに渡す`ID`も前のサブテストの結果を使用するようにしています。前テストのレスポンスを参照するには、`steps.[ステップ名].res.body.[フィールド名]`などと指定します。サブテストで実行しているため、以下のYAMLファイルでは2段で参照していることに注意してください。

```e2e/books/create_and_get_book.yaml
desc: Create and get a book
runners:
  req:
    endpoint: http://localhost:8080
debug: true
steps:
  createBook:
    include:
      path: create_book.yaml
      vars:
        createBookReq: "json://create_book_req.json"
  getBook:
    include:
      path: get_book.yaml
      vars:
        id: "{{ steps.createBook.steps.createBook.res.body.id }}"
        getBookRes: "json://get_book_res.json.template"
```

`get_book_res.json`の方も`get_book_res.json.template`に修正します。JSON中で値を埋め込む場合は、YAMLでの構文と似ていますが最初に`.`が付きます。また、参照できる値は読み込み元のファイルでロード時のタイミングで参照できる値になります。`.json.template`を指定する上行で`id`を`vars`に定義していましたが、これは参照できないため前サブテストの結果を参照しています。

```e2e/books/get_book_res.json.template
{
  "id": {{.steps.createBook.steps.createBook.res.body.id}},
  "name": "走れメロス",
  "author": "太宰治",
  "created_at": "2022-12-05T22:25:02.66881+09:00"
}
```

## B. OpenAPIを活用したスキーマチェック

runnではテスト中に実行したAPIのリクエストやレスポンスがOpenAPIのものと一致しているかスキーマのチェックをすることができます。

まず、OpenAPI形式のスキーマファイルを追加します。

```schemas/book.yaml
openapi: 3.0.0
info:
  title: Create Book
  description: Create a Book record
  version: 1.0.0
paths:
  /books:
    post:
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateBookReq'
      responses:
        '201':
          description: Return a created book
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Book'
  /books/{id}:
    get:
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
      responses:
        '200':
          description: Return a book
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Book'


components:
  schemas:
    CreateBookReq:
      type: object
      properties:
        name:
          type: string
        author:
          type: string
      required:
        - name
        - author

    Book:
      type: object
      properties:
        id:
          type: integer
          format: uint64
        name:
          type: string
        author:
          type: string
        created_at:
          type: string
          format: date-time
```

次にGoのテストの方でスキーマを読み込みます。

```main_test.go
package main_test

import (
	"context"
	"testing"

	"github.com/google/go-cmp/cmp"
	"github.com/google/go-cmp/cmp/cmpopts"
	"github.com/k1LoW/runn"
)

...

func TestE2E(t *testing.T) {
    ...

	runner, err := runn.Load(
        ...
		runn.Runner(
			"req",
			"http://localhost:8080",
            // 以下を追記
			runn.OpenApi3("schemas/book.yaml"),
		),
       ...
	)
    ...
}
```

次にテストを実行してみます。OpenAPIで定義したスキーマは、APIのリクエスト・レスポンスと一致しているためテストは通ります。

```bash
$ go test ./...
ok      github.com/Kourin1996/go-api-e2e-sample 0.401s
?       github.com/Kourin1996/go-api-e2e-sample/controllers/books       [no test files]
?       github.com/Kourin1996/go-api-e2e-sample/domain/books    [no test files]
?       github.com/Kourin1996/go-api-e2e-sample/repositories/books      [no test files]
```

試しにOpenAPIの方のスキーマを編集し、リクエストに新しい必須フィールド`price`を追加してみます。

```schemas/book.yaml
...

components:
  schemas:
    CreateBookReq:
      type: object
      properties:
        name:
          type: string
        author:
          type: string
          type: integer
      required:
        - name
        - author
        - price
```

再度テストを実行すると、`price`がリクエストボディに無い旨が表示されテストが失敗します。同様にレスポンスに関してもチェックすることが出来ます。

```bash
$ go test ./...

Run 'include' on 'Create and get a book'.steps.createBook
Run 'req' on 'Create a book'.steps.createBook
-----START HTTP REQUEST-----
POST /books HTTP/1.1
Host: localhost:8080
Content-Type: application/json

{"author":"太宰治","name":"走れメロス"}
-----END HTTP REQUEST-----
Skip Create a book
Skip Get a book
--- FAIL: TestE2E (0.01s)
    --- FAIL: TestE2E/Create_and_get_a_book(e2e/books/create_and_get_book.yaml) (0.00s)
        --- FAIL: TestE2E/Create_and_get_a_book(e2e/books/create_and_get_book.yaml)/Create_a_book(e2e/books/create_book.yaml) (0.00s)
            operator.go:898: http request failed on 'Create a book'.steps.createBook: openapi3 validation error: request body has an error: doesn't match the schema: Error at "/price": property "price" is missing
                Schema:
                  {
                    "properties": {
                      "author": {
                        "type": "string"
                      },
                      "name": {
                        "type": "string"
                      },
                      "price": {
                        "type": "integer"
                      }
                    },
                    "required": [
                      "name",
                      "author",
                      "price"
                    ],
                    "type": "object"
                  }
                
                Value:
                  {
                    "author": "太宰治",
                    "name": "走れメロス"
                  }
```

また、CIツールからテストを呼び出す際には`runners`のところで指定します。

```e2e/books/create_and_book.yaml
...
runners:
  req:
    endpoint: http://localhost:8080
    openapi3: ../../schemas/create_book.yaml
...
```

# 参考文献

- [github.com/k1LoW/runn](https://github.com/k1LoW/runn)
- [複数のAPIエンドポイントをまたぐテストをgo testで実行するための仕組み](https://tech.pepabo.com/2022/06/07/scenario-testing-in-go/)
- [APIシナリオテストの新ツールrunn](https://zenn.dev/katzumi/articles/api-scenario-testing-with-runn)