goのテストおよびカバレッジ測定を行う

背景

最近techschool/simplebankの写経を行っている。関連動画[Backend #13] Mock DB for testing HTTP API in Go and achieve 100% coverage - YouTubeでカバレッジの計測方法を説明しているのだが、個人的に難しく感じた。テストおよびカバレッジの計測方法は以前から興味があったので、簡単な例を用いて練習する。

はじめに

最終的には以下のようなフォルダ構成となる。

$ tree
.
├── fizzbuzz
│   ├── fizzbuzz.go
│   └── fizzbuzz_test.go
├── go.mod
└── Makefile

この記事は下記バージョンで動作を確認している。

$ go version
go version go1.17.3 linux/amd64

同じ内容をn-hachi/fizzbuzz-goにも配置した。

手順

Step1:テスト対象作成

ここではfizzbuzzをテストする。下記内容をファイル名fizzbuzz/fizzbuzz.goで保存する。

package fizzbuzz

import "fmt"

// Fizzbuzz takes one postive value and return string "Fizz", "Buzz", "FizzBuzz" or number.
func Fizzbuzz(n uint64) string {
	switch {
	case n%3 == 0:
		return "Fizz"
	case n%5 == 0:
		return "Buzz"
	case n%15 == 0:
		return "FizzBuzz"
	default:
		return fmt.Sprintf("%d", n)
	}
}

Step2:テストコードのベース作成

続いてfizzbuzz/fizzbuzz.goをテストするためのコードを示す。ちなみにテストコードはファイル名の末尾が_test.goである必要がある。このテストコードを少しずつ改善していく。 はじめに何もしないテストコードを示す。下記内容をファイル名fizzbuzz/fizzbuzz_test.goで保存する。

package fizzbuzz

import "testing"

func TestFizzbuzz(t *testing.T) {
}

以上で最低限のコードを生成したことになる

Step3:テストの実行

この状態でコマンドgo test ./... -vを実行する

$ go test ./... -v
go: cannot find main module, but found .git/config in /home/n_hachi/src/github.com/n-hachi/fizzbuzz-go
        to create a module there, run:
        go mod init

少なくとも私の環境ではこのように出力されたことからテストが失敗しているようだ(go modが実装されていないバージョンのgoコマンドを使う場合は結果が変わるかもしれない)。コメントにあるように以下コマンドを実行する。

$ go mod init
go: creating new go.mod: module github.com/n-hachi/fizzbuzz-go
go: to add module requirements and sums:
        go mod tidy

上記コマンドを実行することでgo.modが生成される。この状態で再度go test ./... -vを実行する

=== RUN   TestFizzbuzz
--- PASS: TestFizzbuzz (0.00s)
PASS
ok      github.com/n-hachi/fizzbuzz-go/fizzbuzz 0.008s

今度はテストが成功する。ついでにgo test ./... -vの実行を簡略化するため(というか私がコマンドを忘れるので)下記内容のMakefileを作成する

test:
	go test ./... -v

.PHONY: test

以降はmake testと入力することでテストが走る。

Step4:テストケースを一つ追加

テストを実装する。fizzbuzz/fizzbuzz_test.goを以下のように編集する。

package fizzbuzz

import "testing"

func TestFizzbuzz(t *testing.T) {
	// Check number string
	n1 := uint64(1)
	s1 := "1"
	if s1 != Fizzbuzz(n1) {
		t.Errorf("Fizzbuzz(1) = %v but expected %v\n", Fizzbuzz(n1), s1)
	}
}

テスト関数はTestを先頭に持つ必要がある。上記テスト関数は「関数Fizzbuzzに値1を与えたら、文字列"1"が帰ってくる」ことを期待したテストである。この状態でmake testを実行すると以下のようになる。

$ go test ./... -v
=== RUN   TestFizzbuzz
--- PASS: TestFizzbuzz (0.00s)
PASS
ok      github.com/n-hachi/fizzbuzz-go/fizzbuzz (cached)

処理時間(上の例では0.00s)は環境によって変わるかもしれないが、およそ同じ結果になるだろう。上記内容だとStep3の結果と違いがないので正しくテストされているか判断できない。そこで意図的にfizzbuzz/fizzbuzz_test.goの内容を変更してみる。 s1 := "1"となっている一行をs1 := "2"と一時的に変更し、再度make testを実行する。

$ go test ./... -v
=== RUN   TestFizzbuzz
    fizzbuzz_test.go:10: Fizzbuzz(1) = 1 but expected 2
    --- FAIL: TestFizzbuzz (0.00s)
    FAIL
    FAIL    github.com/n-hachi/fizzbuzz-go/fizzbuzz 0.002s
    FAIL
    make: *** [Makefile:2: test] Error 1

上記から正しくテストできていると判断できる。次のステップに進む前に一時的な変更をもとに戻す。

Step5:カバレッジ計測

Step4のテストだと関数Fizzbuzzに与える値が1の場合は正しく動作しそうだが、引数の値が「3の倍数」、「5の倍数」、「15の倍数」の場合に正しく動くか判断できない。このケースは単純なので予想可能だが、複雑になった場合に全テストケースを人力で網羅するのは骨が折れる。そこでカバレッジが重要になってくる。カバレッジを表示するためにMakefileを以下のように変更する

test:
	go test -cover ./... -v

coverage:
	rm -fr coverage
	mkdir coverage
	go test -coverprofile=coverage/cover.out ./...
	go tool cover -html=coverage/cover.out -o=coverage/cover.html

.PHONY: test coverage

Makefileを変更したら先ずmake testを実行してみる。

❯ make test
go test -cover ./... -v
=== RUN   TestFizzbuzz
--- PASS: TestFizzbuzz (0.00s)
PASS
coverage: 40.0% of statements
ok      github.com/n-hachi/fizzbuzz-go/fizzbuzz (cached)        coverage: 40.0% of statements

今までと異なりcoverage: 40.0% of statementsと記載される。以上から「全体の40%はテストでカバーされている」ことがわかる。言い換えると「60%はテストされていない」ということになる。この値を100%に近づけることが正しくテストされていることの一つの指標となる。

続いてmake coverageと実行する

$ make coverage
rm -fr coverage
mkdir coverage
go test -coverprofile=coverage/cover.out ./...
ok      github.com/n-hachi/fizzbuzz-go/fizzbuzz 0.002s  coverage: 40.0% of statements
go tool cover -html=coverage/cover.out -o=coverage/cover.html

上記を実行するとファイルcoverage/cover.htmlが生成される。これをブラウザで開くと以下のようになる。

golang_coverage.png 緑色の部分がテストされている箇所であり、赤色の部分がテストされていない箇所である。 このようにツールを使うことで、どのパスがテストされていないかを把握できる。

Step6:テストケース追加

テストケースを追加していく。[Backend #13] Mock DB for testing HTTP API in Go and achieve 100% coverage - YouTubeで学んだことはテストケースをSliceで定義するとテストケースを後で追加することが簡単になるようだ。具体的に示す。

package fizzbuzz

import "testing"

func TestFizzbuzz(t *testing.T) {
	testCases := []struct {
		name     string
		input    uint64
		expected string
	}{
		{
			// neither a multiple of 3 nor a multiple of 5, case1
			name:     "normal, case1",
			input:    uint64(1),
			expected: "1",
		},
		{
			// neither a multiple of 3 nor a multiple of 5, case2
			name:     "normal, case2",
			input:    uint64(11),
			expected: "11",
		},
		{
			// a multiple of 3, case1
			name:     "multiple of 3, case1",
			input:    uint64(3),
			expected: "Fizz",
		},
		{
			// a multiple of 3, case2
			name:     "multiple of 3, case2",
			input:    uint64(33),
			expected: "Fizz",
		},
		{
			// a multiple of 5, case1
			name:     "multiple of 5, case1",
			input:    uint64(5),
			expected: "Buzz",
		},
		{
			// a multiple of 5, case2
			name:     "multiple of 5, case2",
			input:    uint64(50),
			expected: "Buzz",
		},
		{
			// a multiple of 15, case1
			name:     "multiple of 15, case1",
			input:    uint64(15),
			expected: "FizzBuzz",
		},
		{
			// a multiple of 15, case2
			name:     "multiple of 5, case2",
			input:    uint64(150),
			expected: "FizzBuzz",
		},
	}

	for i := range testCases {
		tc := testCases[i]
		t.Run(tc.name, func(t *testing.T) {
			if tc.expected != Fizzbuzz(tc.input) {
				t.Errorf("Fizzbuzz(%v) = %v but expected %v\n", tc.input, Fizzbuzz(tc.input), tc.expected)
			}
		})
	}
}

上記例では無名の構造体を作成し、その型を満たすテストケースを追加している。このようにすることで後でテストケースに不足があった場合に追加が簡単になる。

この状態でmake testを実行する。

$ make test
go test -cover ./... -v
=== RUN   TestFizzbuzz
=== RUN   TestFizzbuzz/normal,_case1
=== RUN   TestFizzbuzz/normal,_case2
=== RUN   TestFizzbuzz/multiple_of_3,_case1
=== RUN   TestFizzbuzz/multiple_of_3,_case2
=== RUN   TestFizzbuzz/multiple_of_5,_case1
=== RUN   TestFizzbuzz/multiple_of_5,_case2
=== RUN   TestFizzbuzz/multiple_of_15,_case1
    fizzbuzz_test.go:65: Fizzbuzz(15) = Fizz but expected FizzBuzz
=== RUN   TestFizzbuzz/multiple_of_5,_case2#01
    fizzbuzz_test.go:65: Fizzbuzz(150) = Fizz but expected FizzBuzz
--- FAIL: TestFizzbuzz (0.00s)
    --- PASS: TestFizzbuzz/normal,_case1 (0.00s)
    --- PASS: TestFizzbuzz/normal,_case2 (0.00s)
    --- PASS: TestFizzbuzz/multiple_of_3,_case1 (0.00s)
    --- PASS: TestFizzbuzz/multiple_of_3,_case2 (0.00s)
    --- PASS: TestFizzbuzz/multiple_of_5,_case1 (0.00s)
    --- PASS: TestFizzbuzz/multiple_of_5,_case2 (0.00s)
    --- FAIL: TestFizzbuzz/multiple_of_15,_case1 (0.00s)
    --- FAIL: TestFizzbuzz/multiple_of_5,_case2#01 (0.00s)
FAIL
coverage: 80.0% of statements
FAIL    github.com/n-hachi/fizzbuzz-go/fizzbuzz 0.003s
FAIL
make: *** [Makefile:2: test] Error 1

15の倍数を関数Fizzbuzzに与えたら文字列"Fizzbuzz"が出てほしいが、実際には"Fizz"と出力されていることがわかる。fizzbuzz/fizzbuzz.goを以下のように修正する。

package fizzbuzz

import "fmt"

// Fizzbuzz takes one postive value and return string "Fizz", "Buzz", "FizzBuzz" or number.
func Fizzbuzz(n uint64) string {
	switch {
	case n%15 == 0:
		return "FizzBuzz"
	case n%3 == 0:
		return "Fizz"
	case n%5 == 0:
		return "Buzz"
	default:
		return fmt.Sprintf("%d", n)
	}
}

switchの順序をcase n%15==0が先頭に来るように入れ替えた。再度make testを実行する。

$ make test
go test -cover ./... -v
=== RUN   TestFizzbuzz
=== RUN   TestFizzbuzz/normal,_case1
=== RUN   TestFizzbuzz/normal,_case2
=== RUN   TestFizzbuzz/multiple_of_3,_case1
=== RUN   TestFizzbuzz/multiple_of_3,_case2
=== RUN   TestFizzbuzz/multiple_of_5,_case1
=== RUN   TestFizzbuzz/multiple_of_5,_case2
=== RUN   TestFizzbuzz/multiple_of_15,_case1
=== RUN   TestFizzbuzz/multiple_of_5,_case2#01
--- PASS: TestFizzbuzz (0.00s)
    --- PASS: TestFizzbuzz/normal,_case1 (0.00s)
    --- PASS: TestFizzbuzz/normal,_case2 (0.00s)
    --- PASS: TestFizzbuzz/multiple_of_3,_case1 (0.00s)
    --- PASS: TestFizzbuzz/multiple_of_3,_case2 (0.00s)
    --- PASS: TestFizzbuzz/multiple_of_5,_case1 (0.00s)
    --- PASS: TestFizzbuzz/multiple_of_5,_case2 (0.00s)
    --- PASS: TestFizzbuzz/multiple_of_15,_case1 (0.00s)
    --- PASS: TestFizzbuzz/multiple_of_5,_case2#01 (0.00s)
PASS
coverage: 100.0% of statements
ok      github.com/n-hachi/fizzbuzz-go/fizzbuzz 0.002s  coverage: 100.0% of statements

上記からテストが通った事がわかる。またcoverage: 100.0% of statementsからカバレッジが100%に到達できたことが明らかになった。

最後に

このサイズならテストなしでも問題ないかもしれないが、規模が大きくなるとやはりテストなしだと厳しいと思う。今後もこれをベースにテストやカバレッジ計測を実施していきたい。

参考

テストに関して

カバレッジに関して