この記事は夏のブログリレー2021 49日目の記事です。
目次
0. はじめに
こんにちは。20BのRasと申します。はじめましての方は過去の記事をご覧ください。
現在、traPortfolioというプロジェクトでバックエンドを担当しています。このプロジェクトでテストを書く機会があったのでGoのテストについて少し調べました。
なお今回はtesting.T
を使うようなテストについてのみ解説します。
testing.B
を使うベンチマークなどの解説は行いません。
1. Goのテストについて
ここではGoのテストに触れたことがない方のために少しだけ解説します。
すでに書いたことがある方は飛ばしてもらって大丈夫です。
Goでは標準機能のみでテストを行うことができ、go test <対象ファイル名>
でテストを実行することができます。
ちなみに、テストは証明のように一般的に正しいことを示すものではありません(テストの書き方を知るまではこう思っていました)。実際はいくつかの具体的なケースについてそれぞれが正しいことを示すものです。つまり、既に書かれたケースでテストが通ったとしても、対象の関数の動作を全て検証したとは限らない点に注意です。
後述: Coqといった証明プログラミングもあるようです。気になった方は調べてみてください。
例を見てみます。調べると同じようなのがたくさん出てきます。n番煎じです...
ちなみに、Goでテストを行う場合は関数名をTest~~
、引数をt *testing.T
、ファイル名を~~_test.go
にする必要があります。
go mod init <モジュール名、何でもいいです>
でgo.modを作成します。本記事でのモジュール名はtest_trap
とします。
// main.go
package main
func Add(a, b int) int {
return a + b
}
// main_test.go
package main
import "testing"
func TestAdd1(t *testing.T) {
got := Add(1, 2)
if got != 3 {
t.Errorf("Add() = %v, want %v", got, 3)
}
}
$ go test ./...
ok test_trap 0.001s
1+2が3であることを示しています。
しかし、上記のコードはAdd関数の特別な場合を示しただけであり、一般的に正しいことを示してはいません。この問題を改善する方法は後述の「小噺」に書きます。
なお、Go公式ではTable Driven Testというテストの形式を推奨しています。以下のようにケースをまとめて書く形式です。
// main_test.go
package main
import "testing"
func TestAdd2(t *testing.T) {
type args struct {
a int
b int
}
tests := []struct {
name string
args args
want int
}{
{"1+2", args{1, 2}, 3},
{"2+3", args{2, 3}, 5},
{"3+4", args{3, 4}, 7},
{"4+5", args{4, 5}, 9},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := Add(test.args.a, test.args.b); got != test.want {
t.Errorf("Add() = %v, want %v", got, test.want)
}
})
}
}
t.Run
を実行することでTestAdd2
関数の中でテストケースに名前を付けてサブテストを実行することができます。
サブテストを用いると、テストがFailしたときにどのケースがFailしたのか見やすくなります。
上記コードではテストケースを4つに増やしました。TestAdd1
関数ではテストケースを4つに増やすと関数の数も4倍に増えますが、Table Driven Testではテストケース1つにつき今回は1行しか増えません。
なお、本記事では以降もTable Driven Testを使用します。
小噺🤫
1+2=3のように具体的なケースを1つずつテストすると抜けが発生しやすくなります。
ちょっとした対処法として乱数を使う方法があります。
完全解決とはいきませんが、前者よりはバグを発見しやすくなります。
以下ではが成り立つことを乱数を使ってテストしています。
// main.go
package main
// 1からnまでの和を計算します
func Sum(n int) int {
return n * (1 + n) / 2
}
// main_test.go
package main
import (
"math/rand"
"testing"
)
func TestSum(t *testing.T) {
n := rand.Intn(10000)
if simpleSum(n) != Sum(n) {
t.Errorf("Sum(%d) = %d, want %d", n, Sum(n), simpleSum(n))
}
}
// 1からnまでの和を愚直に計算する
func simpleSum(n int) int {
sum := 0
for i := 1; i <= n; i++ {
sum += i
}
return sum
}
2. VSCodeでGoのテスト環境を整える
テストの説明が終わり、いよいよ本題です。VSCodeで環境を整えていきます。
といっても先ほど説明したようにGoには標準機能のみでテストを行うことができるため、公式の拡張機能をインストールすればまずは十分です。
下の画像のようにrun file tests
などを1クリックで行うことができます。
先ほどTable Driven Testを紹介しましたが、これを全て手で書くのはかなり骨の折れる作業です。
そこで、次はGoでテストを楽に作るためのツールをインストールしましょう。
ctrl+shift+P
でコマンドパレットを開きます- ボックスに
go install tools
と入力し、Go: install/update tools
を選択します gotests
にチェックを入れ、インストールします。アップデートする際も同様にこちらから実行できます。
(他のツールも有用なので、インストールしていない方は一緒にインストールすることをお勧めします)
さて、gotestsをインストールしたところでテストを作成します。
テストしたいファイル(ここではmain.go
)を開き、ctrl+shift+P
または右クリックからGo: Generate Unit Tests for File
を選択します。以下のようなテストファイルが生成されたはずです。
※既に対象のテスト関数が定義されている場合はテスト関数は新しく作成されません。テスト関数を作り直す場合は一度対象部分を消去する必要があります。
// main.go
package main
func Add(a, b int) int {
return a + b
}
// main_test.go
package main
import "testing"
func TestAdd(t *testing.T) {
type args struct {
a int
b int
}
tests := []struct {
name string
args args
want int
}{
// TODO: Add test cases.
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := Add(tt.args.a, tt.args.b); got != tt.want {
t.Errorf("Add(%v, %v) = %v, want %v", tt.args.a, tt.args.b, got, tt.want)
}
})
}
}
テストの雛型はほとんどgotestsが作ってくれました。感謝。
私たちが手で書くのは// TODO: Add test cases
の部分のみです。
以上、単純なテストを簡単に作成する方法を紹介しました。
小噺🤫
VSCodeにはGo: Generate Unit Tests for File
以外にもいくつかテスト用の機能が搭載されています。
例えばGo: Generate Unit Tests for Function
はファイル全体ではなく、現在カーソルが当たっている関数のテストのみを作成してくれます。
また、Go: Toggle Test Coverage In Current Package
では関数のどの部分がテストによりカバーされているか確認することができます。
gotestsでテストを作成し、テストケースを追加せずにmain.go
にてGo: Toggle Test Coverage In Current Package
を実行すると、以下のようにreturn a + b
の部分が赤く表示されます。これは、この部分がテストによってカバーされていないことを表しています。
次に、テストケースを追加してみます。TODOの下の行にテストケース
{
name: "1+2",
args: args{1, 2},
want: 3,
},
を追加します。その後再びmain.go
からGo: Toggle Test Coverage In Current Package
を実行すると、先ほどは赤かったところが青くなっているのが確認できると思います。つまり、ファイルの赤い部分がなくなればファイル全体がカバーされているということになります。
なお、これらのメニューの表示/非表示は設定のGo: Editor Context Menu Commands
から変更することができます。
3. 【ちょっと発展】gotestsをカスタマイズする
gotestsをもっとくわしく
VSCodeでは先ほどのひな型の生成にcweill/gotestsを使っています。cweill/gotestsのREADMEに記載されている通り、gotestsでは先ほどの単純なテストだけではなく、並列実行に対応したり(-parallel
)、他のテンプレートを使用した(-template
)テストを作成することが可能になります。
(開発が滞っていることもあるのか、なぜか-parallel
と-template=testify
が同時に動作してくれませんでした)
traPtitech/traPortfolioで実例を見てみます。traPortfolioではClean Architectureに準じてユニットテストが作りやすいような構成になっています。今回は、例としてusecases/service/user.go
のGetUsers
というメソッドのテストを作成します。ここからはコードは流し読みしてもらえれば十分です。
まず、元のファイルはこんな感じ(GetUsers
以下は省略しています)。
package service
import (
"context"
"github.com/gofrs/uuid"
"github.com/traPtitech/traPortfolio/domain"
"github.com/traPtitech/traPortfolio/usecases/repository"
)
type UserService struct {
repo repository.UserRepository
event repository.EventRepository
}
func NewUserService(userRepository repository.UserRepository, eventRepository repository.EventRepository) UserService {
return UserService{repo: userRepository, event: eventRepository}
}
func (s *UserService) GetUsers(ctx context.Context) ([]*domain.User, error) {
users, err := s.repo.GetUsers()
if err != nil {
return nil, err
}
return users, nil
}
gotestsでこのメソッドのテストを作成すると以下のようになります。
package service
import (
"context"
"reflect"
"testing"
"github.com/traPtitech/traPortfolio/domain"
"github.com/traPtitech/traPortfolio/usecases/repository"
)
func TestUserService_GetUsers(t *testing.T) {
type fields struct {
repo repository.UserRepository
event repository.EventRepository
}
type args struct {
ctx context.Context
}
tests := []struct {
name string
fields fields
args args
want []*domain.User
wantErr bool
}{
// TODO: Add test cases.
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := &UserService{
repo: tt.fields.repo,
event: tt.fields.event,
}
got, err := s.GetUsers(tt.args.ctx)
if (err != nil) != tt.wantErr {
t.Errorf("UserService.GetUsers() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("UserService.GetUsers() = %v, want %v", got, tt.want)
}
})
}
}
関数だけではなくメソッドのテストもいい感じに作ってくれるのが偉いですね。
testify/assertを使う
-template testify
をつけてgotestsを実行すると、github.com/stretchr/testify/assert を使ってエラーハンドリングがシンプルなテンプレートに切り替えてくれます。
Go: Generate Tests Flags
にこれらを追加して実行します。差分を見るとシンプルになっているのが分かると思います。
package service
import (
"context"
- "reflect"
"testing"
+ "github.com/stretchr/testify/assert"
"github.com/traPtitech/traPortfolio/domain"
"github.com/traPtitech/traPortfolio/usecases/repository"
)
func TestUserService_GetUsers(t *testing.T) {
ctx context.Context
}
tests := []struct {
- name string
- fields fields
- args args
- want []*domain.User
- wantErr bool
+ name string
+ fields fields
+ args args
+ want []*domain.User
+ assertion assert.ErrorAssertionFunc
}{
// TODO: Add test cases.
}
func TestUserService_GetUsers(t *testing.T) {
event: tt.fields.event,
}
got, err := s.GetUsers(tt.args.ctx)
- if (err != nil) != tt.wantErr {
- t.Errorf("UserService.GetUsers() error = %v, wantErr %v", err, tt.wantErr)
- return
- }
- if !reflect.DeepEqual(got, tt.want) {
- t.Errorf("UserService.GetUsers() = %v, want %v", got, tt.want)
- }
+ tt.assertion(t, err)
+ assert.Equal(t, tt.want, got)
})
}
}
t.Parallel
で並列化する
テスト内の関数にt.Parallel()
を付けることでできます。
詳しい説明は以下の記事などをご覧ください。
gotests標準では、サブテストの並列化は実装されていますが、トップレベルテストの並列化は実装されていません。そこで、自分でテンプレートを作って実装してみます。
実装には以下の記事を参考にしました。
gotestsには-template_dir
というオプションで自分で作ったテンプレートファイルを指定することができます。
僕のはこれ。(ここではGoのテンプレートについて詳しく理解する必要はないです)
作っていきます。
gotests標準のテンプレートからfunction.tmpl
以外を手元にコピーし、function.tmpl
だけはtestifyを使ったテンプレートをコピーします。
最後に、function.tmpl
に以下の一行を追加すれば-parallel
でトップレベルテストの並列化が可能になります。
func {{.TestName}}(t *testing.T) {
+ {{- if .Parallel}}t.Parallel(){{end}}
事前処理を追加する
実はこのテスト、細かい設定ができないためテストの自由度があまり高くありません。
ケースごとに違った処理が書きたいこともあります。
そこで、function.tmpl
に以下を追記します。
{{- if .TestParameters}}
args args
{{- end}}
{{- range .TestResults}}
{{Want .}} {{.Type}}
{{- end}}
+ setup func(
+ {{- with .Receiver}}{{if and .IsStruct .Fields}}f fields, {{end}}{{end}}
+ {{- if .TestParameters}}args args, {{end}}
+ {{- range .TestResults}}{{Want .}} {{.Type}}, {{end -}}
+ )
{{- if .ReturnsError}}
assertion assert.ErrorAssertionFunc
{{- end}}
}{
// TODO: Add test cases.
}
for {{if (or .Subtests (not .IsNaked))}} {{if .Named}}name{{else}}_{{end}}, tt := {{end}} range tests {
{{- if .Subtests}}
{{- if .Parallel}}tt := tt;{{end}}
{{- if and .Parallel .Named}}name := name;{{ end }}
t.Run({{if .Named}}name{{else}}tt.name{{end}}, func(t *testing.T) {
{{- if .Parallel}}t.Parallel(){{end}}
{{- end}}
{{- with .Receiver}}
{{- if .IsStruct}}
{{Receiver .}} := {{if .Type.IsStar}}&{{end}}{{.Type.Value}}{
{{- range .Fields}}
{{.Name}}: tt.fields.{{Field .}},
{{- end}}
}
{{- end}}
+ tt.setup({{if .Fields}}tt.fields,{{end}}{{- if $f.TestParameters}}tt.args,{{end}}{{- range $f.TestResults}}tt.{{Want .}},{{end}})
{{- end}}
これによりテストケースごとに初期化処理を追加することができます。
package service
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/traPtitech/traPortfolio/domain"
"github.com/traPtitech/traPortfolio/usecases/repository"
)
func TestUserService_GetUsers(t *testing.T) {
t.Parallel()
type fields struct {
repo repository.UserRepository
event repository.EventRepository
}
type args struct {
ctx context.Context
}
tests := []struct {
name string
fields fields
args args
want []*domain.User
+ setup func(f fields, args args, want []*domain.User)
assertion assert.ErrorAssertionFunc
}{
// TODO: Add test cases.
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
s := &UserService{
repo: tt.fields.repo,
event: tt.fields.event,
}
+ tt.setup(tt.fields, tt.args, tt.want)
got, err := s.GetUsers(tt.args.ctx)
tt.assertion(t, err)
assert.Equal(t, tt.want, got)
})
}
}
さらにいろいろ改変したテンプレートがRas96/gotests-templateです。ご自由にお使いください。
もっと
ここではcweill/gotestsでは機能が不十分なためレポジトリをforkしてRas96/gotests を作成した話をします。
インストール方法
cweill/gotestsと同じですが、cweill/gotestsが残っているとRas96/gotestsがインストールできないため、以下のコマンドでインストールするようにしてください。
$ rm $GOPATH/bin/gotests & go install github.com/Ras96/gotests/...@v1.6.0-ras1
注意点
Go: install/update tools
を実行すると再びcweill/gotestsがインストールされるのでそれも注意。- テストの記事にもかかわらず追加機能についてはテストをサボっているため、あくまでも自己責任でお願いします。
狭い需要ですので、基本的にはRas96/gotests-templateを使っていただければ十分だと思います。
golang/mockのようなモックを用いてテストを行いたい場合があります。要するに、
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
// Setup mock
ctrl := gomock.NewController(t)
tt.fields = fields{
repo: mock_repository.NewMockUserRepository(ctrl),
event: mock_repository.NewMockEventRepository(ctrl),
}
...
みたいなことを自動で生成してくれるようにしたいのですが、これをうまく実装するには標準のgotestsの機能では厳しく、かなり苦戦しました。
実装にはrepository.UserRepository
という文字列をmock_repository.NewMockUserRepository
に変換する必要があります。
しかし、Goのテンプレートは標準では機能が非常に少ないため、このような多少複雑な文字列処理は標準ではできません。
Masterminds/sprigのようにテンプレートに関数を提供するツールも存在しますが、これを組み込むにはgotests自体のコードを改変する必要があります。
gotestsでもsprigを使おう!なissueが立っています(一年以上放置されていますが...)。
どうしても使いたかったので、forkして自前で実装します。
これで正規表現やら文字列操作やらができるようになりました。やったね!
明日の担当者は@temmaさん!楽しみ~~