Go自動テスト - Testcontainersとrunn を用いたAPIテストの実践-
この記事はGo Advent Calendar 2024の 14 日目の記事です🎄
はじめに
先日、専用コンテナを利用したユニットテストのアプローチとして、Testcontainers for Goを利用した具体例を紹介させていただきました!
まだ読んでない人は、ぜひチェックといいねをよろしくお願いします 👍
本記事では、Testcontainers と合わせて、runn を用いた API テスト基盤について詳しく解説します。
前提
runn とは
runn は、k1LoWさん作のシナリオテスト[1]を柔軟に構築できるツールです。
個人的な runn の推しポイントとしては以下2点です。
ポイント 1. チュートリアル、クックブックが日本語対応している
初めて使うのにまったく困らないほどのドキュメントが、それぞれ日本語かつ zenn で公開されているのは、国内で普及させるのにだいぶアドバンテージがあると感じます ✨
また、かなりの部分が無料公開されているので、お試しで導入しやすいのもポイントです。
ポイント 2. runn は CLI ツールであると共に、Go のパッケージでもあ
runn の強力な機能として、特筆すべきは Go のテストヘルパーパッケージが提供されていることです。
runn とは - runn クックブックでも説明がありますが、Go をインストールしていれば、通常のユニットテストと同様に扱えることが魅力です。
API テストとは
runn のテストスコープとしては、LayerX 社のブログで紹介されているバックエンド向け API テストの説明が適しているので、引用させていただきます。
バックエンドの API が設計通りに動作することを確認します。API は多層構造のため、個別の API が正しい値を返すこと、複数の API を組み合わせた際に正常動作することなどを検証します。
本記事では、runn のテストヘルパーを使って Go Testing として API テストを実現します。
テストピラミッド(LayerX 社ブログより引用したものに一部赤字で追記)
導入
ディレクトリ構成
便宜的にディレクトリ、ファイルを省略してます。
.
├── cmd
│ └── server
├── docker
│ └── Dockerfile
├── e2e
│ ├── base_test.go
│ └── e2e_test.go
├── migrations
│ ├── ddl
│ │ └── create_example_profile.sql
│ └── dml
│ └── insert_example_profiles.sql
└── testdata
├── migrations
│ └── get_example_profiles_test.sql
└── runn
├── get_example_profiles_test.http
└── gen
└── book
└── get_example_profiles_test.http_0.yaml
ポイント 1: ユニットテストで使うテストデータ
runn で実行したいテストはランブックとして yaml で用意します
desc: get_example_profiles_test.http_0
runners:
req: ${RUNN_ENDPOINT:-http://localhost:8080}
steps:
- req:
/example/profiles:
get:
headers:
body: null
test: |-
current.res.status == 200 && compare(current.res.body, {})
ランブックは手動で作成することも可能ですが、curl リクエストから生成することもできます。
$ runn new -- curl https://httpbin.org/json -H "accept: application/json"
desc: Generated by `runn new`
runners:
req: https://httpbin.org
steps:
- req:
/json:
get:
headers:
Accept: application/json
body: null
func TestMain(m *testing.M)
で行う
ポイント 2: テスト用コンテナ生成を このパートは、ポイント 2: テスト用コンテナ生成を func TestMain(m *testing.M)で行う - Go 自動テスト - Testcontainers を用いたユニットテストの実践-とほぼ同じ構成です。
// github.com/jackc/pgx v5.5.5
// github.com/testcontainers/testcontainers-go v0.33.1
// github.com/testcontainers/testcontainers-go/modules/postgres v0.32.0
package e2e
var (
testServerURL string
conn *pgxpool.Pool
)
func TestMain(m *testing.M) {
os.Exit(testMain(m))
}
func testMain(m *testing.M) (result int) {
ctx := context.Background()
// NOTE: Dockerfileのイメージを元にリクエストを作成
req := testcontainers.GenericContainerRequest{
ContainerRequest: testcontainers.ContainerRequest{
FromDockerfile: testcontainers.FromDockerfile{
Dockerfile: "./docker/Dockerfile",
Repo: "example-test-db",
BuildOptionsModifier: func(buildOptions *types.ImageBuildOptions) {
buildOptions.Target = "db"
},
},
ExposedPorts: []string{"5432/tcp"},
Env: map[string]string{
"POSTGRES_USER": "",
"POSTGRES_PASSWORD": "",
"POSTGRES_DB": "",
"TZ": "",
},
Files: func() []testcontainers.ContainerFile {
var files []testcontainers.ContainerFile
// NOTE: DDL 実行
for _, file := range ddlFiles {
files = append(files, testcontainers.ContainerFile{
HostFilePath: file,
ContainerFilePath: "/docker-entrypoint-initdb.d/" + filepath.Base(file),
})
}
return files
}(),
},
Started: true,
}
// NOTE: コンテナ生成
container, err := testcontainers.GenericContainer(ctx, req)
if err != nil {
return 1
}
// NOTE: 生成したコンテナをPostgreSQLドライバーでラップする
globalPostgresContainer, err = postgres.RunContainer(ctx,
postgres.WithDatabase(""),
postgres.WithUsername(""),
postgres.WithPassword(""),
)
if err != nil {
return 1
}
globalPostgresContainer.Container = container
// NOTE: テストコンテナのスナップショットを取得する
// 各テストケースでリストアした場合は、このポイントに戻る
_ = globalPostgresContainer.Snapshot(ctx, postgres.WithSnapshotName("test-db-snapshot"))
defer func() {
if err := globalPostgresContainer.Terminate(ctx); err != nil {
result = 1
}
}()
connStr, err := globalPostgresContainer.ConnectionString(ctx)
if err != nil {
return 1
}
config, err := pgxpool.ParseConfig(connStr)
if err != nil {
return 1
}
conn, err = pgxpool.NewWithConfig(ctx, config)
if err != nil {
return 1
}
// NOTE: httptest サーバーを起動して、URLを取得する
e := startServer()
testServer := httptest.NewServer(e)
testServerURL = testServer.URL
return m.Run()
}
ポイント 3: runn をテストヘルパーとして扱います。
API テストは、以下のように記述します。
// github.com/k1LoW/runn v0.120.0
package e2e
func Test_e2e(t *testing.T) {
ctx := context.Background()
// NOTE: ランブックごとにAPIテストを実行
for _, runnBookFile := range runnBookFiles {
runnBookFilePath := filepath.Join(runnBookDir, runnBookFile.Name())
// NOTE: ランブックに一致するテスト用SQLファイルを読み込んでマイグレーションする
if err := setup(t, runnBookFilePath); err != nil {
t.Error(err)
continue
}
// NOTE: runn を実行する
opts := []runn.Option{
runn.T(t),
runn.Book(runnBookFilePath),
runn.Runner("req", testServerURL),
runn.Scopes("read:parent"),
}
o, err := runn.New(opts...)
if err != nil {
t.Error(err)
}
if err := o.Run(ctx); err != nil {
t.Error(err)
}
// NOTE: リストアする
if err := globalPostgresContainer.Restore(ctx); err != nil {
t.Errorf(err)
}
}
}
おわりに
Testcontainers と runn を組み合わせることで、API テスト基盤を構築する方法を紹介しました。
ユニットテスト基盤と同様に、リストアに時間がかかってしまうなど課題感はありますが、API 間のデータ依存のないテストが実現できるのは魅力だと思います。
ぜひ、似たような構成でテスト基盤を構築していたり、課題に対して知見をお持ちの方はフィードバックよろしくお願いします 👍
-
シナリオテストとは、ユーザーが一連の流れに沿ってシステムを問題なく利用できることを確認するためのテストのことです。開発者の視点だけではなく、ユーザーがシステムを問題なく利用できることを目標に、ユーザー視点での確認がメインとなるテストです。そのため、システムテストや受け入れテストなどでこのテスト技法が用いられます。 https://service.shiftinc.jp/column/4939 ↩︎
Discussion