このコードラボでは、時間によってあいさつ文を返す簡単なライブラリを作ります。そして、そのライブラリのテスト書いていくことで、テストのやりかたやテストしやすいGoのコードについて学ぶことができます。
なお、コマンド等はmacOSのコマンドを元に表記してあるため、他のOSの場合には適宜読み替えてください。
まずはGitHubからこのコードラボで使うサンプルコードをダウンロードしましょう。git clone
するか、ZIPでダウンロードして解凍しましょう。
$ git clone https://github.com/golangtokyo/codelab.git
なお、このコードラボのサンプルコードは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/gopath
にGOPATH
を設定してみましょう。
3_package/gopath
以下のディレクトリ構造をtree
コマンドで確認してみます。なお、tree
コマンドはデフォルトではインストールされてない可能性があります。
$ tree gopath gopath └── src ├── cli │ └── greeting │ └── main.go └── greeting └── greeting.go
GOPATH
直下にはsrc
ディレクトリがあり、その下にcli
とgreeting
というディレクトリがあります。前述したgreeting.go
はgreeting
ディレクトリ以下に置いてあります。
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 build
でmain.go
をビルドしても問題ありませんが、ここではgo install
を使ってみましょう。go install
は指定したパスのmain
パッケージをビルドし、GOPATH
以下のbin
ディレクトリに配置してくれるコマンドです。
次のように、まずexport
コマンドでGOPATH
を3_package/gopath
に設定しています。なお、pwd
コマンドは現在のディレクトリの絶対パスを取得するコマンドです。go install cli/greeting
でsrc/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.Write
にfmt.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 test
はTest
という関数名にプリフィックスがついた関数で引数が*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.Writer
とio.Reader
を実装した型です。そのため、io.Writer
として書き込んだ値をStringメソッドなどで取得することが可能です。
このテスト関数では、Do
メソッドを呼び出した結果が"おはよう"であるかチェックし、"おはよう"でなければ、t.Errorf
メソッドでテストを落としています。
それでは、テストを実行してみましょう。GOPATH
を4_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.go
のmockClock
関数がヘルパー関数になります。
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つの項目をチェックしたいと思います。
io.Writer
に書き込まれるかこれらをチェックするために、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ドキュメントなど読んだりして応用的なことにチャレンジしてみてください。