このコードラボでは、時間によってあいさつ文を返す簡単なライブラリを作ります。そして、そのライブラリのテスト書いていくことで、テストのやりかたやテストしやすいGoのコードについて学ぶことができます。

なお、コマンド等はmacOSのコマンドを元に表記してあるため、他のOSの場合には適宜読み替えてください。

まずはGitHubからこのコードラボで使うサンプルコードをダウンロードしましょう。git cloneするか、ZIPでダウンロードして解凍しましょう。

$ git clone https://github.com/golangtokyo/codelab.git

ZIPでダウンロード

なお、このコードラボのサンプルコードはgo-greeting以下に入っています。

$ cd codelab/go-greeting
$ ls
1_helloworld 2_time 3_package 4_mock 5_helper 6_tabletest README.md

まずは、単に"こんにちは"と表示するだけのプログラムを作ってみましょう。1_helloworld/main.goを見ると次のようになっています。

package main

import "fmt"

func main() {
        fmt.Println("こんにちは")
}

標準パッケージのひとつのfmtパッケージという書式に関するパッケージをインポートし、fmt.Println関数で標準出力に"こんにちは"と表示しているだけです。

それでは、1_helloworld/main.goを実行してみましょう。ここではgo runコマンドを使って実行してみます。

$ go run main.go
こんにちは

うまく実行できれば、ターミナル上に"こんにちは"と表示されているのが確認できるでしょう。

標準出力に"こんにちは"と表示しただけでは面白くありません。次に現在時刻によってあいさつ文を変えるようにしてみましょう。

時間

あいさつ文

04時00分 〜 09時59分

おはよう

10時00分 〜 16時59分

こんにちは

17時00分 〜 03時59分

こんばんは

Goの標準パッケージでは時間に関する関数や型を提供するとしてtimeパッケージを提供しています。timeパッケージには現在時刻を取得するために、time.Now関数が存在します。

time.Now関数で取得した値はtime.Time型という時刻を表す型です。time.Time型のHourメソッドを呼び出すことでを取得することができます。

これらを踏まえて2_time/main.goを見てみましょう。

package main

import (
        "fmt"
        "time"
)

func main() {
        greet()
}

func greet() {
        h := time.Now().Hour()
        switch {
        case h >= 4 && h <= 9:
                fmt.Println("おはよう")
        case h >= 10 && h <= 16:
                fmt.Println("こんにちは")
        default:
                fmt.Println("こんばんは")
        }
}

あいさつ文を表示する処理は、main関数から切り離し、greet関数を作りました。greet関数内では、time.Now関数で現在時刻を取得し、その値のHourメソッドを呼び出すことでを取得しています。取得したを基にswitch文で分岐し、時刻によってあいさつ文を変えています。

それでは実行してみましょう。お昼に実行した場合は、実行結果は変わりませんが、夜や朝に実行すると"おはよう"や"こんばんは"と表示されるはずです。

$ go run main.go
こんにちは

ユーザ定義パッケージ

他の言語と同様に、Goでもユーザ定義のパッケージを作ることができます。ユーザ定義パッケージは、fmtパッケージやtimeパッケージなどの標準パッケージと同様に、インポートすることで利用することができます。

それでは、あいさつ文を表示するパッケージとしてgreetingパッケージを作ってみましょう。greeting.goを作り、次のようなにDo関数を作成します。

package greeting

import (
        "fmt"
        "time"
)

func Do() {
        h := time.Now().Hour()
        switch {
        case h >= 4 && h <= 9:
                fmt.Println("おはよう")
        case h >= 10 && h <= 16:
                fmt.Println("こんにちは")
        default:
                fmt.Println("こんばんは")
        }
}

Do関数の関数名が大文字で始まる理由は、Goではパッケージ外に関数や型、変数などを公開する場合には大文字で名前を始めるというルールがあるからです。

GOPATH

作成したユーザ定義パッケージはどのディレクトリに置くべきでしょうか。外部パッケージをインポートするには、import文を書きます。Goのコンパイラは、import文で書いたインポートパス("fmt""time"など)から外部パッケージのソースコードを見つける必要があります。そのため、標準パッケージ以外のパッケージは、GOPATHという特別なディレクトリ以下にソースコードが格納されている必要があります。

GOPATHは複数設定でき、マシン全体のGOPATHを1つ設定するほか、プロジェクトごとにGOPATHを追加で設定する場合もあります。ここでは、3_package/gopathGOPATHを設定してみましょう。

3_package/gopath以下のディレクトリ構造をtreeコマンドで確認してみます。なお、treeコマンドはデフォルトではインストールされてない可能性があります。

$ tree gopath
gopath
└── src
    ├── cli
    │   └── greeting
    │       └── main.go
    └── greeting
        └── greeting.go

GOPATH直下にはsrcディレクトリがあり、その下にcligreetingというディレクトリがあります。前述したgreeting.gogreetingディレクトリ以下に置いてあります。

cli/greetingディレクトリ以下には次のような実行可能なバイナリを生成するためのmain関数を持ったmain.goが置いてあります。

package main

import "greeting"

func main() {
        greeting.Do()
}

main.goでは、greetingパッケージを使うためにimport "greeting"という文を書いています。Goのコンパイラは、このimport文に書かれたインポートパスとGOPATHを基にして、gopath/src/greeting以下にgreetingパッケージのソースコードがあることを知ります。つまり、srcディレクトリ以下に置いたディレクトリ構造とインポートパスは一致している必要があるということです。

それでは、GOPATHを設定してcli/greeting/main.goをビルドしてみましょう。go runで実行したり、go buildmain.goをビルドしても問題ありませんが、ここではgo installを使ってみましょう。go installは指定したパスのmainパッケージをビルドし、GOPATH以下のbinディレクトリに配置してくれるコマンドです。

次のように、まずexportコマンドでGOPATH3_package/gopathに設定しています。なお、pwdコマンドは現在のディレクトリの絶対パスを取得するコマンドです。go install cli/greetingsrc/cli/greetingディレクトリ以下のコードをビルドし、binディレクトリ以下に配置しています。binディレクトリ以下に生成されたgreetingという名前のバイナリを実行してみると、うまくビルドされていることが確認できます。

$ cd 3_package/gopath
$ export GOPATH=`pwd`
$ go install cli/greeting
$ ./bin/greeting
こんにちは

テストしやすいコードにしよう

それでは次にgreetingパッケージのテストを書いてみましょう。しかし、ここまでのgreetingパッケージの実装では、うまくテストができません。例えば、"こんばんは"がうまく表示されるかテストしたい場合には、内部でtime.Now関数を使っているため、夜にならないと確認することができません。

そこで現在時刻を取得する部分をテスト用にモックに差し替えるようにしましょう。まずはtime.Now関数を抽象化したClockインタフェースを次のように定義しましょう。

type Clock interface {
        Now() time.Time
}

type ClockFunc func() time.Time

func (f ClockFunc) Now() time.Time {
        return f()
}

ClockインタフェースはNowメソッドを規定しており、シグニチャはtime.Now関数と一致しています。また、ここではClockインタフェースの実装を簡単にするために、ClockFunc型も提供しています。ClockFunc型はClockインタフェースを実装している型で関数です。time.Time型の値を返す関数をClockFunc型にキャストするだけで、新しい型を作ることなくClockインタフェースを実装していることにできます。

次にClockインタフェースをフィールドとして持つGreeting構造体型を作りましょう。Greeting型は次のような定義になっています。また、現在時刻を取得するnowメソッドも定義しています。nowメソッドは、Clockフィールドがnilの場合はtime.Now関数を使って現在時刻を取得しています。一方、nilではない場合はClockフィールドのNowメソッドを呼び出すことで現在時刻を取得しています。そのため、Clockフィールドを入れ替えるだけでtime.Now関数とは別の挙動をさせることができるようになり、常に同じ時間を返すようなモックを差し込むことでテストを行うことが容易になります。

type Greeting struct {
        Clock Clock
}

func (g *Greeting) now() time.Time {
        if g.Clock == nil {
                return time.Now()
        }
        return g.Clock.Now()
}

現在時刻の問題はモックを差し込めるようにインタフェースにすることで解決しました。しかし、現在の実装ではあいさつ文が標準出力に書き出されるためテストする際に取得することが困難です。そこで書き込み先を自由に変えられるようにしましょう。例えば、次のようにDoメソッドを定義し、引数で受け取ったio.Writerに書き込むようにすれば良さそうです。

func (g *Greeting) Do(w io.Writer) error {
        h := g.now().Hour()
        var msg string
        switch {
        case h >= 4 && h <= 9:
                msg = "おはよう"
        case h >= 10 && h <= 16:
                msg = "こんにちは"
        default:
                msg = "こんばんは"
        }

        _, err := fmt.Fprint(w, msg)
        if err != nil {
                return err
        }

        return nil
}

Doメソッドはnowメソッドで取得した時刻を基に、あいさつ文を設定し、引数で受け取ったio.Writefmt.Fprintf関数で書き込んでいます。

テストを書こう

さて、テストを書く準備ができたので、実際にテストを書いていきましょう。テストコードを書くために、Greeting型を定義したgreeting.goと同じディレクトリにgreeting_test.goというファイルを作りましょう。

4_mockディレクトリ以下の構成は次のようになっています。

$ tree 4_mock
4_mock
└── gopath
    └── src
        ├── cli
        │   └── greeting
        │       └── main.go
        └── greeting
            ├── greeting.go
            └── greeting_test.go

Goでは_test.goのプリフィックスをつけたファイルをテストコードだと解釈します。そして、go testコマンドでそれらのテストコードを実行することが可能です。

テストコードのパッケージは、テスト対象のパッケージと同じパッケージにすることもできますが、greeting_testのように_testサフィックスつけることもできます。_testサフィックスをつけることでテスト対象のパッケージとは別のパッケージにすることができ、テスト対象のコードとテストコードを疎結合にすることができます。

テストコードはいくつかのテスト関数から成ります。go testTestという関数名にプリフィックスがついた関数で引数が*testing.T型を取るものをテスト関数だと解釈します。たとえば、greeting_test.goを見てみると次のような定義になっています。

package greeting_test

import (
        "bytes"
        "testing"
        "time"

        "greeting"
)

func TestGreeting_Do(t *testing.T) {
        g := greeting.Greeting{
                Clock: greeting.ClockFunc(func() time.Time {
                        // 2018/08/31 06:00:00
                        return time.Date(2018, 8, 31, 06, 0, 0, 0, time.Local)
                }),
        }

        var buf bytes.Buffer
        if err := g.Do(&buf); err != nil {
                t.Error("unexpected error:", err)
        }

        if expected, actual := "おはよう", buf.String(); expected != actual {
                t.Errorf("greeting message wont %s but got %s", expected, actual)
        }
}

greeting_test.goにはTestGreeting_Do関数というテスト関数が1つだけ定義されています。TestGreeting_Do関数は*greeting.Greeting型のDoメソッドをテストするテスト関数です。

greeting.Greeting構造体のClockフィールドにgreeting.ClockFuncにキャストした、常に2018年08月31日の06時00分の時刻を返す関数を設定しています。そのため、いつDoメソッドを呼び出しても朝6時のあいさつ文がDoメソッドに渡したio.Writerに書き込まれます。

Doメソッドには、引数として*bytes.Buffer型の値を渡しています。*bytes.Buffer型はio.Writerio.Readerを実装した型です。そのため、io.Writerとして書き込んだ値をStringメソッドなどで取得することが可能です。

このテスト関数では、Doメソッドを呼び出した結果が"おはよう"であるかチェックし、"おはよう"でなければ、t.Errorfメソッドでテストを落としています。

それでは、テストを実行してみましょう。GOPATH4_mock以下に通し、go testを実行することでテストを行うことができます。

$ cd 4_mock/gopath
$ export GOPATH=`pwd`
$ go test -v greeting
=== RUN   TestGreeting_Do
--- PASS: TestGreeting_Do (0.00s)
PASS
ok      greeting 

go testにはテストを行いたいパッケージ名を指定しています。また、-vオプションを指定することで、テストの詳細を表示しています。go testの結果から、うまくTestGreeting_Do関数が実行され、テストが成功していることが分かります。

ここまでのテストでは、朝6時の場合の1つのケースしかテストを行っていませんでした。テストを網羅的に行うためには、もっとテストケースを増やす必要があります。しかし、その前にヘルパー関数を作って、greeting.Greeting構造体のClockフィールドを設定しやすくしましょう。

作成するヘルパー関数は、greeting.Clockインタフェースを実装した値を返します。引数に文字列で"2018/08/31 06:00:00"のように渡すと、その文字列をtime.Time型としてパースします。そして、ヘルパー関数の戻り値として返された値のNowメソッドを呼び出すとパースされたtime.Time型の値が返されます。

さっそく実装を見てみましょう。5_helper/gopath/src/greeting/greeting_test.gomockClock関数がヘルパー関数になります。

func mockClock(t *testing.T, v string) greeting.Clock {
        t.Helper()
        now, err := time.Parse("2006/01/02 15:04:05", v)
        if err != nil {
                t.Fatal("unexpected error:", err)
        }

        return greeting.ClockFunc(func() time.Time {
                return now
        })
}

mockClock関数は2つの引数を受け取ります。第1引数は*testing.T型の値です。第2引数はtime.Time型の値としてパースするための文字列です。

ここで第1引数に*testint.T型の値を取る理由は2つあります。

1つめは、Helperメソッドを呼ぶためです。Helperメソッドを呼ぶことでt.Errorなどでテストを落とした際に表示されるテストが落ちた位置がヘルパー関数内ではなく、呼び出し元の位置になります。ヘルパー関数は複数のテスト関数から呼び出されることが予想されます。そのため、テストが落ちた位置がヘルパー関数内だと、あまり情報として有益なものではないからです。

2つめは、ヘルパー関数内でエラーが発生した場合にテストを落とすためです。ヘルパー関数内で発生するエラーは予期しないものが多く、本来のテスト対象のコードとは関係のないエラーが多いからです。たとえば、mockClock関数の例だと、渡された文字列をtime.Time型にパースする際に失敗するとエラーが発生します。ここでエラーが発生するとテストを続けることが不可能であるため、t.Fatalでテストを落としています。

さて、mockClock関数を使ってテストコードを書いていきましょう。greeting_test.goを見てみると、テスト関数で次のようにmockClock関数を使っています。

func TestGreeting_Do(t *testing.T) {
        g := greeting.Greeting{
                Clock: mockClock(t, "2018/08/31 06:00:00"),
        }

        var buf bytes.Buffer
        if err := g.Do(&buf); err != nil {
                t.Error("unexpected error:", err)
        }

        if expected, actual := "おはよう", buf.String(); expected != actual {
                t.Errorf("greeting message wont %s but got %s", expected, actual)
        }
}

greeting.Greeting構造体のClockフィールドを設定する際に、mockClock関数を呼び出すことで簡単に設定できていることが分かります。

それではテストを実行してみましょう。特に実行結果は変わりませんが、うまくテストができていることが分かります。

$ cd 5_helper/gopath
$ export GOPATH=`pwd`
$ go test -v greeting
=== RUN   TestGreeting_Do
--- PASS: TestGreeting_Do (0.00s)
PASS
ok      greeting 

テストケースを考えよう

ヘルパー関数を作りテストケースが作りやすくなったので、さっそくテストケースを増やしていきましょう。まずは1つのテストケースで何をテストするか決めましょう。ここでは次の3つの項目をチェックしたいと思います。

これらをチェックするために、1つのテストケースを次の構造体で定義します。

struct {
        writer io.Writer
        clock  greeting.Clock

        msg       string
        expectErr bool
}

writerフィールドは、*greeting.Greeting型のDoメソッドに渡すためのio.Writerです。実際には*bytes.Buffer型の値を渡すことで書き込んだ内容をチェックすることができます。

clockフィールドは、greeting.Greeting構造体のClockフィールドに設定される値です。mockClock関数で生成した値を設定します。

msgフィールドは、writerフィールドに書き込まれるあいさつ文の期待する値です。この値が書き込まれていない場合はテストを落とします。

expectErrフィールドは、*greeting.Greeting型のDoメソッドの第2戻り値がエラーを返すことを期待するかどうかを表す値です。予期するエラーを返す場合にはtrueが設定されます。expectErrフィールドの値がfalseの場合にDoメソッドがエラーを返した場合にはテストを落とします。一方、trueの場合にDoメソッドがエラーを返さない場合もテストを落とします。

テーブル駆動テストをやってみよう

Goではテーブル駆動テストと呼ばれるテスト手法があります。テストケースをテーブル状に定義しておき、それを順次テストすることで網羅的にテストを行うものです。

ここでは、前述したテストケースを表す構造体を次のようなマップにして並べてましょう。

cases := map[string]struct {
        writer io.Writer
        clock  greeting.Clock

        msg       string
        expectErr bool
} {
        // テストケース        
}

このマップを次のようにforで回すことで各テストケースを実行します。

for n, tc := range cases {
        tc := tc
        t.Run(n, func(t *testing.T) {
                g := greeting.Greeting{
                        Clock: tc.clock,
                }

                switch err := g.Do(tc.writer); true {
                // エラーを期待してるのにエラーが発生していない
                case err == nil && tc.expectErr:
                        t.Error("expected error did not occur")
                // エラーは期待してないのにエラーが発生した
                case err != nil && !tc.expectErr:
                        t.Error("unexpected error:", err)
                }

                // tc.writerが*bytes.Bufferだったら値もチェック
                if buff, ok := tc.writer.(*bytes.Buffer); ok {
                        msg := buff.String()
                        if msg != tc.msg {
                                t.Errorf("greeting msg wont %s but got %s", tc.msg, msg)
                        }
                }
        })
}

各テストケースは、t.Runメソッド用いてサブテストとして実行されます。サブテストとして、実行することで各テストケースに名前をつけることができます。t.Runメソッドの引数は、第1引数がテストケース名、第2引数がテストケースを実行するための関数です。

サブテストとして各テストケースを実行する利点は、go testコマンドの-runオプションで実行するサブテストを指定することができるためです。例えば、go test -run TestHoge/Aと実行すると、TestHoge関数のサブテストのAだけが実行されます。個別に実行することができると、テストケースが増えた際に無駄に実行を待つ必要がなくなります。

なお、t.Runメソッドを呼び出す前にtc := tcと代入しなおしている理由は、変数tcのスコープをfor全体ではなく、各繰り返しにするためです。こうしておくことで、テストケースが並列に実行されてしまった場合でも、t.Runメソッドに渡した関数の中で変数tcを参照しても期待した挙動になるからです。もし、上書きしてない場合には、テストケースが並列に実行される場合には、一度forがすべて終わったあとにテストケースが実行されるため、どの回でも変数tcが最後のテストケースになってしまうからです。

それでは、実際にテストケースを並べていきましょう。時間によってあいさつ文が異なるため、境界となる時刻でテストケースを作ります。作成したテストケースは次のようになります。

cases := map[string]struct {
        writer io.Writer
        clock  greeting.Clock

        msg       string
        expectErr bool
}{
        "04時": {
                writer: new(bytes.Buffer),
                clock:  mockClock(t, "2018/08/31 04:00:00"),
                msg:    "おはよう",
        },
        "09時": {
                writer: new(bytes.Buffer),
                clock:  mockClock(t, "2018/08/31 09:00:00"),
                msg:    "おはよう",
        },
        "10時": {
                writer: new(bytes.Buffer),
                clock:  mockClock(t, "2018/08/31 10:00:00"),
                msg:    "こんにちは",
        },
        "16時": {
                writer: new(bytes.Buffer),
                clock:  mockClock(t, "2018/08/31 16:00:00"),
                msg:    "こんにちは",
        },
        "17時": {
                writer: new(bytes.Buffer),
                clock:  mockClock(t, "2018/08/31 17:00:00"),
                msg:    "こんばんは",
        },
        "03時": {
                writer: new(bytes.Buffer),
                clock:  mockClock(t, "2018/08/31 03:00:00"),
                msg:    "こんばんは",
        },
}

"おはよう"、"こんにちは"、"こんばんは"の3つのあいさつ文に対して、それぞれ始まりの時刻と終わりの時刻をテストしていることが分かります。

さて、これでテストケースをある程度網羅できました。しかし、まだ*greeting.Greeting型のDoメソッドで期待したエラーが発生した場合のテストケースがありません。Doメソッドでエラーが発生する可能性があるのは、io.Writerに書き込む時です。そこで、わざとエラーを発生させるために、Writeメソッドを呼び出すたびにエラーが発生するような型を作ってみましょう。

type errorWriter struct {
        Err error
}

func (w *errorWriter) Write(p []byte) (n int, err error) {
        return 0, w.Err
}

errorWriter型は構造体でErrフィールドを持ちます。*errorWriter型のWriteメソッドは、io.Writerで規定しているWriteメソッドを実装するものです。このメソッドの実装をみると、呼び出すたびにErrフィールドで設定したエラーを返していることが分かります。

さて、このerrorWriter型を用いて予期するエラーが正しく発生するかテストするケースを追加しましょう。

cases := map[string]struct {
        writer io.Writer
        clock  greeting.Clock

        msg       string
        expectErr bool
}{
        // 略
        "エラー": {
                writer:    &errorWriter{Err: errors.New("error")},
                expectErr: true,
        },
}

"エラー"というテストケースを追加し、writerフィールドに*errorWriter型の値を設定し、expectErrにはtrueを設定しています。こうすることで、予期しているエラーがちゃんとDoメソッドから返ってくるかチェックすることができます。

最後にGOPATHを設定して、go testコマンドでテストを実行してみましょう。

$ cd 6_tabletest/gopath
$ export GOPATH=`pwd`
$ go test -v greeting
=== RUN   TestGreeting_Do
=== RUN   TestGreeting_Do/04時
=== RUN   TestGreeting_Do/09時
=== RUN   TestGreeting_Do/10時
=== RUN   TestGreeting_Do/16時
=== RUN   TestGreeting_Do/17時
=== RUN   TestGreeting_Do/03時
=== RUN   TestGreeting_Do/エラー
--- PASS: TestGreeting_Do (0.00s)
    --- PASS: TestGreeting_Do/04時 (0.00s)
    --- PASS: TestGreeting_Do/09時 (0.00s)
    --- PASS: TestGreeting_Do/10時 (0.00s)
    --- PASS: TestGreeting_Do/16時 (0.00s)
    --- PASS: TestGreeting_Do/17時 (0.00s)
    --- PASS: TestGreeting_Do/03時 (0.00s)
    --- PASS: TestGreeting_Do/エラー (0.00s)
PASS
ok      greeting        0.011s

-vオプションをつけると、各テストケースがサブテストとしてうまく実行できていることが分かります。

このコードラボでは、テテストの基本的な書き方からテストしやすいコードの書き方、ヘルパー関数、テーブル駆動テストなどを一通り学びました。ここで学んだ内容は基礎的な内容なので、ぜひtestingパッケージのAPIドキュメントなど読んだりして応用的なことにチャレンジしてみてください。