こんにちは、「カミナシ 従業員」サービスチームのソフトウェアエンジニアの a2 (A2hiro_tim) です。
早速ですが、ウェブアプリケーション開発、特に Go 言語を使っている方は、テストをどのように書いていますでしょうか。
我々のチームはバックエンドに Go を採用しており、 Table Driven Test (以下 TDT )を使っていました。Go を採用した開発では TDT が広く採用されており、我々も慣習に則るのが最善だと判断したからです。
しかし実際に開発を進めると、ことウェブアプリケーション開発における TDT にはいくつか課題を感じるようになりました。そこで我々のチームでは runn というツールをテストに活用する方針に切り替え、その結果、開発を加速し、価値提供も早くすることができました。
本記事では、TDT の課題、runn の採用経緯、その後について我々のチームの例を紹介します。Go 言語での事例になりますが、テストの課題感・runn の有用性は他の言語にも共通すると思います。
runn について知ってもらえたら嬉しいですし、さらにカミナシや我々のチームの技術への向き合い方がいいなと思っていただければ、採用サイトの方からご連絡いただけると嬉しいです!
背景
我々のチームはバックエンドをクリーンアーキテクチャを意識した設計にしています。当初、レイヤーごとに mock を活用しながら TDT を書いていました。レイヤーごとのテストは時間がかかることは明らかですが、GitHub Copilot を活用すればむしろ時間は短縮でき、メリットが大きくなると考えていました。
しかし実際には、Copilot では生成が不十分で自力での実装が必要になったり、mock 実装を頻繁に修正する必要があり、レイヤーごとではなくコントローラーレベルで TDT を書くように方針転換しました。
コントローラーレベルで TDT を書くことで、ゲートウェイやユースケース層の変更で既存のテストの修正が発生することはなくなり、より少ない労力で広い範囲をカバーしつつ開発を進めることができました。
課題
一方で、コントローラーレベルで TDT を書くことに、新しい課題も感じるようになりました。
まず、テストを書くコストが依然として高く、読みづらくもなったと感じていました。
TDT を書く際、大まかに
- テストケースごとの事前条件の準備・入出力データの用意
- テーブル形式のテストケースを作成
- それぞれのケースを実行
という順に記載します。
a の事前準備では ID 生成や DB insert などをテストケースごとに逐次的に記載する必要があり、コードが長くなってしまいます。特に Go では、短くしようとしすぎると読みにくくなってしまうので、結果的に冗長さの方を許容しがちです。
TDT は複数のテストケースを一つのテスト関数として書くため、事前準備1, 2,.. テストケース 1,2… と記述します(画像参照)。こうなると、テストコードを上から順番に読むということが難しく、ある正常系のテストについて、上と下に行き来しながら読む必要があります。可読性が高いとはいえません。
更に、OpenAPI 定義から生成したコードを利用したり、独自のテストヘルパーの書き方があることで、新しくチームに参加したメンバーは書き方に慣れる必要がありました。それは同時に、Copilot の生成結果の正確性が低下することも意味します。生成するよりも一から自分で書いた方が早いと感じることも多々ありました。
まとめると、以下の3点を課題に感じていました。
- 事前準備のコード量が多く、実装が手間
- テストコードを上下に行ったり来たりして読む必要があり、可読性が低い
- 生成AIを利用したテストコード生成が不十分
解決策:runn の導入
これらの課題を解決するため、OSS のシナリオ実行ツールの runn を導入しました。
runn は、APIリクエスト だけでなく、DB クエリもシナリオ実行の中に組み込むことができ、それらを yaml として記述できます。
シナリオテストに使うこともできますし、Go の test helper package として使う方法も提供されています。我々のチームでは、両方の使い方を活用しています。
実は、 runn は既にプロジェクトの一部で試験的に導入していました。その際に使用感もよかったため、メインのテストとして使うことも考えましたが、ちょうどリリースに追われており、コンセプト提案に留めていました。リリース完了後、
- テストを誰でも書きやすい
- 記述が OpenAPI 定義に近く、読みやすい
- 生成 AI の yaml 生成結果が正確
と考え、スプリント内で時間をもらい、土台を実装しました。本記事では割愛しますが、他にも「直接原因を特定できてはいないが runn への移行で解決しそうな課題」があり、導入への後押しになりました。
最初は、Go の test helper として実装しました。
func TestMultipleScenarios(t *testing.T) { ... r := setupRouterForTest(db) ts := httptest.NewServer(r) opts := []runn.Option{ runn.T(t), runn.Runner("req", ts.URL), runn.DBRunner("db", dbr), runn.Scopes("run:exec"), } o, err := runn.Load("testdata/book/endpoints/*/*.yml", opts...) if err != nil { t.Fatal(err) } if err := o.RunN(ctx); err != nil { t.Fatal(err) } ... }
テストケースは以下のように yaml で書きます(実際のコードを修正して例として記載しています)
desc: "chat を作成する" # 注: runner の定義はコード上で行う steps: # arrange - desc: user の初期化 include: ../../utils/users.yml bind: companyID: current.companyID userID: current.userID # act - desc: チャットグループを作成 req: /c/{{ companyID }}/chats: post: body: application/json: name: "sample" members: - "{{ userID }}" test: | current.res.status == 201 # assert - desc: チャットの record が 1 つ作成されている db: query: | select count(*) from chats where company_id = '{{ companyID }}' test: current.rows[0].count == 1
このように書くと、 go test
実行時にシナリオも実行されるようになります。
コントローラーレベルのテストではコントローラーの関数に対してテストを書きましたが、runn を利用すると endpoint uri に対してテストケースを記述できます ( req block 参照)。 OpenAPI の記述と近く、endpoint ごとのテストも、endpoint を跨いだテストも書きやすいです。
また、一長一短ではありますが、runn は DB クエリも直接記述できます。
さて、上では httptest.NewServer
を用いましたが、 runn を使う場合はなんらかの形でサーバーを立てることになります。しかし、production と同じ設定のサーバーにリクエストを送ると、認可ミドルウェアにはじかれてしまいます。我々は middleware がない状態のルーターを用意することで、テスト可能にしています。(これも、runn によって実現したかったことの一つです)
// production で動いているコード func setupRouter(config Config) (*chi.Mux, error) { server, err := web.NewResourceRouter(config) if err != nil { return nil, err } r := chi.NewRouter() r.Use(middleware1) r.Use(middleware2) // 認証・認可関連 r.Use(middleware3) r.Mount("/", server) return r } // runn 用 func setupRouterForTest(config Config) (*chi.Mux, error) { server, err := web.NewResourceRouter(config) if err != nil { return nil, err } r := chi.NewRouter() r.Use(fakeMiddleware1()) // 認証系のmiddleware は使わない r.Mount("/", server) return r, nil }
Go test package として runn を導入してすぐ、CLI としても使いたいという声が上がったため、ポートを割り当てて実際にリクエストを受け付けるサーバーを立ち上げ、CLI ツールとしても runn を活用するようにしました。長くなるので割愛しますが、別の機会があれば書きたいと思います。
チームメンバーへの展開
runn は非常に使いやすいツールですが、全く学習なく始められるわけではありません。上記のサンプルコードも、 syntax のクセや細かいルールの違いで困った末に生まれています。例えば、runn では {{ xxx }}
の形で変数を展開するのですが、場所によっては単に xxx
と書かなければいけません。チームにツールを展開する上では、こうした自分の知見をシェアしてスムーズに使ってもらえるようにしたいです。
我々のチームは月1で物理出社をすることにしているので、その集まりの場で実際にテストケースを書いてもらい、横から助言をする形でチームに導入しました。同期的に実施したことで、自分がハマった経験をスムーズにチームに還元できましたし、チームへの展開・浸透が早まりました。
使いやすいツールだけあって、すぐにメンバーも慣れ、その場で、「こういう書き方はできないか」と runn を掘り下げる動きまで始まったので、改めて頼もしいチームだと思いました。
インパクト、その後
導入・展開後も順調です。落ち着いて振り返ると、全体としては大きく二つのメリットを感じています。
- メンバーの体感値で明確に開発・レビューの速度が上がった。*1
- 「これ runn で自動化できないかな」といった声が自然に上がるなど、テストに対して前向きにになった。
特に二つ目は、長期で価値提供をするサービスチームでは非常に重要です。気持ちが後ろ向きだと、ついついテストを skip して書いたりしてしまいます。
改善点・今後
我々のウェブアプリケーションには、AWS の API や、社内の認証サービス等の外部サーバーにリクエストする endpoint もあります。他の endpoint と同様の runn のテストを書くとエラーになってしまうので、迂回する方法を整備する必要があります。
mock server 、実際の API サービスを使ってテストできるように整備する等、対応方針は現在もいくつか検討中です。
おわりに
テストは、直接的にユーザーに価値を生む機能ではありません。
テストだけをいくら書いても、売り上げは増えません。その意味で、高速に価値提供・仮説検証を進めたい場合、特にスタートアップでは、テストにかける時間は短くしたいです。
もちろん、「質とスピード」を考えれば、テストをおろそかにするわけにも行きません。できるだけ少ない手間でテストの効果を得たいと常々考えていました。
runn は使いやすいのが何よりすごいですが、アプリケーションの技術品質を担保するために十分な性能も備えています。今回は我々のチームにおける runn の活用について書きましたが、runn はカミナシの他のチームでも活用しており、評判が良いです。
導入のためにアプリケーションに変更を加える必要性もほとんどないですし、本記事のようにあれこれ考えなくても、「評判もいいしまずは導入してみて、使ってみて合わなかったらやめる」でも良いと思います。おすすめです!
言語化して記事にすると多少回りくどいプロセスに見えたかもしれませんが、我々のチームでは意思決定の理由を適切に残しつつ、常に速さを意識して開発を進めています。中長期を見据えて開発をすることが好きな方、ぜひカミナシで一緒に価値を生み出しませんか!チームごとに特色もあるので、初回の面談で合うチームを探していただければと思います、お待ちしております!
おまけ
来週 12 月頭にはアメリカのラスベガスで AWS re:Invent 2024 が開催されますね!
ここ数日でどんどん新しいリリースが出ていて、会期中は何があるのかとワクワクしています。
そして、今年の re:Invent はなんとカミナシから登壇者がいます!しかも二人も(!!)
私はただの参加者で登壇はしないんですが、現地にいらっしゃる方はお話ししましょう。
参加した結果得られた学びはまたブログで還元していきたいと思います。
*1:厳密な定量計測ができればカッコよかったですが、内向きの労力だと判断して割愛しました