はじめに
本記事はHRBrainのAdvent Calendar21日目の記事です。
こんにちは!!!
HRBrainでバックエンドエンジニアをしている@darkroです。
寒さが一段と厳しい今日この頃ですが、サッカーW杯で盛り上がり、M-1グランプリで盛り上がり、アドベントカレンダーでも盛り上がっていきましょう!!!
さて、本日のテーマはGoのユニットテストについてです。
皆さんはユニットテストをちゃんと書いていますか?
- 最速リリーススケジュール優先でユニットテストを書く時間を見積もっていない
- 結合テストをやっているから大丈夫
- 効果が実感できない、必要性がわからない
などなど、理由をつけてついつい疎かになっていませんか?
確かにユニットテストを省くことで動くものをそれだけ早く出せるかもしれません。
しかし、その分そのコードは信頼性が損なわれていると言っていいと思います。実際に私もユニットテストのお陰でリリース前に考慮漏れや事前にバグを検出できたケースをいくつも経験してきました。
そこで、今回はGoでユニットテストを書くにあたってのルールや便利な外部パッケージをいくつか紹介してみたいと思います。
標準パッケージ
まずは、Goの標準パッケージtestingについてです。
基本的にはこのtestingのみでユニットテストを実行することができます。
- ファイル名はhoge_test.goのように、後ろに
_test
をつけます。 - 関数名はTestHogeMogeのように、Testではじめ後ろはキャメルケースで命名します。
- TableDrivenTestというテスト形式で記述することが推奨されています。
package main
import (
"testing"
)
func max(a, b int) int {
if a > b {
return a
}
return b
}
func TestMax(t *testing.T) {
type args struct {
a int
b int
}
tests := []struct {
name string
args args
want int
}{
{
name: "a:1,b:2",
args: args{a: 1, b: 2},
want: 2,
},
{
name: "a:2,b:1",
args: args{a: 2, b: 1},
want: 2,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := max(tt.args.a, tt.args.b); got != tt.want {
t.Errorf("plus() = %v, want %v", got, tt.want)
}
})
}
}
VS Codeを使っている場合、コマンドパレットからgo test
と入力すると簡単に雛形を作成できるのでおすすめです。
基本的にこの標準パッケージのみでユニットテストを書くことができるのですが、あると便利な外部パッケージを少し紹介します。
アサーション
Goの標準パッケージではアサーションを提供していません。
理由は公式のFAQに書かれています。
そこで、testifyをアサーションとして利用すると便利です。
package main
import (
"testing"
"github.com/stretchr/testify/assert"
)
// ...
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := max(tt.args.a, tt.args.b)
assert.Equal(t, tt.want, got)
})
}
}
等価比較
よく構造体の比較には標準パッケージreflect
のDeepEqual()
を使うことがあると思いますが、go-cmpを使うと一部のフィールドを無視して比較できたり、差分が見やすくなります。
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// ...
if !reflect.DeepEqual(got, tt.want) {
if diff := cmp.Diff(got, tt.want); diff != "" {
t.Errorf("Hogefunc differs: (-got +want)n%s", diff)
}
}
})
}
モック
次に、インターフェース定義からモックの生成を行うことができるライブラリのgomockについてです。次のようなUserを更新する関数のモックを例にみていきます。
type User struct {
Name string
Email string
}
type Usecase interface {
Update(id uint64, user User) error
}
まずはmockgen
コマンドを使ってモックを作成してみましょう。
mockgen -source=usecase.go -destination=./mock/mock_usecase.go -package mock
使い方としては、モック呼び出し用のControllerを作成してからモックを作成します。
その後テストで使用するモック関数とその戻り値を記述し、テストを実行するようにします。
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// モック呼び出しのcontroller作成
ctrl := gomock.NewController(t)
defer ctrl.Finish()
// モック作成
repo := mock.NewMockRepository(ctrl)
repo.EXPECT().
Update(user.ID(id), user.User{
Name: "名前",
Email: "hogehoge@hrbrain.co.jp",
}).
Return(nil)
usecase := uc.NewUsecase(repo)
err := usecase.Update(tt.id, tt.user)
// ...
})
}
SQLモック
最後に、sqlのMockを作ってDBを使った関数のユニットテストを書く場合について、go-sqlmockを使うと便利です。
SELECT文ではExpectQuery()
、UPDATE、INSERT、DELETE文ではExpectExec()
を使い、期待するクエリと結果をモック化します。
package main
import (
"fmt"
"testing"
"github.com/DATA-DOG/go-sqlmock"
)
// a successful case
func TestShouldUpdateStats(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("an error '%s' was not expected when opening a stub database connection", err)
}
defer db.Close()
mock.ExpectBegin()
mock.ExpectExec("UPDATE products").WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectExec("INSERT INTO product_viewers").WithArgs(2, 3).WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectCommit()
// now we execute our method
if err = recordStats(db, 2, 3); err != nil {
t.Errorf("error was not expected while updating stats: %s", err)
}
// we make sure that all expectations were met
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("there were unfulfilled expectations: %s", err)
}
}
// a failing test case
func TestShouldRollbackStatUpdatesOnFailure(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("an error '%s' was not expected when opening a stub database connection", err)
}
defer db.Close()
mock.ExpectBegin()
mock.ExpectExec("UPDATE products").WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectExec("INSERT INTO product_viewers").
WithArgs(2, 3).
WillReturnError(fmt.Errorf("some error"))
mock.ExpectRollback()
// now we execute our method
if err = recordStats(db, 2, 3); err == nil {
t.Errorf("was expecting an error, but there was none")
}
// we make sure that all expectations were met
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("there were unfulfilled expectations: %s", err)
}
}
おわりに
以上、Goのユニットテストについての基本の書き方と、HRBrainでも使用している外部パッケージをいくつか紹介させていただきました。少しでも参考になりましたら幸いです。
そしてそしてHRBrainのDevelopmentチームでは、ドメインや市場環境を問わず優位性の高いプロダクトを早く作れるチームを目指して日々開発しており、一緒に働いてくれる仲間を募集しています。