こちらは HITOTSU株式会社 Advent Calendar 2022 の5日目の記事になります。
こんにちは、HITOTSU株式会社にて、業務委託でプロジェクトに参画させていただいている こうりん と申します。
HITOTSU内では、現在進めているプロダクトの技術選定から関わらせて頂いており、主にWebバックエンド開発に携わっています。(たまに、Reactのフロントエンドのコードを書いたりもします)
HITOTSUのプロダクトのWebバックエンドはGoを使っており、軽量Webフレームワークとして labstack/echo を使用しています。
この記事では、Webバックエンド向けのツールとして runn をご紹介させていただきます。本記事で使用するサンプルコードは github.com/Kourin1996/go-api-e2e-sample にて公開しています。
APIのe2eテスト
これまで当プロジェクトのバックエンドのテストは、ユニットテストを中心に行ってきました。実際のシナリオに則して複数のAPIをコールした時 (e.g. 作成→ステータスアップデートなど) に期待通りに動作するかの確認については、シンプルに記述・管理できるためe2eテストの拡充を進めています。
e2eテスト向けのフレームワークとしては様々なものがありますが、HITOTSUでは 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コンテナも用意されているので、そちらを使用することもできます。
$ go install github.com/k1LoW/runn/cmd/runn@latest
APIコード説明
テストの説明に入る前に、記事中で行うテストの対象APIを簡単に説明します。詳しくは、github.com/Kourin1996/go-api-e2e-sampleを参照してください。
今回のAPIは本を登録するAPIになり、本を登録するAPIとIDを元に本を1件取得するAPIの2種類持ちます。
ちなみに、APIのコードは簡易的なものであるためDBには接続せず、インメモリでデータを管理しています。
POST /books
名前と著者をbodyに指定して本を1件登録します。レスポンスは登録したレコードのデータを返します。
{
"name": "走れメロス",
"author": "太宰治"
}
{
"id": 0,
"name": "走れメロス",
"author": "太宰治",
"created_at": "2022-12-05T22:25:02.66881+09:00"
}
GET /books/{id}
パスパラメータで指定したIDとマッチする本のデータを返します。無い場合はnull
を返します。
{
"id": 0,
"name": "走れメロス",
"author": "太宰治",
"created_at": "2022-12-05T22:25:02.66881+09:00"
}
テストの実装
早速runnを使ったテストのコードを記述していきます。runnのテストはYAML形式で記述します。
POSTのテスト
以下のYAMLはPOSTのAPIをテストするコードになります。
# テストの説明
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
{
"name": "走れメロス",
"author": "太宰治"
}
テストを行うAPIのURLは、runners.req.endpoint
とsteps
のreq
直下で指定します。ここでは、ベースパスとしてhttp://localhost:8080
とstepで/books
を指定しているので、http://localhost:8080/books
を呼ぶテストになります。また、/books
直下がpost
になっているため、POSTで呼び出します。
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
などで任意の部分に展開できます。
vars:
# JSONデータの読み込み
createBookReq: "json://create_book_req.json"
steps:
createBook:
req:
/books: # APIのパス
post:
body:
# リクエストボディをvarsから展開
application/json: "{{ vars.createBookReq }}"
APIの実行を踏まえて値のチェックをするにはtest
のところで指定します。ここでは、ステータスコードのみをチェックしています。
test: |
steps.createBook.res.status == 201
テストの実行はrunn run [yamlファイル]
コマンドで指定できます。テスト中でdebug:true
を指定しているため、APIの呼び出し詳細などが表示されます。
$ 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
ちなみにテストが失敗するような場合は以下のような出力になります。
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のデータと一致するかチェックしています。
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
を実行したタイミングに依存するので気をつけてください。特定のフィールドのチェックをスキップする方法については後述します。
{
"id": 0,
"name": "走れメロス",
"author": "太宰治",
"created_at": "2022-12-05T22:25:02.66881+09:00"
}
includeを使用した複数テストの実行
テストファイルのsteps
では複数のリクエストを指定して順番に実行できる他、別ファイルで定義したテストファイルを読み込んで実行できます。
以下のテストでは、本の登録と取得を順番にテストします。各テストの詳細については、今までの定義したファイルに基づきます。include
を使用することで他のテストファイルを指定でき、またvars
でそのサブテストに渡す値を指定できます。このvars
で指定した値は今までと同様にvars.hoge
などで、そのテスト中に参照できます。
呼び出し元のファイル中のvars
で参照できるものであっても、呼び出し先に渡したい値は必ずinclude.vars
以下で指定する必要があります。
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ファイルのテストを実施しようとした際に、このファイルを直接テストとして実行しなくなります。
# テストの説明
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
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
は単独ではスキップされていることが確認でき、テストが通ります。
# ./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に変換する関数をテスト中から呼び出せるようにしています。
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段で参照していることに注意してください。
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
に定義していましたが、これは参照できないため前サブテストの結果を参照しています。
{
"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形式のスキーマファイルを追加します。
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のテストの方でスキーマを読み込みます。
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のリクエスト・レスポンスと一致しているためテストは通ります。
$ 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
を追加してみます。
...
components:
schemas:
CreateBookReq:
type: object
properties:
name:
type: string
author:
type: string
type: integer
required:
- name
- author
- price
再度テストを実行すると、price
がリクエストボディに無い旨が表示されテストが失敗します。同様にレスポンスに関してもチェックすることが出来ます。
$ 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
のところで指定します。
...
runners:
req:
endpoint: http://localhost:8080
openapi3: ../../schemas/create_book.yaml
...