若くない何かの悩み

何かをdisっているときは、たいていツンデレですので大目に見てやってください。

go の OSS でテストを書きまくったので、テストしやすくするために役立った工夫を紹介します

社で必要になったので、Go 言語の OSS を書きました。

github.com

この OSS がなんなのかは スライド を見ていただくとして、テストをめっちゃ書いたのでだんだんどうテストを書いたらやりやすいのかがわかってきました。この成果自体はいつか vanilla-manifesto へまとめると思いますが、簡単に説明しようと思います。

書いてて辛くないテストをするために、もっとも重要なのは テスト対象の設計 です。設計といえば、よく SOLID がよいオブジェクト指向設計の指針とされますが、テストの文脈でも同じことがあてはまります。例えば、テスト対象が SOLID をよく守っていると次のように嬉しいことがあります:

単一責任原則 (S)
これが守られると、どんな振る舞いを検証したいのかが明確になるので読みやすいテストになる
開放閉鎖原則 (O)
これが守られると、機能の追加/変更でテストの書き換えが少なくなって保守しやすくなる
リスコフの置換原則 (L)
これが守られると、同じインターフェースに準拠するコンポーネントのテストの一部で、同じヘルパ関数(xUTP で言う所の Custom Assertion)を流用できるようになる
インターフェース分離の原則 (I)
これが守られると、テストの準備部分で余計なコードを書かなくて済むようになる
依存性逆転の原則
これが守られると、間接的な入出力をテストから制御できるようになってテストできる範囲が広がる

ということで「テストしやすい工夫」の実態とは「テストしやすいテスト対象の設計の工夫」と同じ意味なので、この記事で紹介するのは後者がメインになります。

さて、今回紹介するプラクティスを大雑把にいうと「だいたいの処理を関数を受け取る関数でさばく」という過激なものです。具体的手法はあとで説明しますが、これをすると、単一責任原則・開放閉鎖原則・インターフェース分離の原則・依存性逆転の原則がかなり高まり、テストの書きやすさがとてもあがります。

簡単に例を見てみましょう。Hoge という Web API があったとして、以下の 2 つを提供しているとします:

  1. GET すると Hoge 文字列を手に入れられる
  2. Hoge 文字列を POST できる

これを過激派のアプローチで実装すると、次のようになります:

type HttpDoer func(*http.Request) (*http.Response, error)
 
type Hoge string

// Hoge を Get する関数のインターフェースのようなもの。
type HogeGetter func() (*Hoge, error)

// Hoge を Get する具体的な関数のコンストラクタのようなもの。
func CreateHogeGetter(httpDo HttpDoer) HogeGetter {
    return func() (*Hoge, error) {
        request, err := http.NewRequest("GET", "http://example.com/hoge", nil)
        if err != nil {
            return nil, err
        }

        response, err := httpDo(request)
        if err != nil {
            return nil, err
        }

        if response.StatusCode < 200 || 300 <= response.StatusCode {
            return nil, fmt.Errorf("unsuccessful http response: %s", response.Status)
        }

        bs, err := ioutil.ReadAll(response.Body)
        if err != nil {
            return nil, err
        }

        hoge := Hoge(bs)
        return &hoge, nil
    }
}

あっ、待って!ちょっと待って!まだ読むのをやめないで!

ここで、なぜ http.Client を直接使わずに HttpDoer という関数を作っているかというと、 次のようにテストがめちゃ簡単になるからです

func TestHogeGetter(t *testing.T) {
    response := AnyHttpResponse()
    response.StatusCode = 200
    response.Body = ioutil.NopCloser(strings.NewReader("HOGE"))
    httpDo := ConstHttpDoerStub(response, nil)

    getHoge := CreateHogeGetter(httpDo)

    actual, err := getHoge()

    if err != nil {
        t.Errorf("want (_, nil), got (_, %v)", err)
        return
    }

    expected := Hoge("HOGE")
    if *actual != expected {
        t.Errorf("want (%v, nil), got (%v, nil)", expected, *actual)
        return
    }
}

ここでは、AnyHttpResponseHttpDoerStub という 2 つのテストダブルファクトリが登場します。それぞれの定義は次のようになっています:

// 指定したレスポンスとエラーを常に返す偽物。
func ConstHttpDoerStub(response *http.Response, err error) HttpDoer {
    return func(*http.Request) (*http.Response, error) {
        return response, err
    }
}


// よく使うテストダブルは名前をつけて定義しておくと便利。
// これは内容はともかく成功したっぽいレスポンスになるので、通信の成功だけをみているテスト対象が多ければよく使うことになる。
func AnySuccessfulHttpDoerStub() HttpDoer {
    response := AnyHttpResponse()
    response.StatusCode = 200
    return ConstHttpDoerStub(response, nil)
}


// 適当なレスポンスを作成する。
func AnyHttpResponse() *http.Response {
    // テスト側で必要な値だけを指せるようにすると、テストがどこに着目しているのかがわかりやすくなる。
    // ありえない値を指定しているのは、テスト対象に影響を与えているのにテスト側で指定し忘れるミスに気付きやすくするため。
    return &http.Response{
        Status:           "000 Dummy Response",
        StatusCode:       0,
        Proto:            "HTTP/0.0",
        ProtoMajor:       0,
        ProtoMinor:       0,
        Header:           nil,
        Body:             nil,
        ContentLength:    0,
        TransferEncoding: nil,
        Close:            false,
        Uncompressed:     false,
        Trailer:          nil,
        Request:          nil,
        TLS:              nil,
    }
}

生産性の高いテストを書く上では、(1) 間違えにくく、(2) 再利用しやすい、の 2 条件を満たすテストダブルがとても重要です。この点、上のテストダブルは極めて単純なため間違った使い方をしづらく、さらに HTTP 通信を必要とするどのテストでも再利用できます。例えば、Hoge API の POST 側のテストを次のようにできます:

func TestPost(t *testing.T) {
    response := AnyHttpResponse() // 再利用
    response.StatusCode = 201
    response.Body = ioutil.NopCloser(strings.NewReader("OK"))
    httpDo := ConstHttpDoerStub(response, nil) // 再利用

    postHoge := CreateHogePoster(httpDo)

    hoge := Hoge("")
    err := postHoge(&hoge)

    if err != nil {
        t.Errorf("want (_, nil), got (_, %v)", err)
        return
    }
}

うまく再利用できている様子がわかります。

また、このようなテストダブルファクトリを用意することで、テスト側でどのような HTTP レスポンスが帰ってくることを期待しているのかが極めて明確になっています:

func TestPost(t *testing.T) {
    response := AnyHttpResponse()
    response.StatusCode = 201
    response.Body = ioutil.NopCloser(strings.NewReader("OK"))
    httpDo := ConstHttpDoerStub(response, nil)

    // ステータスコード 201 で内容が OK なレスポンスを期待していることがすぐ読み取れる

さらに、テストダブルが関数なため柔軟性も高く、例えばリクエストの内容に応じてレスポンスを変えたくなったら次のように書けます:

func TestHogeGetter(t *testing.T) {
    httpDo := func(request *http.Request) (*http.Response, error) {
        response := AnyHttpResponse()
        if strings.HasSuffix(request.URL.Path, "hoge") {
            response.StatusCode = 200
            response.Body = ioutil.NopCloser(strings.NewReader(request.URL.Path))
        } else {
            response.StatusCode = 400
            response.Body = ioutil.NopCloser(strings.NewReader(""))
        }
        return response, nil
    }

    getHoge := CreateHogeGetter(httpDo)

どのように動くのかが一目瞭然です!(モックライブラリだとこうはいかない)

また、これにはもう一つ利点があります。上のテストダブルはそこそこ複雑なので「書きたくないなぁ」という嫌な印象をうけないでしょうか? この「書きたくないなぁ」という感覚はとても重要で、このとき偽装対象に期待する振る舞いが多い(=密結合)ことを示唆しています。つまり、偽装対象との間に抽象層を挟むなり、偽装対象を分割するなりのきっかけとできるわけです。

なお、これまではテスト面だけのメリットを強調してきましたが、やってきたことは SOLID を意識するということだったのでテスト対象側にもうれしさがあります。例えば、HTTP 通信の結果をキャッシュしたいと思ったとすると、既存の定義に影響を与えずに(=既存テストを書き換えることなく)キャッシュするバージョンをすぐに作れます:

func CreateCachedHttpDoer(httpDo HttpDoer) HttpDoer {
    cache := make(map[string]*ClonableResponse)
    return func(request *http.Request) (*http.Response, error) {
        if clonable, ok := cache[request.URL.String()]; ok {
            return clonable.Clone(), nil
        }
        response, err := httpDo(request)
        if err != nil {
            return nil, err
        }
        clonable := ClonableResponse(response)
        cache[request.URL.String()] = clonable
        return clonable.Clone(), nil
    }
}

さて、もちろん良さの裏側には悪さもあります。欠点も紹介しておきます。

欠点

この種のパターンをかなり厳密に適用すると、依存を解決する部分がモリモリになります。慣れれば型を見れば何を要求されているのかはすぐにわかって苦にならなくなるのですが、コードの見た目はかなり悪いです。

あと、関数の型定義がめちゃ増えるのですが、命名に困るようになります。上の OSS でも結構苦しかった記憶が…

まとめ

Go でテストしやすい環境を作るには、とにかく関数を使いまくってテストダブルを再利用するとよかったという話でした。

これまで説明してきたことは、DeNA/devfarm の executor まわり で実用しているので、もし気なる場合は参考にしてみてもいいかもしれません。テストダブルは *_stub.go という命名規則で分離しているので、すぐに見つけられると思います。