カジュアルに使えるスタブサーバとしてhttpstubとgrpcstubを作って使っている

このエントリは Go Advent Calendar 2023 12 日目の記事です。

Goのテスティングパッケージで一番好きなパッケージは net/http/httptest です。

テスト実行時に実際にHTTPサーバを立ててHTTPリクエストを受けるというシンプルかつ強力なアプローチが良いです。

クライアント側にエンドポイントを変える仕組みさえあればクライアントのリクエストを受け付ける形でテストを構築することができるので、選択肢に入れておきたいテスト構成です。

ところで、私たちは runn (ランエヌ)というシナリオテスティングツールを開発しています。

github.com

runnはHTTPクライアントでありgRPCクライアントでもあるのですが*1、そのrunn自体のテストのためにhttpstubとgrpcstubを作って使用しています。

httpstub

github.com

httpstubはHTTPスタブサーバを立てるテストヘルパーです。

一番簡単な使い方としては httpstub.NewServer(t) でスタブサーバを立てた後、メソッドチェインで必要な分だけ各パスのレスポンスを定義してあげたら出来上がりです。

package myapp

import (
    "io"
    "net/http"
    "testing"

    "github.com/k1LoW/httpstub"
)

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"}`)

    res, err := http.Get(ts.URL + "/api/v1/users/1")
    if err != nil {
        t.Fatal(err)
    }
    t.Cleanup(func() {
        res.Body.Close()
    })
    body, err := io.ReadAll(res.Body)
    if err != nil {
        t.Fatal(err)
    }
    got := string(body)
    want := `{"name":"alice"}`
    if got != want {
        t.Errorf("got %v\nwant %v", got, want)
    }
    if len(ts.Requests()) != 1 {
        t.Errorf("got %v\nwant %v", len(ts.Requests()), 1)
    }
}

grpcstub

github.com

grpcstubはgRPCスタブサーバを立てるテストヘルパーです。

一番簡単な使い方としては .protoファイルを指定して grpcstub.NewServer(t, "protobuf/proto/*.proto") でスタブサーバを立てた後、メソッドチェインで必要な分だけ各パスのレスポンスを定義してあげたら出来上がりです。 そう、ほぼhttpstubと同じです。

package myapp

import (
    "io"
    "net/http"
    "testing"

    "github.com/k1LoW/grpcstub"
    "github.com/k1LoW/myapp/protobuf/gen/go/routeguide"
)

func TestClient(t *testing.T) {
    ctx := context.Background()
    ts := grpcstub.NewServer(t, "protobuf/proto/*.proto")
    t.Cleanup(func() {
        ts.Close()
    })
    ts.Method("GetFeature").Response(&routeguite.Feature{
        Name: "hello",
        Location: &routeguide.Point{
            Latitude:  10,
            Longitude: 13,
        },
    })
    // OR
    // ts.Method("GetFeature").Response(map[string]any{"name": "hello", "location": map[string]any{"latitude": 10, "longitude": 13}})

    client := routeguide.NewRouteGuideClient(ts.Conn())
    if _, err := client.GetFeature(ctx, &routeguide.Point{
        Latitude:  10,
        Longitude: 13,
    }); err != nil {
        t.Fatal(err)
    }
    {
        got := len(ts.Requests())
        if want := 1; got != want {
            t.Errorf("got %v\nwant %v", got, want)
        }
    }
    req := ts.Requests()[0]
    {
        got := int32(req.Message["longitude"].(float64))
        if want := int32(13); got != want {
            t.Errorf("got %v\nwant %v", got, want)
        }
    }
}

その他細かい機能とまとめ

httpstubもgrpcstubも基本は指定された通りにレスポンスを返すスタブなわけですが、他にも細かい機能があります。

  • 受け取ったレスポンスを確認する機能
  • リクエストやレスポンスが仕様に沿っているかバリデーションする機能
  • 動的にレスポンスを返す機能
  • OpenAPI Specの examples: セクションの値を使ってレスポンスを返す機能

など、便利に使えています。

実はギョームでもガッツリ使っているので、生じたニーズに応じて都度機能を追加していっています。

httpstubとgrpcstubの使い勝手がほぼ同じなのも便利です。

こういうテストヘルパーを作れるのも、カジュアルにgoroutineでサーバを立てることができるGoの強みの1つですね。

*1:他にもDBクライアントでありCDPクライアントであり任意のコマンド実行ツールでもあります