6月2日(金)に開催された技術カンファレンス「Go Conference 2023 Online」に、GMOペパボ株式会社 小山 健一郎(@k1LoW)が登壇しました。セッションでは、新規アプリケーション開発時のテスト戦略としてnet/http/httptest.Serverのアプローチを活用した事例について紹介しました。
登壇者
小山 健一郎 @k1LoWGMOペパボ株式会社 技術部 技術基盤チーム
https://speakerdeck.com/k1low/go-conference-2023
テストサイズとnet/http/httptest.Server
小山は本題に入る前にまず、テストサイズとnet/http/httptest.Serverの前提知識について整理しました。
テストサイズ
テストサイズは、テストの実行速度と決定性に着目したテスト分類で、『Googleのソフトウェアエンジニアリング』(オライリージャパン、2021年)という書籍で紹介されています。正確には4つですが、今回は下記3つの分類を用います。
Smallテスト:単一プロセス内で実行されなければならない。Sleep禁止、I/O禁止、ブロック禁止Mediumテスト:単一マシン内で実行されなければならない。localhost以外のシステムへのネットワーク呼び出し禁止 (シングルスレッドである必要はない)Largeテスト:複数マシンにまたがるテスト
net/http/httptest.Server
net/http/httptest.Serverでは、httptest.NewServerでローカルループバックアドレス+システムが選んだポートで待ち受けるHTTPサーバーを起動できます。起動したHTTPサーバーのアドレスを取得できるため、そのアドレスに対してリクエストを投げるなどして使用します。なお、HTTPサーバーはgoroutineで実行されています。
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "Hello, client")
}))
defer ts.Close()
res, _ := http.Get(ts.URL)
b, _ := io.ReadAll(res.Body)
res.Body.Close()
fmt.Printf("%s", b)
net/http/httptest.Serverは、HTTPクライアント-サーバー間のテストで用いられることが多くあります。たとえば、Dockerなどを使って別プロセスでHTTPサーバーを立て、それに対してクライアントからリクエストを投げるといった形の構成が考えられます。
テストサイズ分類におけるnet/http/httptest.Serverのメリット
ここで小山は、net/http/httptest.Serverのメリットを考えるために、別プロセスでHTTPサーバーを起動してテストする場合と、net/http/httptest.Serverを使用してテストする場合を比較しました。いずれも、テストサイズの分類はMediumテストとなります。
別プロセスの場合、テスト実行時のHTTPクライアントやHTTPサーバーの構成要素の管理はメインプロセスで行います。例えば make testタスクを実行すると、make testタスク内においてDockerでHTTPサーバーを起動し、その後go testを実行するというイメージです。一方、net/http/httptest.Serverの場合は、HTTPサーバー、HTTPクライアントも実質goroutine上で動くため、HTTPのGo Runtimeに構成要素の管理を任せることができます。
「これがnet/http/httptest.Serverの大きなメリット」とする小山は、twada氏のスライド「サバンナ便り〜自動テストに関する連載で得られた知見のまとめ(2023年5月版)〜」からテストサイズとテストスコープの対応表を引用し、その理由を次のように解説しました。
「net/http/httptest.Serverを使ったgoroutineベースのMediumtestテストは、対応表内の青枠で示された部分。Mediumテストでも、スレッドの管理はGo Runtimeに任せられるため、単純な単一マシンに複数プロセスを走らせるテストなどよりはコスパが良い」(小山)
また、同一のGo Runtime上で実行できるということは、同じコードベースに乗るということであり、それぞれの構成要素で値の相互受け渡しなどの連携が容易に実現できます。このため、小山は「モックやスタブ、スパイといったテストタブルの実装がしやすい点もメリット」と説明します。
goroutineベースのMediumテストを広く活用するテスト戦略
続いて小山は、新規アプリケーション開発時に採用したgoroutineベースのMediumテストを広く活用するテスト戦略について紹介しました。
自動テストを含むテストスイートは、用意が早ければ早いほどそのテストの恩恵を受ける期間・規模が大きくなります。ただし、アプリケーションのアーキテクチャが未完状態である開発初期は、コードベースで大幅な変更の余地が多く残ってます。
ここで、専用のデータベースを持っており、他のコンポーネントと連動しながら動くAPIサーバーをテスト対策のアプリケーションとして想定してみます。他のコンポーネントとは、アプリケーションが提供するAPIを使う別のアプリケーション、APIクライアント、アプリケーションが使用する外部APIなどが考えられます。また、APIサーバー専用のデータベースもアプリケーションと連動するコンポーネントといえます。この場合、それぞれのテストサイズのスコープは下図の点線のようなイメージとなります。
オレンジの点線がSmallテスト、緑の点線がMediumテスト、紫の点線がLargeテスト
これをもとにアーキテクチャが未完状態のアプリケーションを考えてみると、Smallテストをまだ固定的に書くことができないというのがわかります。こうした課題に対し、小山は次のような戦略を取ったことを明かしました。
「アーキテクチャが定まっていないのであれば、アプリケーションの外側からのテストを厚くすることで、アプリケーション内部のアーキテクチャ変更に強くするという手段が取れると考えた。つまりMediumテストを中心にテストを作成するということ。goroutineベースのMediumテストであれば、コスパが良いアプローチとなる」(小山)
goroutineベースのMediumテストの作成をサポートする
こうした考えのもとGMOペパボでは、goroutineベースのMediumテストの作成をサポートしてMediumテストを実施する戦略を加速させていきました。
ここで、アプリケーションの外側を改めて見てみます。
アプリケーション概要図
プロトコルだけでもHTTPだけでなく、gRPC、データベース、SMTPなどがあり、Mediumテストの適用範囲は広いと考えられます。そこで、HTTPに使用するnet/http/httptestパッケージ以外にもテスト作成をサポートするパッケージを用意することとしました。小山は順にそれらのパッケージを紹介していきました。
github.com/k1LoW/httpstub
まずは、httpstub( github.com/k1LoW/httpstub )です。このパッケージでは、任意のレスポンスを返すHTTPサーバーを簡単に組み立てて起動することができます。上図(アプリケーション概要図)のうちExternal HTTP Serverを含めたテストをサポートするために用意したものといえます。テストHTTPサーバーをスタブサーバーとしてテストを作成するというユースケースを考えています。
func TestGet(t *testing.T) {
ts := httpstub.NewServer(t)
t.Cleanup(func() {
ts.Close()
})
ts.Method(http.MethodGet).
Path("/api/v1/users/1").
Header("Content-Type", "application/json").
ResponseString(http.StatusOK, `{"name":"alice"}`)
// テストHTTPサーバに対してリクエストを投げる
res, err := http.Get(ts.URL + "/api/v1/users/1")
// snip…
}
OpenAPI Documentとの連携もできるため、Document内に記述された仕様に沿っていないリクエストやレスポンスのバリデーションも可能となります。このほか、OpenAPI Documentにはexamplesというセクションがあり、リクエストとレスポンスの定義を書くこともできますが、httpstubは、そのexamplesセクションの値を使用してレスポンスを返すことが可能です。
ts := httpstub.NewServer(t, httpstub.OpenApi3("path/to/schema.yml"))
t.Cleanup(func() {
ts.Close()
})
ts.ResponseExample()
```
```
ts := httpstub.NewServer(t, httpstub.OpenApi3("path/to/schema.yml"))
t.Cleanup(func() {
ts.Close()
})
ts.Method(http.MethodPost).Path("/api/v1/users").ResponseExample(httpstub.Status("2*"))
github.com/k1LoW/grpcstub
httpstubの兄弟パッケージとして、grpcstub( github.com/k1LoW/grpcstub )も作成しています。上図(アプリケーション概要図)でいうと、External gRPC Serverを含めたテストをサポートするために用意したものです。機能的にはhttpstubとあまり変わりはありません。
func TestClient(t *testing.T) {
ctx := context.Background()
ts := grpcstub.NewServer(t, "protobuf/proto/*.proto")
t.Cleanup(func() {
ts.Close()
})
ts.Method("GetFeature").Response(map[string]any{
"name": "hello",
"location": map[string]any{"latitude": 10, "longitude": 13},
})
client := routeguide.NewRouteGuideClient(ts.Conn())
// テストgRPCサーバのGetFeatureメソッドをcallする
res, err := client.GetFeature(ctx, &routeguide.Point{
Latitude: 10,
Longitude: 13,
});
// snip…
}
ただ、protoファイルにはexamplesというセクションはないため、代わりにResponseDynamicというメソッドを作成しています。protoファイルの定義を利用して動的にランダムなレスポンスを組み立てて返すといったことも可能です。
ts := grpcstub.NewServer(t, "path/to/*.proto")
t.Cleanup(func() {
ts.Close()
})
ts.ResponseDynamic()
```
```
ts := grpcstub.NewServer(t, "path/to/*.proto")
t.Cleanup(func() {
ts.Close()
})
ts.Service("routeguide.RouteGuide").Method("GetFeature").ResponseDynamic()
独自に生成関数を設定することもできます。たとえば、fieldの末尾が_idの場合にはUUIDを返す、末尾が_timeの場合には固定の時刻を返すなどとといった設定が可能です。
ts := grpcstub.NewServer(t, "path/to/*.proto")
t.Cleanup(func() {
ts.Close()
})
fk := faker.New()
want := time.Now()
opts := []GeneratorOption{
Generator("*_id", func(r *grpcstub.Request) any {
return fk.UUID().V4()
}),
Generator("*_time", func(r *grpcstub.Request) any {
return want
}),
}
ts.ResponseDynamic(opts...)
github.com/k1LoW/smtptest
メール送信機能に関してもテストサーバーを立てたいという狙いから作成したのがsmtptest( github.com/k1LoW/smtptest )です。上図(アプリケーション概要図)でいうと、External SMTP Serverを含めたテストをサポートするために用意したものになります。挙動は、net/http/httptestパッケージに近いものとなり、テストSMTPサーバーが受信したメールを確認することもできます。
下記コードの場合、ts.Messagesで受信したメールをすべて取得可能です。テストダブルの分類でいうと、テストスパイが実現できるということになります。
ts := grpcstub.NewServer(t, "path/to/*.proto")
t.Cleanup(func() {
ts.Close()
})
fk := faker.New()
want := time.Now()
opts := []GeneratorOption{
Generator("*_id", func(r *grpcstub.Request) any {
return fk.UUID().V4()
}),
Generator("*_time", func(r *grpcstub.Request) any {
return want
}),
}
ts.ResponseDynamic(opts...)
github.com/k1low/runn
runn( github.com/k1low/runn )は、ここまで紹介したパッケージとは異なり、アプリケーション自体をgoroutineベースで動かし、それに対してリクエストを投げるテストを組み立てるというアプローチをサポートするものです。シナリオをYAMLで書き、それをもとに操作を自動化します。上図(アプリケーション概要図)では、External HTTP Client、External gRPC Client、DBを含めたテストをサポートするために用意したものです。
1シナリオにつき1YAMLファイルとなっており、これをランブックと呼びます。シナリオは1つ以上のステップを持っており、シナリオごとにランナーと呼ぶステップ実行コンポーネントを定義します。そして、ランナーを使って各ステップを実行していきます。たとえば、HTTPリクエストを行うHTTPランナー、データベース操作を行うDBランナーなどがあります。
実際のランブックの構成は下記のとおりです。
ランナーおよび変数を設定して、各ステップを記述する
runnにはさまざまな機能がありますが、今回はYAMLシナリオテストを大量記述しGoのテストコード内からrunnを実行するという方針を取りました。
実践して感じるメリットとデメリット
goroutineベースのMediumテストを広く活用する方針のメリットとして小山は、すべて同じコードベースになるため認知負荷が小さい点、アプリケーション内部の実装をモックするテストよりも本来の挙動に近い点をあげます。「『アプリケーション外部コンポーネントをスタブするというような形で実際に通信するテスト』のほうが直感的には理解しやすいと感じている」(小山)
また、runnのメリットとして小山は、1シナリオあたりのカバレッジが大きく、その結果リファクタリングへの耐性が大きくなる点をあげます。また、OpenAPI SpecライクのYAMLであるため、エンジニアにとっては読みやすい=書きやすいというメリットもあるといいます。
一方、デメリットとなるのがテスト実行時間です。小山は「Smallテストに比べて実行時間が長くなってしまう。Mediumテストが容易に拡充できるようになった結果増えすぎてしまうという課題があるので、テストやデータベースを並列化するなどして対抗している」と説明します。
小山は、Mediumテストが書きやすくなることでアプリケーションコード自体のテスト容易性が下がりがちという点もデメリットとしてあげます。ただ、Unitテストを疎かにしてはいけないことは明らかであるため、アーキテクチャ未完状態とはいえ、テスト容易性は担保していかなければならないとしました。
そして、小山はアプリケーション新規開発時のアーキテクチャ未完状態に対抗する手段としてgoroutineベースのMediumテストを広く活用する方針を取ったことに対し「少なくとも現時点において悪くない判断だった」と評価します。ただし今後、規模が拡大する、あるいはアーキテクチャが安定するなどの要因により、テスト構成が変わることは大いにありえるため、「定期的に振り返っていきたい」ともコメントしました。
アーカイブ
映像はアーカイブ公開しておりますので、まだ見ていない方、もう一度見たい方は 是非この機会にご視聴ください!
https://www.youtube.com/watch?v=fWhmQcbzfZM