go api 結合テスト シナリオテスト

複数のAPIエンドポイントをまたぐテストをgo testで実行するための仕組み

go api 結合テスト シナリオテスト

こんにちは。技術部技術基盤チームの@k1LoWです。

久しぶりにオンラインで画面越しに会話した弟からLightning Boltというバンドを教えてもらい、うろたえながらもなかなかにハマっています(Apple独自規格のアレではありません)。音源よりも、まずは是非ライブ動画を観てほしいです。

今回は、複数のAPIエンドポイントをまたぐテストを go test で実行するための仕組みについて紹介します。

複数のAPIエンドポイントをまたぐテストを書きたい

現在私はGo言語によるAPIサーバーの開発に参加しています。HTTPでリクエストを受け、データの永続化にはリレーショナルデータベースを使う、よくあるAPIサーバーです。

APIエンドポイントの設計にはOpenAPI Specification v3とそのエコシステムを使用しており、openapi-generator でサーバーとクライアントのコードを生成しています(生成されるコードをテンプレートの仕組みを使ってカスタマイズしています)。

API仕様からコードを生成する仕組みを導入することでAPI仕様とずれることなく、ビジネスロジックに集中して開発を進めることができます。

また、データベース操作を行うレイヤーもモック化できるように interface を備えた設計・実装にしており、APIエンドポイント単位での単体テストも書きやすくなっています。

ただ、我々が開発しているAPIサーバーは「機能」というくくりでみると1つのAPIエンドポイントで完結する機能というのは少なく、複数のAPIエンドポイントをまたいではじめて完結する機能のほうが多くを占めていました。

「APIサーバーが提供する機能は意図した通りに動くのか」という観点で考えても、複数のAPIエンドポイントをまたぐ結合テストにも十分に注力した方が良いと考えました。

runn

私は結合テストは単体テストよりも作成コストやメンテナンスコストが大きいと考えています。

例えば、今回のような複数のAPIエンドポイントをまたぐテストの場合だと次のような特徴が挙げられます。

  • 1つのテストが対象とするコンポーネントが増える
  • ステートフルになる
    • クライアント側でもAPIサーバー側でもステートを持つ必要がでてくる

結合テストを素朴なGoのコードで書いていくと先にあげた特徴により作成コストが大きくなったり可読性に課題が出てくると考えました。

そこで、できるだけコストを小さく複数のAPIエンドポイントをまたぐテストを書ける仕組みとして runn ( "Run-N" という形で読みます )を作成しました。

https://github.com/k1LoW/runn

runnの特徴はいくつかありますが、本エントリのコンテキストだと次のようなものが挙げられます。

  • 複数のステップで構成されるシナリオをYAMLで記述することができ、またHTTP Requestの場合はOpenAPI Specライクに記述することができる
  • 各ステップでのHTTPリクエストやクエリのレスポンスは自動で保持されて、次以降のステップで再利用できる
  • シナリオを再利用できる
  • go testのテストヘルパーとしてシナリオを実行することができる

次節以降でそれぞれ紹介します。

複数のステップで構成されるシナリオYAMLで記述することができ、またHTTP Requestの場合はOpenAPI Specライクに記述することができる

runnはシナリオをYAMLで記述する方式を採用しています。

「ログインしてプロジェクト一覧を取得する」シナリオの例を次に示します。

desc: ログインしてプロジェクト一覧を取得する
runners:
  req: https://example.com/api/v1
  db: mysql://root:mypass@localhost:3306/testdb
vars:
  username: alice
  password: ${TEST_PASS}
steps:
  find_user:
    desc: usernameを条件にユーザ情報をDBから取得
    db:
      query: SELECT * FROM users WHERE name = '{{ vars.username }}'
  login:
    desc: メールアドレスとパスワードでログイン
    req:
      /login:
        post:
          body:
            application/json:
              email: "{{ steps.find_user.rows[0].email }}"
              password: "{{ vars.password }}"
    test: steps.login.res.status == 200
  list_projects:
    desc: ログイン状態でプロジェクト一覧を取得
    req:
      /projects:
        get:
          headers:
            Authorization: "token {{ steps.login.res.body.session_token }}"
          body: null
    test: steps.list_projects.res.status == 200
  count_projects:
    desc: プロジェクト情報を取得できていることを確認
    test: len(steps.list_projects.res.body.projects) > 0

YAMLのそれぞれのセクションについて紹介します。

runners:

runnでは runners: セクションにRunnerと呼ぶ各ステップを処理するコンポーネントを定義します。

runners:
  req: https://example.com/api/v1
  db: mysql://root:mypass@localhost:3306/testdb

今回はreqdbの2つのRunnerを定義しています。この名前は任意で別にapiclientでもqueriesでも構いません。各ステップでの呼び出し時にこの名前を使用します。

https://*もしくはhttp://*といったURLを指定するとHTTPリクエストを投げるHTTP Runnerに、 mysql://*pg://* といったDSNを指定するとデータベースにクエリを投げるDB Runnerとして定義することになります。

また、runnにはHTTP RunnerやDB Runner以外に、事前に定義しなくても良い組み込みRunnerが用意されています。例えば任意のコマンドを実行するExec Runnerや、保持している値を出力するDump Runnerなどがあります。その他のRunnerについてはREADMEをご覧ください。

vars:

vars: セクションではシナリオの各ステップで使用できる変数を設定できます。

vars:
  username: alice
  password: ${TEST_PASS}

vars: セクションに限りませんが ${ENV_NAME} 形式で書くと環境変数 ENV_NAME の値を設定します。

設定された変数は {{ vars.username }} といった形で利用できます。

steps:

steps:セクションにシナリオで実行したい内容を順に記述します。steps:セクションは配列かMapを選ぶことができます(Mapは記述順序が維持されます)。

次に示すステップは runners: セクションで定義したDB Runner db を利用してクエリを発行しています。

  find_user:
    desc: usernameを条件にユーザ情報をDBから取得
    db:
      query: SELECT * FROM users WHERE name = '{{ vars.username }}'

次に示すステップはHTTP Runner req をつかってHTTPリクエストを投げています。

  login:
    desc: メールアドレスとパスワードでログイン
    req:
      /login:
        post:
          body:
            application/json:
              email: "{{ steps.find_user.rows[0].email }}"
              password: "{{ vars.password }}"
    test: steps.login.res.status == 200

HTTP RunnerのリクエストのフォーマットはOpenAPI Specのそれに近づけています。

また、同時に test: セクションでレスポンスのステータスが 200 であるかどうかをテストしています。

このようにrunnは各ステップでHTTPリクエストやデータベースへのクエリを発行していきます。

各ステップでのHTTPリクエストやクエリのレスポンスは自動で保持されて、次以降のステップで再利用できる

runnは vars: セクションの値だけでなく、各ステップでのレスポンスも自動で保持します。

そのため、前ステップのレスポンスの値を利用したステップを記述することができます。

本エントリの例として使っているシナリオファイルに色つけをしてみると次のように値の再利用があります。

シナリオファイル

シナリオを再利用できる

組み込みのInclude Runnerを使って他のシナリオを読み込むことができます。

例えば vars.email を使ってサインアップをするシナリオ signup.yml があったとして、複数ユーザのサインアップをするシナリオを作成する場合、次のように記述できます。

desc: 2名サインアップさせる
steps:
  signup:
    desc: サインアップ
    include: signup.yml
  signup2:
    desc: 別のメールアドレスでのサインアップ
    include:
      path: signup.yml
      vars:
        email: bob@pepabo.com

include.vars を設定することで signup.yml に記述されている vars を上書きできるので、別のメールアドレスを与えることで別のアカウントとしてのサインアップを実現しています。

go testのテストヘルパーとしてシナリオを実行することができる

作成したシナリオファイルをまとめて go test で実行できます。

具体的には次のようなコードで複数のシナリオを実行できます。

func TestScenario(t *testing.T) {
	ctx := context.Background()
	dsn := "username:password@tcp(localhost:3306)/testdb"
	db, err := sql.Open("mysql", dsn)
	if err != nil {
		log.Fatal(err)
	}
	dbr, err := sql.Open("mysql", dsn)
	if err != nil {
		log.Fatal(err)
	}
	ts := httptest.NewServer(NewRouter(db))
	t.Cleanup(func() {
		ts.Close()
		db.Close()
		dbr.Close()
	})
	opts := []runn.Option{
		runn.T(t),
		runn.Runner("req", ts.URL),
		runn.DBRunner("db", dbr),
	}
	o, err := runn.Load("testdata/books/**/*.yml", opts...)
	if err != nil {
		t.Fatal(err)
	}
	if err := o.RunN(ctx); err != nil {
		t.Fatal(err)
	}
}

ポイントは

  • runn.T(t)*testing.T を渡すことで runn をテストヘルパーとして動かしている点
  • runn.Runner("req", ts.URL)req という名前のHTTP Runnerをhttptestで用意したテストサーバーに、 runn.DBRunner("db", db)db という名前のDB Runnerをテストデータベースに、置き換えている点

です。

このように動的にRunnerを置き換えるオプションを使って、例えば、「httptestを使ったGoでのHTTPサーバーのテスト」のような、よく知られているテスト手法と同じ手法でシナリオで作成したテストを実行できるようになっています。

他にもオプションがありますので、興味がありましたら https://pkg.go.dev/github.com/k1LoW/runn#Optionをご覧ください。

先行実装の紹介

実は、同じようなテストツールとして既に実績のある先行実装としてscenarigoがあります。

https://github.com/zoncoen/scenarigo

ここまで紹介したrunnの機能のほとんどがscenarigoでも実現できますし、gRPCにも対応しています( runnも対応したい! runnもgRPCに対応しました(2022年9月追記) )。

scenarigoも、もしご存じなければぜひ触ってみてください。runnも機能面で大変に参考にさせてもらいました。

「ではなぜrunnを作ったのか?」ですが、軽微な理由が何個かあるのですが(例えば「大体作ってしまってから自分のGitHubのYour starsを確認したらStarをつけていたのに気づいた」など)、ここでは省略します。

もし比べてもらえたら光栄です。

まとめ

今回、複数のAPIエンドポイントをまたぐテストを go test で実行するための仕組みとして runn を紹介しました。

runnは、端的に言えばGoでテストコードを書くところをYAMLで書けるようにしたというパッケージです。

HTTPリクエストとデータベースへのクエリに特化することで、

  • テストが追加しやすい
  • 可読性が高い
  • 再利用性が高い

というテストの仕組みになったと考えています。

runnの今後としては次のような機能を考えています。

シナリオからドキュメント生成

runnは複数のAPIエンドポイントをまたぐ機能のテスト(や品質保証)のために開発した経緯があります。

つまり作成したシナリオがそのまま機能の説明の一部になると考えました。

過去にもテストケースがライブラリの使い方を知るのに有用だったということは何回も経験がありますし、実際に今回のプロジェクトでもAPIの使い方やフローの説明の際にシナリオファイルのYAMLを使って説明することがあります。

これを発展させて作成したシナリオの一部から有用なドキュメント生成ができたりしないかと考えています。これは過去に作成したtblsndiagで得た知見が生かせるかもしれません。

実行時間の短縮

結合テストは単体テストよりもテスト1つの実行時間が大きい傾向があります。たくさんテストを追加した結果テスト実行が遅くなり、テスト実行がボトルネックになってしまっては元も子もありません。

今回runnを導入したプロジェクトでも既にテスト実行時間が大きくなってきています。

そのためrunnもシナリオ実行の時間をできるだけ最適化できる仕組みがあると良いと考えています。

現バージョンのrunn(v0.20.1)でも全てのシナリオファイルをみて他のシナリオで利用されているシナリオは単体実行をスキップするようなオプションを提供していますが、まだできることはありそうです。

例えば、共有しているテストデータベースを考慮したシナリオの並行実行などができると嬉しいのではないかと考えています。

負荷テストへの転用

APIリクエストのシナリオを書いているのですから、それらをそのまま負荷テストに転用できないかなと考えています。

実装方法としては、runnにベンチマーク機能をつけるというよりもrunnのシナリオファイルから世にある負荷テストツール(例えばk6など)への入力データを生成する方が筋が良いと考えています。

構想段階なので、どういう設計にするかはまだ未定です。


以上です。

今後も、より良い、より楽できるテストについて考えていきたいと思います。