Goa v3 のテストをシュッとする

5 min read読了の目安(約5200字

この記事は 2019/12/03 にはてなで公開していた記事の再掲です。

はじめに

Goa というのは、Go で API を書くためのフレームワークで、APIデザインを Go のコードとして DSL を使って書けて、デザインからコードの生成、ビジネスロジックを実装というサイクルと上手に回していくことを目的にしています。

統一的なバリデーション、エラー処理、OpenAPIドキュメントの生成など API 開発で必要なあれこれが用意されています。

Goa v3 は今年の5月にリリースされました。v3 では、HTTP だけでなく、gRPC もサポートし、HTTP と gRPC の2つのトランスポートに対して共通のデザインを書くことが出来るようになっています。

Goa v3 では v1 で生成されていたようなテスト用ヘルパー関数を生成しなくなりました。また、v3 のサービスは、サービス毎にパッケージが切られてしまうので、interface での取り回しの自由も少なく、テスト用の環境を整えるのが少し面倒でした。Goa v3 用の(HTTP トランスポートの)テストヘルパーを用意したので、これを使ってテストする方法を紹介したいと思います。

gRPC を使っている場合でも、HTTP を用意しておけば特殊なものでなければサービスメソッドは共通なので、この方法でテストしてもいいのかも・・・。

サービスメソッドをテストする

サービスメソッドは REST API でいうところのエンドポイントです。サンプルとして、次のようなデザインを考えます。

    var _ = Service("calc", func() {
        Description("The calc service performs operations on numbers.")
        Method("add", func() {
            Payload(func() {
                Field(1, "a", Int, "Left operand")
                Field(2, "b", Int, "Right operand")
                Required("a", "b")
            })
            Result(Int)
            HTTP(func() {
                GET("/add/{a}/{b}")
                Response(StatusOK)
            })
        })
    })

テストしたいエンドポイントは、/add/{a}/{b} で、これは単純に ab を足すものとします。たとえば /add/1/2 とすると、レスポンスはステータス OK(200) が返ってきて、ボディは 3 が返ってくる事をテストしていきます。
goa gen すると、gen/http の下にサービス毎のフォルダが切られてパッケージ化されたコードが生成されます。

やっかいなのは、サービス毎にパッケージ化されてしまうので、サービス毎の Server は共通して使えません(パッケージが違うから)。ぎりぎり共通で使えそうなのは、エンドポイント用の http.Handler を生成するコンストラクターと、そのハンドラをサービスにマウントする関数だけです。

テストヘルパー

テストを効率よく書けるようにするために、エンドポイント用の http.Handler を生成するコンストラクターと、そのハンドラをサービスにマウントする関数、エンドポイントを指定すると、テスト用のチェッカーを用意するヘルパーを作成しました。

https://github.com/ikawaha/goahttpcheck

使い方は

  1. チェッカーを生成
    1. 必要ならオプションを設定してください。
  2. goa gen で生成されるメソッドのhttp.Handlerを生成するコンストラクター、ハンドラをサービスるにマウントする関数、エンドポイントをチェッカーに設定します。
  3. もし必要ならミドルウエアを Use 関数で追加します。
  4. Test 関数を呼び出します。

Test 関数は ivpusic/httpcheck の http checker を返すので、あとはこれに従ってテストを調整します。

上の足し算のエンドポイントの正常系テストする例は次です。

    package calcapi
    
    import (
       "encoding/json"
       "io/ioutil"
       "log"
       "net/http"
       "strings"
       "testing"
    
       "calc/gen/calc"
       "calc/gen/http/calc/server"
       "github.com/ikawaha/goahttpcheck"
       goa "goa.design/goa/v3/pkg"
    )
    
    func TestCalcsrvc_Add(t *testing.T) {
       var logger log.Logger
       checker := goahttpcheck.New()
       // テストしたいエンドポイントをマウントします
       checker.Mount(server.NewAddHandler, server.MountAddHandler, calc.NewAddEndpoint(NewCalc(&logger)))
    
       // エンドポイントをテストします
       checker.Test(t, http.MethodGet, "/add/1/2").
          Check().
          HasStatus(http.StatusOK) // OK が返ってくるはず
    }

レスポンスの値などもチェックしたければさらに細かくテストできます。

詳細は https://github.com/ivpusic/httpcheck を参照してください。

    checker.Test(t, http.MethodGet, "/add/1/2").
       Check().
       HasStatus(http.StatusOK).
       Cb(func(r *http.Response) {
          b, err := ioutil.ReadAll(r.Body)
          if err != nil {
             t.Fatalf("unexpected error, %v", err)
          }
          r.Body.Close()
          if got, expected := strings.TrimSpace(string(b)), "3"; got != expected {
             t.Errorf("got %+v, expected %v", b, expected)
          }
       })

異常系のテストも見てみましょう。足し算のエンドポイントを少し修正して、割り算のエンドポイントを用意します。割り算では0で割ろうとするとエラーを返すようになっているものとします。

    Method("div", func() {
       Error("zero_division", ErrorResult)
       Payload(func() {
          Field(1, "a", Int, "Left operand")
          Field(2, "b", Int, "Right operand")
          Required("a", "b")
       })
       Result(Int)
       HTTP(func() {
          GET("/div/{a}/{b}")
          Response(StatusOK)
          Response("zero_division", StatusBadRequest)
       })
    })

テストはこんな風に書けます。

    func TestCalcsrvc_Div(t *testing.T) {
       var logger log.Logger
       checker := goahttpcheck.New()
       checker.Mount(server.NewDivHandler, server.MountDivHandler, calc.NewDivEndpoint(NewCalc(&logger)))
    
       // see. https://github.com/ivpusic/httpcheck
       checker.Test(t, "GET", "/div/1/0").
          Check().
          HasStatus(http.StatusBadRequest).
          Cb(func(r *http.Response) {
             b, err := ioutil.ReadAll(r.Body)
             if err != nil {
                t.Fatalf("unexpected error, %v", err)
             }
             r.Body.Close()
             var resp goa.ServiceError
             if err := json.Unmarshal(b, &resp); err != nil {
                t.Fatalf("unexpected error, %v", err)
             }
             if got, expected := resp.Message, "zero division error"; got != expected {
                t.Errorf("got %+v, expected %v", got, expected)
             }
          })
    }

見通しよく非常にスッキリ書けます。テストが書きやすいと安心ですね ╭( ・ㅂ・)و ̑̑

おわりに

HTTPとgRPCの2つのトランスポート統一的にデザインするという新たな挑戦が盛り込まれた Goa v3 が今年の5月にリリースされて、半年がたちました。HTTP による REST API を構築していた v1 ユーザーからすると、DSL の変更などもあり少し敷居が高いように感じます。v3 はまだこなれていない部分もありますが、みんなが使い始めて細かな問題点が報告され修正されるようになってきました。

僕はこの仕組みがかなり気に入っていて、事ある毎に推してるんですが、「コード生成はどうなの?」とか「標準で書いてなんぼじゃない?」みたいな意見もよく聞きます。なかなか「使ってる人は使ってるツール」から抜け出せない感はあります。v1 から v3 への移行などだいぶ知見も溜まってきたのですが、なかなか意見を交換できる人に出会えません。「ウチでも使ってるよー」という方がいらっしゃいましたら是非お教えいただきたいです。

Goa いいよ、Goa!

Happy hacking!