DMM.comの、一番深くておもしろいトコロ。

DMMにおけるユーザーレビュー基盤の変革(Goのテスト技法編)

DMMにおけるユーザーレビュー基盤の変革(Goのテスト技法編)

  • このエントリーをはてなブックマークに追加

はじめに

こんにちは、プラットフォーム事業本部の平岩(@_rock619)です。

ユーザーレビュー基盤では現在リプレイスを進めており、バックエンドAPIをGoで開発しています。私たちのチームでは今回Goを初めて書くメンバーが多かったため、当初テストの書き方で戸惑うことがありました。そこで今回は、GoでAPIのテストを書く際に押さえておきたい点を私たちが参考にした情報とともにご紹介できればと思います。

なお、過去のユーザーレビュー基盤の変革シリーズは以下のリンクよりご覧いただけます!

inside.dmm.com

テストフレームワーク

他の言語では事実上の標準となっているテストフレームワークが多くの場合あると思います。RubyのRSpecなどがその一例です。 ですが、Goではそのようなフレームワークを使うことは推奨されず、むしろ使わないのが一般的です。 公式FAQ(Where is my favorite helper function for testing?)にその理由が書かれています。

個人的には、FAQの通りテストフレームワークは使わずにtestingパッケージで十分だと思います。ですが、値のアサーションに使うstretchr/testify/assertなど、一部のパッケージに関しては補助的に使っても問題ないと考えています。

公式FAQ(Why does Go not have assertions?)によれば、Goにアサーションがない理由は適切なエラーハンドリングとエラーレポーティングについて考えなくなってしまうからです。 適切なエラーハンドリングとはエラー発生時にクラッシュしてしまうのではなく動作を続けることであり、適切なエラーレポーティングとは大量のトレースを追わなくてもよくなるように内容が直接的で明確であることとあります。

テストの場合、適切なエラーハンドリングとエラーレポーティングによって得たいものは、テストが通らなかった場合に原因の特定が容易であることだと言えます。

この点に関して、差分の表示は補助的にパッケージを使ったほうが分かりやすくなる場合もあります。具体的には、JSONの文字列やフィールドが多い構造体などの比較の場合です。

それに加え、後述するSubtestでテストケース名を明示しそれぞれのケースの情報を補うことで、原因の特定を容易にするという目的は達成できると考えています。

前述のFAQ(Where is my favorite helper function for testing?)では他の問題点も述べられています。 テストフレームワークは標準ですでにある機能を再開発するようなミニ言語を作ってしまう傾向にあり、Goだけでなくフレームワークの理解も要求することになってしまう点です。

この点に関しても、stretchr/testify/assertのような一部のパッケージなら問題ないと考えています。なぜなら、値の比較でしか使わないため追加の知識はほとんど要求せず、Goの標準から外れたテストの書き方や実行コマンドを強制されることもないからです。

とはいえ、テストフレームワークを使わないにしてもテストの書き方には指針が欲しいですし、統一されていてほしくもあります。 標準パッケージでも使用されていて一般的な書き方として、Table Driven Testとそれに加えてSubtestがあります。

Table Driven Test

Table Driven Testとは、それぞれの行が入力と期待する出力を含んだテストケースになっているテーブルを作り、それをループして実行する形式のテストです。 Goの場合、入力と出力をフィールドとして持った構造体のスライスを定義し、forでループして実行する形になります。

利点としては、以下の点などが挙げられると思います。

  • テストケースの追加がしやすい
  • それぞれのテストケースの入力と出力がまとまって書かれるため可読性が高い

ループするため実行時の行数からではどのテストケースで失敗したのか区別できないので、どのケースか特定できる情報を出力することが必要になります。

以下のコードはGo公式のBlogUsing Subtests and Sub-benchmarksにあるサンプルコードです。

func TestTime(t *testing.T) {
    testCases := []struct {
        gmt  string
        loc  string
        want string
    }{
        {"12:31", "Europe/Zuri", "13:31"},  // incorrect location name
        {"12:31", "America/New_York", "7:31"}, // should be 07:31
        {"08:08", "Australia/Sydney", "18:08"},
    }
    for _, tc := range testCases {
        loc, err := time.LoadLocation(tc.loc)
        if err != nil {
            t.Fatalf("could not load location %q", tc.loc)
        }
        gmt, _ := time.Parse("15:04", tc.gmt)
        if got := gmt.In(loc).Format("15:04"); got != tc.want {
            t.Errorf("In(%s, %s) = %s; want %s", tc.gmt, tc.loc, got, tc.want)
        }
    }
}

Subtest

また、それに加えてSubtestを使うことができます。これにより、以下の利点があります。

  • 明示的にテストケース名を与えられるためテストケースをより説明的にできる
  • テストケースの特定のサブテストだけ実行できる
  • 平行実行ができる

Table Driven Testで挙げたUsing Subtests and Sub-benchmarksの例がSubtestを使うように修正されたものが以下です。

func TestTime(t *testing.T) {
    testCases := []struct {
        gmt  string
        loc  string
        want string
    }{
        {"12:31", "Europe/Zuri", "13:31"},
        {"12:31", "America/New_York", "7:31"},
        {"08:08", "Australia/Sydney", "18:08"},
    }
    for _, tc := range testCases {
        t.Run(fmt.Sprintf("%s in %s", tc.gmt, tc.loc), func(t *testing.T) {
            loc, err := time.LoadLocation(tc.loc)
            if err != nil {
                t.Fatal("could not load location")
            }
            gmt, _ := time.Parse("15:04", tc.gmt)
            if got := gmt.In(loc).Format("15:04"); got != tc.want {
                t.Errorf("got %s; want %s", got, tc.want)
            }
        })
    }
}

テストのパッケージ名

テストコードのパッケージは、対象と同じパッケージか_testと付けた別パッケージかのどちらかにできます。別パッケージにする利点としては、以下の2点があります。

  • 循環参照を避けられる
  • テストコードがパッケージの利用法のドキュメントに近づく

exportされていない変数などは呼ぶことができなくなりますが、テスト中で使いたい変数などをexportするコードをテスト対象のパッケージに用意するという方法で回避が可能です。

この手法に関しては@tenntennさんのGo Fridayこぼれ話:非公開(unexported)な機能を使ったテスト #golang - Mercari Engineering Blogで詳細に説明されています。

テストの関数名

テストの関数名にはTestXxxの形式にしないと実行されない以外、規則は特にありません。ですが、私たちは関数のテストは関数名がFunctionの場合TestFunction、メソッドのテストは型名がTypeでメソッド名がMethodの場合TestType_Methodと統一しています。これはテストコードに書くことができるExampleと命名規則をそろえるためです。

Exampleについては@budougumi0617さんのGoのtestingを理解する in 2018 - Examples編 #go - My External Storageを参考にしていただければと思います。

テストしやすいコード

さて、基本的なテストの書き方が分かっても、そもそもテストしやすいようにコードが書かれていないとかなり苦労することになります。

テストしやすいコードを書く方法については、@deeetさんのテストしやすいGoコードのデザインが明確に言語化されていてとても良いため、ぜひ読んでいただきたいです。個人的にも何度も読み返しています。

interfaceを使ったモック

外部のサービスなどに依存する処理を単体でテストすることを可能にするため、よく使われるテクニックとしてinterfaceを使った依存のモック化があります。

これについては、まず@deeetさんのGolangにおけるinterfaceをつかったテスト技法 | SOTAで基本を押さえるのがいいと思います。

それに加えて、structへのinterfaceの埋め込みを利用して部分的に実装を入れ替えるパターンを利用するとより柔軟なテストを記述できます。この手法については@haya14busaさんのGolangにおけるinterfaceをつかったテストで mock を書く技法を読むのが良いと思います。

テストの前後にはさむ処理

テストの前後に処理をはさみたい場合、以下でのsetup()のように後処理用の関数を返す関数を使うパターンがあります。後処理用の関数が唯一の戻り値の場合はdefer setup()()とも書けて一見スマートに見えますが、個人的には明示的に受け取って呼び出した方が分かりやすいと感じます。

func TestFunc(t *testing.T) {
    teardown := setup()
    defer teardown()

    // ...
}

func setup() func() {
    // 前処理
    return func() {
        // 後処理
    }
}

また、パッケージ全体でのテストの前後に処理をはさむこともできます。TestMain(m *testing.M)を定義しm.Run()の前後に記述します。以下の例ではos.Exit()を使う都合上deferを使っていません。

func TestMain(m *testing.M) {
    teardown := setup()
    ret := m.Run()
    teardown()
    os.Exit(ret)
}

DB関連のテスト

DBがからむ処理については、docker-composeで開発環境を構築しているため、ローカル、CIでDBのコンテナを立ててテストしています。

もちろん、sqlmockなどを使い、DBを使わずにテストする方法もあります。ですが、たとえばsqlmockでは発行されるクエリが意図したものであるところまでしか担保できないという難点があります。

そのため、テストの実行に時間がかかる欠点はあるものの、動作を保証できる範囲が増える利点の方が上回ると考え、DBを実際に使ってテストする方法を選択しています。

HTTPリクエスト・レスポンスのテスト

APIから意図したレスポンスが返ってくるかのテストには、net/http/httptestResponseRecorderを使っています。 ResponseRecordernet/httpResponseWriterを実装しており、ResponseRecorder.Body*bytes.Bufferなので、レスポンスボディをあとからioutil.ReadAllなどで取り出すことができます。

もっともシンプルな例はResponseRecorderのExampleにありますが、より詳しくは@__timakin__さんのGoのAPIのテストにおける共通処理 – timakin – Mediumが参考になるかと思います。

さいごに

私の所属するプラットフォーム事業本部 Customer Decision Support Teamでは、一緒に働いてくれる仲間を探しています。 それぞれの得意分野を活かしながら、みんなでフロントエンドからインフラまでフルスタックを目指しているチームです。 少しでもご興味のある方はぜひご応募ください!

dmm-corp.com