若くない何かの悩み

何かを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 という命名規則で分離しているので、すぐに見つけられると思います。

ブログタイトルを更新した

ブログタイトルを変えました。前のタイトルは「若き何かの悩み」で、さらにその前のタイトルは「若き JavaScripter の悩み」でした。

背景

若い頃は、JavaScript をやっていました(若き JavaScripter の悩み期)。

これだとマッチする企業が少なすぎて、手を広げることにしました。この時もまだ若くて、ただ JS から離れて iOS とか Ansible とか Jenkins とかをやっていたので、もう JavaScripter ではないなになってタイトルを変えました(若き何かの悩み期)。

今はもう若くないのでタイトルを変えました(若くない何かの悩み期)。最近やっているのは、形式手法 / ゲームテスト支援(Go とか) / バグ分析基盤構築 / にわかスクラムマスター / 闇業(メール書いて待って書いて待つ)です。

終わりに

今後ともよろしくお願いします。

あと、 @cocopon から祝いをもらいました。

LINE スタンプが4年の時を越えて登録された

学生時代最後の春休み(2014年)につくって reject されていた LINE スタンプが、なんと4年後の今日登録されました(再申請は今年2月に依頼しています)。

store.line.me

なお、これらの画像群はもともとは LINE のスタンプ 向けに作成したものです。ただ、初回申請時に微妙な条項で reject されてお蔵入りになりかけたのですが、もったいないので積極的にスライドに使って減価償却を続けていました。最近でもときどき画像を追加しています

FAQ

このキャラクター is 何?

もともとは、私が小学生ぐらいのときに書いたキャラクターです。実は最近のは目が閉じているバージョンで、当時は目が開いていました。 なお、@cocopon と共同で開発していたゲームにボスキャラとして出現しますので、これをプレイすると目が開いているバージョンを見られます。

cocopon.me

何のツールで描いたの?

フリーでオープンソースなエディタである Inkscape を使っています。

inkscape.org

このハート部分は凹んでいるのか?それとも出っ張っている?

未定義です。処理系にもよりますが、出っ張っていることがあります。

f:id:devorgachem:20190709015359p:plain

最近愛用しているお手頃快適グッズ(計 ¥3,000 未満)

近年、オフィスや休憩スペースなどで落ち着けない問題があります。落ち着き環境は生産性に大きなインパクトがありますね。 さて、いろいろグッズを集めたところ落ち着き環境の構築に成功したので共有します。

特におすすめなのは以下の2つです:

以降は、詳細や他に試した製品との比較です。

続きを読む

try! Swift Tokyo 2019 で発表した「SwiftSyntax で便利を実現する基礎」

ここのところ極めて体調が悪い日々でしたが、try! Swift でなんとか発表できました。

speakerdeck.com

続きを読む