Go標準パッケージで作るシナリオテスト
この記事は Eureka Advent Calendar 2022 15日目の記事です。
昨日はBackendチームのemahiroの「チームで事業推進と技術改善を両立していく話」でした。
こんにちは!Eureka 23新卒(Backend Intern)のぺりーです。
最近は近くのエアロビクスのクラスに通っているのですが、3テンポくらい遅れつつ最後のターンだけは揃えて踊れている雰囲気を出すことを頑張っています。
このエントリではGo標準パッケージで作ったE2Eテストをシナリオテストに対応し、保守性を上げた話をします。
目次
- 既存のE2Eテストの振り返り
- 既存のE2Eテストの課題
- シナリオテストの導入
- シナリオテストの工夫
4.1. 異常系
4.2. 命名規則 - まとめ
1. 既存のE2Eテストの振り返り
BackendチームではControllerレベルのE2Eテストを書くことが仕組み化され、必須になっています。
go testコマンドのみで走らせることができるE2Eテストによって、APIの振る舞いが検証可能になり、開発効率は格段と上がり、ドキュメントとしての役割も果たせるようになりました。
BackendチームのE2Eテストは各エンドポイントのController単位のテストでリクエストとレスポンスのステータスコードをテーブル駆動で指定し、テスト実行時にレスポンスはgoldenファイルを比較するようになっています。
func TestAPIHoge_PutFuga(t *testing.T) {
// テスト名から対応するsetup.sql, cleanup.sqlを探して実行する.
// 全てのテストケースで必要となるデータをセットアップする.
e2e.SetupDB(t)
t.Cleanup(func() {
e2e.CleanupDB(t)
})
tests := []struct {
body map[string]interface{}
want int
}{
{
body: map[string]any{
// body
},
want: http.StatusNoContent,
},
}
endpoint := "/hoge"
for _, tt := range tests {
t.Run(APITestName(endpoint, tt.want), func(t *testing.T) {
// テスト名から対応するsetup.sql, cleanup.sqlを探して実行する.
// テストケースごとに必要となるデータをセットアップする.
e2e.SetupDB(t)
t.Cleanup(func() {
e2e.CleanupDB(t)
})
r := e2e.NewRequest(http.MethodPut, endpoint, e2e.JSONBody(t, tt.body))
// Responseをgolden形式で比較する.
e2e.RunTest(t, r, tt.want)
})
}
本筋とは逸れますが、テストしやすいように値が固定されない部分は柔軟に書き換えられるようになっていたり、JSONレスポンスがフォーマットできるようになっていたり、さまざまな工夫がされています。
詳しくは、下記の記事で解説されています。
2. 既存のE2Eテストの課題
このE2EテストはAPIごとのテストとなっているため、テストで利用する初期データの生成を自分たちで行う必要があります。
先のコードのうち下記がリソースの用意に該当します。
// テスト名から対応するsetup.sql, cleanup.sqlを探して実行する.
e2e.SetupDB(t)
t.Cleanup(func() {
e2e.CleanupDB(t)
})
実際に用意するデータのサンプルはこちらです。
-- setup.sql
INSERT INTO `user` (`id`, `name`, `created_at`, `updated_at`)
VALUES (1, 'hoge', NOW(), NOW());
INSERT INTO `login_token` (`user_id, `token`, `expires_at`, `created_at`, `updated_at`)
VALUES (1, 'token', '2099-01-01 00:00:00', NOW(), NOW());
-- cleanup.sql
DELETE FROM `user` WHERE `id` = 1;
DELETE FROM `login_token` WHERE `user_id` = 1;
全てのテストケースで同じデータを用意する場合は一回ですが、テストケースごとに用意が必要な場合があります。
ほとんどの場合で複数のレコードが必要で、相互に矛盾なくデータを投入するのが難しく、初期データが誤っていた場合、テストの意味がないどころか、既存のテストがPASSすることで開発者が混乱してしまいます。
また、初期データが誤っていることにレビューで気付くのは大変難しい上に、機械で検知することも難しいです。
さらに、E2Eテスト作成時点では初期データは正しかったとしても、運用していくうちに、APIを叩く順番が変更されたり、APIを叩くユーザーのステータスが変わることもあるため、途中からテストの意味が変わってしまうことさえ考えられます。
3. シナリオテストの導入
この問題を解決するために実際のユーザーの流れに沿って複数のエンドポイントを連続で叩くシナリオテストを導入しました。
シナリオテストを実施するにあたって、それをサポートするOSSの導入も検討しました。
しかし、ymlなど独自構文で設定ファイルを書くことが多いため、学習コストがかかることや、Goのコードを多めに書いた方がリファクタリングする際にastでまとめて書き換えができるメンテナビリティの観点からOSSの導入は見送ることに決めました。
(Backendチームは改造版goplsで開発していることもあり、astが頻繁に使われています。)
そのため自前のE2Eテストをうまく組み合わせることで初期データをほとんど用意する必要がなく実際の挙動と同じになるシナリオテストを実施できるようにしました。(詳細は後述)
func TestAPIUser_Scenario(t *testing.T) {
var res map[string]any
t.Run("1 Create", func(t *testing.T) {
req := NewRequest("POST", "/users", e2e.JSONBody(map[string]any{"name": "John"}))
RunTest(t, req, http.StatusCreated, e2e.CaptureResponse(&res))
}
t.Run("2 Update", func(t *testing.T) {
req := NewRequest("PUT", fmt.Sprintf("/users/%s", res["id"], e2e.JSONBody(map[string]any{"name": "Taro"}))
RunTest(t, req, http.StatusNoContent)
}
t.Run("3 Get", func(t *testing.T) {
req := NewRequest("GET", fmt.Sprintf("/users/%s", res["id"], nil)
RunTest(t, req, http.StatusOK)
}
}
上記の例ではユーザーを作成し、ユーザー名を更新し、ユーザーを取得するシナリオをもとにテストしています。(簡略化のため型のチェックは省略しています。)
実際に更新できたかどうかはユーザーを取得する部分のJSONレスポンスをgoldenファイル形式で比較することで担保されています。
HTTP/1.1 200 OK
Connection: close
/* 中略 */
Content-Type: application/json; charset=utf-8
/* 中略 */
{
"id": 1,
- "name": "Taro"
+ "name": "John"
}
もしレスポンスでステータス等の確認ができない場合は別途ステータスを確認するAPIなどをテスト用に生やすなどを検討してもいいかもしれません。
上のようなシナリオテストを作れるようにするにあたって既存のE2Eテストではレスポンスを次に使い回すことができるように改修しました。(CaptureResponse
部分)
// 追加分.
func CaptureResponse(resp any) ResponseFilter {
return func(t *testing.T, r *http.Response) {
t.Helper()
body, err := io.ReadAll(r.Body)
if err != nil {
t.Fatal(err)
}
r.Body = io.NopCloser(bytes.NewReader(body))
if err := json.Unmarshal(body, &resp); err != nil {
t.Fatal(err)
}
}
}
// ここから既存コード.
func RunTest(t *testing.T, r *http.Request, want int, filters …ResponseFilter) {
// 前略
for _, f := range filters {
f(t, got)
}
// 後略
}
type ResponseFilter func(t *testing.T, r *http.Response)
func NewRequest(method, endpoint string, body io.Reader, options …RequestOption) *http.Request {
r := httptest.NewRequest(method, endpoint, body)
for _, opt := range options {
opt(r)
}
return r
}
RunTest
がResponseFilter
を受け取るように作られていて、拡張しやすく、とても助かりました。
4. シナリオテストの工夫
4.1. 異常系
シナリオテストのデメリットを述べるならば、異常系まで網羅しようとするとテストが長くなってしまうことです。
そのため異常系は既存のE2Eテストで担保しつつ、できるだけ異常系を含んだシナリオを作るのが良いと思います。
前述のAPIUserでメールアドレスを認証しないと2回目以降name
を変更できない仕様があるとするとすれば、例えば下記のシナリオにしてみます。
- ユーザー作成(
http.StatusCreated
) - 1度目の
name
変更(http.StatusNoContent
) - ユーザー取得(
http.StatusOK
) - 2度目の
name
変更(http.StatusBadRequest
) - メールアドレス登録(
http.StatusNoContent
) - メールアドレス認証(
http.StatusNoContent
) - 2度目の
name
変更(http.StatusNoContent
)
このようにすることで、ユーザーの流れに沿わせつつ異常系も含めてテストすることができます。
4.2. テストの命名規則
また、長くなりがちなシナリオテストの可読性を上げるために、テスト名を通し番号_methodName_description
にすることもおすすめです。
Backendチームのテストはテスト本体の場所とtestdata
に分けられているため、先の命名規則を守ることで、テストディレクトリだけでもシナリオの順番をわかりやすくすることができます。
omitted/
├─ routes.go
├─ api_user_test.go
├─ api_fuga_test.go
├─ testdata/
│ ├─ TestAPIUser/
│ │ ├─ 1_Create_201.golden
│ │ ├─ 2_Update_204_name.golden
│ │ ├─ 3_Get_200.golden
│ │ ├─ 4_Update_400_2nd_name_update.golden
│ │ ├─ 5_RegisterEmail_204.golden
│ │ ├─ 6_VerifyEmail_204.golden
│ │ ├─ 7_Update_200_retry_2nd_name_update.golden
│ │ ├─ cleanup.sql
│ │ ├─ setup.sql
│ ├─ TestAPIFuga/
5. まとめ
これまで見てきたように、既存のE2Eテストを拡張してシナリオテストを作ることで実際の挙動と同じかつ保守しやすいテストにすることができました。
少なくとも正常系が動くことがテストで確認できることで、保守性だけでなく、生産性も向上させることができました。
E2Eテストと比べてまだまだシナリオテストは少ないので、これから増やしていきたいと思います。
明日はSREチームの@marnie0301の「Pairs(Eureka)のEKS Production環境の設計と運用のお話」です。