コネヒト開発者ブログ

コネヒト開発者ブログ

How to Write Testable Code in Golang

f:id:itosho525:20180823212423p:plain

はじめに

本記事は コネヒト Advent Calendar 2018 の19日目のエントリーになります。

こんにちは!先日PHPカンファレンス(通称: ペチコン)でLT登壇させていただいた @itosho です。

ペチコンのことも書きたいのですが、ペチコンでもGoの話をしたので、今日はGoのテストを話をしたいと思います。具体的には、今年公開した gdp というCLIツールのテストを書く時に工夫したことを紹介します。

ちなみに、gdpがどういうツールなのかは以前 Go製のCLIツールを公開しましたという記事で説明しているので、もし興味がある方はこちらも読んでいただけると嬉しいです。

開発初期のコード

gdpにはリモートリポジトリに指定したtagが存在確認を行う処理があるのですが、当初はこんな感じのメソッドを用意していました。*1

呼び出される側

func IsExistTagInRemote(tag string) bool {
    out, err := exec.Command("git", "ls-remote", "--tags", "origin", tag).CombinedOutput()
    if err != nil {
        return false
    }

    if string(out) == "" {
        return false
    }

    return true
}

呼び出す側

func main() {
    err := Validate(tag) // tagは事前にコマンドライン引数から取得
}

func Validate(tag string) error {
    if !IsExistTagInRemote(tag) {
        return errors.New("tag is not exist in remote")
    }

    return nil
}

問題点と解決方法

このコードでも、やりたいことは実現出来ます。しかし、テストのことを考えるとこのコードには問題があります。何故なら、 Validate() メソッドが IsExistTagInRemote() メソッドに依存しているからです。IsExistTagInRemote() メソッドは外部(リモートリポジトリ)との連携があるため、Validate() メソッドのテストコードを書く際に IsExistTagInRemote() メソッドの結果を変更するのが難しく、結果としてテストしづらくなります。これがまだローカルのテストであればよいのですが、例えば、CIのテストとなると、場合によってはgitのセットアップから始める必要があり、非常に手間がかかります。

このような問題を解決する手段として、Goでは Interface を利用します。Validate() メソッドを IsExistTagInRemote() メソッドに依存させるのではなく、Interfaceに依存させます。そうすることで、モックが使えるようになるため、テスタビリティを向上させることが出来ます。

Interfaceを利用したコード

概念だけだと分かりづらいと思うので、実際のコードをご覧ください。

呼び出される側

type Git interface {
    IsExistTagInRemote(tag string) bool
}

type Cmd struct {
}

func (c *Cmd) IsExistTagInRemote(tag string) bool {
    out, err := exec.Command("git", "ls-remote", "--tags", "origin", tag).CombinedOutput()
    if err != nil {
        return false
    }

    if string(out) == "" {
        return false
    }

    return true
}

Git というInterfaceにIsExistTagInRemote() というメソッドを持たせるようにしました。GoではメソッドリストがInterface内のメソッドと一致する型はそのInterfaceを満たしていることになるため、 Cmd 型は Git Interfaceを実装していることになります。

呼び出す側

func main() {
    cli := &CLI{
        git: &Cmd{},
    }

    err := cli.Validate(tag) // tagは事前にコマンドライン引数から取得
}

type CLI struct {
    git Git
}

func (cli *CLI) Validate(tag string) error {
    if !cli.git.IsExistTagInRemote(tag) {
        return errors.New("tag is not exist in remote")
    }

    return nil
}

CLI 型にGit Interfaceを持たせて、Validate() メソッドはこのInterfaceを利用します。初期化時に Cmd型を与えているので、実際にはCmd型の IsExistTagInRemote() メソッドが実行されますが、Validate()メソッドはそれを知らない(あくまでIntefaceを利用している)ので、冒頭のコードにあった依存をなくすことが出来ました。

Interfaceを利用した際のテスト

では、これで本当にテスタブルになったのか確認していきましょう。Validate() メソッドのテストは以下のように書けます。

type MockCmd struct {
    Git
}

func (m *MockCmd) IsExistTagInRemote() bool {
    return false
}

func TestValidate_NotExistInRemote(t *testing.T) {
    cli := &CLI{
        git: &MockCmd{},
    }

    err := cli.Validate()

    expected := "tag is not exist in remote"
    if !strings.Contains(err.Error(), expected) {
        t.Errorf("Output=%q, Expected=%q", err.Error(), expected)
    }
}

ポイントは Git interfaceを満たすモックを準備していることです。Validate() メソッドのテストでは、このモックを利用することで、Gitのセットアップなしにテストを行うことが出来るようになります。冒頭のコードでは依存があったため、このようなモックを準備することが困難でした。しかし、Interfaceを利用することで、モックが使えるようになり、結果としてテストコードを書くことが出来るようになりました!

まとめ

ここまでInterfaceを利用したテストについて説明してきました。Interfaceを利用することで、依存性を分離することができ、コードをシンプルにすることが出来ました。コードをシンプルにすることのメリットはテストのしやすさだけではありません。例えば、Gitの操作をコマンドベースではなく、APIベースに変更した際、呼び出す側(今回の場合だと CLI 型)は具体的な実装に依存していないので、コードの変更を最小限にすることが出来ます。「Interfaceを制するものがGoを制す」とも言われているように、Interfaceは非常に便利な機能です。

とは言え、常にInterfaceを利用するべきかと言うとそうではないと思います。Interfaceを利用したコードはどうしてもコード量が多くなってしまいます。ですので、例えば、一回きりしか使わない使い捨てコードの場合は冒頭のように愚直に書いたがほうがよいと思います。gdpは社内で頻繁に利用されているツールであり、OSSとして公開しているので、可能な限り*2よいコードを目指しました。

ちなみに、このあたりの話は以前勉強会でも発表させていただいたことがあるので、興味がある方はこちらもご覧いただけるととっても嬉しいです。

speakerdeck.com

参考サイト

Goのこの手の話は五億番煎じくらいなので、たくさんお世話になりました。

明日の予告

明日のアドベントカレンダーは @ry0_adachi さんが登場するよ!

*1:なお、コードは必要な箇所のみ掲載しています。

*2:まだまだ改善の余地はあるので、Pull Requestをお待ちしております。