この記事は Go2 Advent Calendar 2018 の 25日目の記事です。
昨日は、 @sago35 さんの Go + gRPC でC言語プロジェクトのビルドを早くした話でした。
TL;DR
- テストには、どんな種類があるの?
- E2E / integration / unit test
- コスト意識をもって書くことが大切
- テストの種別ごとに、Goだったらこう書くかなというノウハウをまとめました
- 費用対効果の高いテストコードのため、引き出しを持っておくことが大切です
- ここから、実際のコードを手元で確認できます
※自分は普段、developerとして働いておりQAに関する知識が少なかったり、goの経験も浅かったりするので、ツッコミ大歓迎です。
Motivation
-
AgileTestingDays2018に参加してテストに関するノウハウを学び、知識を整理したかった1
-
Goにおけるテストであればどうか、テストコードを書きたかった
Level of Test
(改めて整理する必要はないかもですが)テストには、下記のレベルがあります。いろいろな整理方法があるかもしれませんが、本投稿では下記の通り整理しています。
- E2E test: 実際の挙動を実機で確認する(EndtoEnd test)。たとえばwebサービスであればブラウザでの挙動確認すること。本投稿では、ここには触れません。
- Integrate test: 複数システム間を結合したテスト。例えばマイクロサービスであれば、複数サービスを結合したときに問題がないか確認すること。本投稿では、外部ミドルウェア(データベースなど)もここに含めています。
- Unit test:関数単体のテスト。本投稿では、複数関数にまたがるテストでも、アプリ(ここではGo)だけで検証できるテストをここに含めています。
上記のように整理すると、「何をテストすべきか」「テストを書くことのコスト」を考えやすいと思っています。
今年話題になった記事(スピード感重視なのでテストは書かない。テストはなぜ開発を遅くするか)やそれにまつわる投稿でも触れられていますが、自分が参加したAgileTestingDays2018でも繰り返し述べられていたのは、コスト意識です。
たとえば、eBay at Berlinのエンジニアの方は、seleniumなどを用いたE2Eテストはあまり書いていないと言っていました。上記で整理したレベルが上に行くほど、一般的にコストが大きくなると思います。
Introduction of code
さっそくですが、goにおけるテストについて、コードベースで説明していきます。
ここのソースから、下記の手順で手元で確認できます。
# setup
go get github.com/matsu0228/gotest
# mysqlの設定
# - database接続を確認するためには、docker/docker-composeが必要です
cd $GOPATH/src/github.com/matsu0228/gotest/infla
docker-compose up -d
# hostsに追記
sudo sh -c 'echo "\n127.0.0.1 docker-mysql" >> /etc/hosts'
# create dabatase/table
go run init/main.go
アプリ自体の動作確認。ここでは2種類のアプリがあります(最低限の要素を詰め込んだだけのアプリで、特に意味はありません)
cd $GOPATH/src/github.com/matsu0228/gotest
# 税抜額を算出するアプリ
go run tips/main.go
# dbに保存・dbから値取得、取得した値を計算するアプリ
# 手元でmysqlが動いている必要があります(`docker ps`で確認できます)
go run integrate/main.go
各テストは、下記でを実行できます
# table driven test や setup/teardown の例
go test -v github.com/matsu0228/gotest/tips
# mockをもちいたunit test の例
go test -v github.com/matsu0228/gotest/integrate
# buildタグによる、"unit testing"と"integrate testing"切り替えの例
go test -v github.com/matsu0228/gotest/integrate/repository
go test -v github.com/matsu0228/gotest/integrate/repository -tags=integration
Knowledge about unit test
table driven testを用いた境界値チェック
テストにおいて大切なことは、カバレジでしょうか? panicを起こさずに動作することだけをテストすれば十分でしょうか?テストコードの効果を高めるためには、それらでは不十分です。
大切な概念の1つとして、境界値チェック(入力値/出力値のパターンを網羅しておくこと)が大切です。今回のアプリでは、税込額から、税抜額(本体価格)を算出する関数calcurateTaxExcludeAmount()
がありますが、消費税で割ったときに、「割り切れる/切り上げる/切り下げる」場合がすべて正しいこと(閾値前後のチェック)を確認していると、効果が高いテストになると思います。
Goでは、このような場合にtable driven test
が有用です。テストデータを後から追加・変更がしやすくなり、何をテストしているかも見通しが良くなります。また、サブテストとして実行することでテストが並行に実施され、データ量が多くなってもテスト時間が減らせる・どのテストケースで失敗したかがわかりやすくなるなどのメリットがあります。
// at https://github.com/matsu0228/gotest/blob/0f399f729c979b27e912dd915b7442b131b0328d/tips/main_test.go#L33
// testCase として、割り切れる場合・切り上げる場合・切り下げる場合を用意する
testCase := []struct {
testName string
input int
want int
}{
{
testName: "divided",
input: 2160,
want: 2000,
},
〜省略〜
}
for _, tc := range testCase {
// 【サブテスト】として実行
t.Run(tc.testName, func(t *testing.T) {
got := testTax.calcurateTaxExcludeAmount(tc.input)
if got != tc.want {
t.Fatalf("want %v, but %v:", tc.want, got)
}
〜省略〜
setup/teardownにおける共通化
テストコードにおけるコストの一つとして、プロダクトコードと同様にメンテナンスコストがあります。プロダクトコードが大きく変わったりすると、テストコードの修正も必要になります。したがって、見通しの良さ・共通化されていることは、テストコードにおいても大切です。
Goでは、func Test**(t *testing.T) {〜〜}
でテスト内容を記述できますが、ここにはテスト内容だけを記述し、テストのための事前準備などは別に用意することで見通しが良くなります。たとえば、setup()
は、テスト用データベースへの接続、テスト用APIに接続するための認証設定など、複数のテストで利用するオブジェクトを作ったり、複数のテストで利用するデータを作成するのに適しており、teardown()
はテスト用データを削除したりとテスト用データをきれいにするのに適しています。
// at https://github.com/matsu0228/gotest/blob/0f399f729c979b27e912dd915b7442b131b0328d/tips/main_test.go#L12
// testing Mainについて : https://golang.org/pkg/testing/#hdr-Main
// TestMain is called first
func TestMain(m *testing.M) {
setup()
exitCode := m.Run()
// teardown()
os.Exit(exitCode)
}
func setup() {
fmt.Println("called setup()")
testTax = newTax()
}
mockを用いたunit test技法
外部環境に依存しない関数単体のテストをするために、mockを用いたテストが有用です。例えば、データベースから取得した値に対する処理や、利用しているAPIが500エラーを返した時のエラーハンドリングの正しさを確認するための手段として、外部環境をmockする方法があります。
この例では、データベースから取得した値に対する処理として、取得できた値を演算する関数のテストを書きました。
Goでは、外部環境をinterfaceとして実装することで、このようにmockテストができるようになります。
テストの話からはそれますが、このようにinterfaceを利用することで、ビジネスロジック(とあるデータを保存し、取得したあと処理をする)と、 技術的・具体的な実装を分離しておくことでメンテナンスしやすく、後から具体的な手法を変えることもできたり(RDBから取得していた部分を、キャッシュ参照したあとキャッシュがなければRDBを見にいく、など)と柔軟性が増します。
テスト対象の関数 : DBから取得したデータを足し合わせる関数
// at https://github.com/matsu0228/gotest/blob/0f399f729c979b27e912dd915b7442b131b0328d/integrate/main.go#L21
// calcurate of keys data
func calcurate(repo Repository, keys []string) (int, error) {
sum := 0
// gather data
var values []repository.SomeData
for _, key := range keys {
data, err := repo.Get(key)
if err != nil {
return 0, err
}
values = append(values, data...)
}
// calcurate of data
for _, v := range values {
i, err := strconv.Atoi(v.Body)
if err != nil {
return 0, err
}
log.Printf("[INFO] sum = sum:%v + i:%v", sum, i)
sum = sum + i
}
return sum, nil
}
interfaceとして外部環境を定義しておくことがポイントです
// Repository is interface of datastore
type Repository interface {
Get(title string) ([]repository.SomeData, error)
Save(title, body string) error
}
テストコード側で、interfaceを満たすmockを作成しておくことで、unitテストが書かけます
// at https://github.com/matsu0228/gotest/blob/0f399f729c979b27e912dd915b7442b131b0328d/integrate/main_test.go#L25
// repoMock is struct of Repository
type repoMock struct{}
func (r repoMock) Get(title string) ([]repository.SomeData, error) {
// testing local rule
ary := strings.Split(title, "_")
if len(ary) == 0 {
return []repository.SomeData{}, nil
}
return []repository.SomeData{
{Body: ary[1]},
}, nil
}
func (r repoMock) Save(title, body string) error {
return nil
}
Knowledge about integrate test
ビルドタグを用いた「unit test」と「integrate test」の切り替え
すぐにCI上に乗せることはできませんが、特定環境下(APIが叩ける、DB接続できる)で、integrate testができるようにするための方法です。
直接外部環境とのintegrate testをすることでmockを用いたテストでは想定していなかったケースに気づけたりしますし、最初から全ての箇所でmockとして実装するのはそれなりにコストがかかりますため、CIに乗っていなくてもこのようなテストも有用だと思います。
初期データ作成箇所
// at https://github.com/matsu0228/gotest/blob/0f399f729c979b27e912dd915b7442b131b0328d/integrate/repository/main_test.go#L24
func setup() {
fmt.Println("called setup()")
if integrateFlag { // setup when execute integrat test
var err error
//【注意】 あくまでサンプルなのでパスワード直書きしてますが、環境変数を使って設定するなど、セキュアな実装にしてください
repo, err = NewDatabase("root", "mysql", "127.0.0.1", "3306", "todo", "?parseTime=true&loc=Japan")
if err != nil {
log.Fatal(err)
}
}
}
上記のように、ビルドタグ-tags=integration
があるときのみに、外部DBへ接続するコードを呼ぶようにするため、フラグの値を下記のとおり定義します
// build tagについてのドキュメント: https://golang.org/pkg/go/build/#hdr-Build_Constraints
// タグあり: at https://github.com/matsu0228/gotest/blob/0f399f729c979b27e912dd915b7442b131b0328d/integrate/repository/integration.go
var integrateFlag = true
// タグ無し : at https://github.com/matsu0228/gotest/blob/0f399f729c979b27e912dd915b7442b131b0328d/integrate/repository/integration_false.go
// integrateFlag :build +integrate時のみtrueとする
var integrateFlag = false
同様に、外部環境が必要なテストは、ビルドタグ-tags=integration
ありのテストファイルに分けて記述しておきます。たとえば、データベースに接続した値が取得できるかどうかのテストは下記のとおりです。
// at https://github.com/matsu0228/gotest/blob/0f399f729c979b27e912dd915b7442b131b0328d/integrate/repository/database_integration_test.go
want := "body_" + timestamp
key := "title_" + timestamp
err := repo.Save(key, want)
if err != nil {
t.Fatalf("cannot Save():%v", err)
}
data, err := repo.Get(key)
if err != nil {
t.Error(err)
}
if len(data) == 0 {
t.Fatalf("none data of key:%v", key)
}
〜省略〜
if d.Body == want {
isExitst = true
}
}
if !isExitst {
t.Fatalf("want %v, but %v:", want, got)
}
circle.ciを用いた自動化
circle.ciのドキュメントどおりですが、ポイントは下記です。
最低限の設定(抜粋)
# at https://github.com/matsu0228/gotest/blob/0f399f729c979b27e912dd915b7442b131b0328d/.circleci/config.yml
jobs:
build:
docker:
# go言語の環境をdocker imageから指定する
- image: circleci/golang:1.9
# ディレクトリの指定
working_directory: /go/src/github.com/matsu0228/gotest
# Goの環境設定、コード取得
steps:
- run: echo 'export PATH=${GOPATH}/bin/:${PATH}' >> $BASH_ENV
- checkout
# テストコード実行
- run: go test -v github.com/matsu0228/gotest/tips
- run: go test -v github.com/matsu0228/gotest/integrate
- run: go test -v github.com/matsu0228/gotest/integrate/repository
# 静的解析
- run: go get golang.org/x/lint/golint
- run: go get github.com/haya14busa/goverage
- run: golint ./...
- run: go vet ./...
外部環境(ここではmysql)を使ったテスト(抜粋)
docker:
# mysql のdocker imageを取得&設定
# 【注意】 あくまでサンプルなのでパスワード直書きしてますが、環境変数を使って設定するなど、セキュアな実装にしてください
- image: circleci/mysql:5.7
environment:
MYSQL_ROOT_PASSWORD: mysql
# MYSQL_ALLOW_EMPTY_PASSWORD: yes
MYSQL_DATABASE: todo
command: [--character-set-server=utf8, --collation-server=utf8_general_ci]
# mysqlのdockerが立ち上がるまで待つ
# これがなくてテスト失敗になってハマりました。。
- run:
name: Waiting for Database to be ready
command: |
for i in `seq 1 20`;
do
nc -z 127.0.0.1 3306 && echo Success && exit 0
echo -n .
sleep 1
done
echo Failed waiting for mysql && exit 1
# 必要なテーブルの作成
# TODO:これはテストコード内でやるべきかも?
- run: go run infla/init/main.go
# mysqlを利用したテストを実行
- run: go test -v -tags=integration github.com/matsu0228/gotest/integrate/repository
Cost performance of test code
(補足:以下はテストコードにする(=テスト自動化するかどうか)の話で、リリース前にテストをしないという話ではありません)
プロダクトのフェーズや、開発チームの状況・機能の性質によって、どこまでテストコードを書くか変わると思います。たとえば、1ヶ月先には使わなくなる機能であれば、その機能のテストコードの優先度は低く、人手でテストすればよいかもしれません。
【本投稿のまとめ】効果が高いテストコードとは、「低コスト」「高効果」なものです。次の点を考慮し、今の状況に応じて、どこまでテストするかを考えるとよいでしょう。
-
低コストにする
- 一般的に、テストのレベルが高次元となるほど、そのテストコードのコストは高くなります。つまり、「Unit test多め、E2Eテストは少なめ」にした方がコストは低くできます。
- setup/taerdownなどのテクニックを使って、テストコードをメンテナンスしやすく(テスト内容とテストデータ設定を分離・DRYの原則に従う)しておくことで、将来的な改修コストを減らせます。
- 外部環境(DBなど)をテストコード用に用意するよりも、ビルドタグを使ったintegrate testをひとまず書いておくといった手法も低コストでできる手段のひとつだと思います。
-
高効果にする
- Table driven testのように値のチェック、エラーハンドリングのチェックまですると、バグを見つけやすくなりテストの効果が高まります。
- CIに乗せることで、人手でテストコードを実行しなくてもリリースプロセスで必ずテストが実行されるようにするとよいでしょう。
- 重要な機能(そのサービスにコンバージョンに関わるような)や、3回以上人手でテストをする(リグレッションテスト含む)ような機能に対してテストを書くとのがテストコードがある意義が高まります。