feature image

2021年9月25日 | ブログ記事

VSCodeで手を抜いてGoのテストを手を抜かずに書く

この記事は夏のブログリレー2021 49日目の記事です。

目次

0. はじめに

こんにちは。20BのRasと申します。はじめましての方は過去の記事をご覧ください。

現在、traPortfolioというプロジェクトでバックエンドを担当しています。このプロジェクトでテストを書く機会があったのでGoのテストについて少し調べました。

なお今回はtesting.Tを使うようなテストについてのみ解説し、testing.Bを使うベンチマークなどの解説は行いません。

1. Goのテストについて

ここではGoのテストに触れたことがない方のために少しだけ解説します。すでに書いたことがある方は飛ばしてもらって大丈夫です。

Goには標準でテストを行うパッケージが入っており、go test ./...のようなコマンドでテストを実行することができます。

僕個人の経験ですが、テストを書く前まではテストは証明のように一般的に正しいことを示すものだと思っていたのですが、実際はそうではなくいくつかの具体的なパターンについてそれが正しいことを示すものでした。つまり、そのいくつかのパターンについてテストが通ったとしても、テストした関数の中身すべてがカバーされたとは限らない点に注意です。

例を見てみます。「golang test」などで検索をかけるとだいたいこんな感じのやつなので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のように特定のケースのみを試すのでは抜けが発生しやすいですが、math/randパッケージで乱数を使うことによりバグを発見しやすく、また起こしにくくなります。
以下はが成り立つことを乱数を使ってテストしています。

// 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には標準でテストコマンドが搭載されているため、基本的には公式の拡張機能を入れるだけで終わります。これを入れると、下のようにgoファイルから直接テストを行うことができます。
ファイルから直接テスト

先ほどTable Driven Testを紹介しましたが、すべてこれを書くのはかなり骨の折れる作業です。ということで次はGoでテストを楽に作るためのツールをインストールします。ctrl+shift+Pでコマンドパレットを開き、ボックスにgo install toolsと入れてGo: install/update toolsをクリックします。すると下のようにツールがいくつか出てくると思うので、gotestsにチェックを入れ、インストールします(他のツールも有用なので、インストールしていない方は一緒にインストールすることをお勧めします)。アップデートする際も同様にこちらから実行できます。
go install/update tools

さて、gotestsを入れたところでテストを作成します。先ほど作ったmain.goを開き、ctrl+shift+Pまたは右クリックからGo: Generate Unit Tests for Fileを選択します。これを実行すると、以下のようなテストファイルが一瞬で作成されます。すごい。
※すでにmain.go内の関数のテスト関数が実装されている場合は新しく作成されないため、一度main_test.goを消去するとうまくいくかもしれません

// 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の部分が赤く表示されます。これは、この部分がテストによってカバーされていないことを表しています。
----------2021-09-25-031740

次に、テストケースを追加してみます。TODOの下の行にテストケース

{
	name: "1+2",
	args: args{1, 2},
	want: 3,
},

を追加します。その後再びmain.goからGo: Toggle Test Coverage In Current Packageを実行すると、先ほどは赤かったところが青くなっているのが確認できると思います。つまり、ファイルの赤い部分がなくなればファイル全体がカバーされているということになります。
----------2021-09-25-032302

なお、右クリックから開けるこれらのメニューの表示/非表示は設定のGo: Editor Context Menu Commandsから変更することができます。ぜひ自分が使いやすいものにカスタマイズしてみてください。

3. 【ちょっと発展】gotestsをカスタマイズする

gotestsをもっとくわしく

先ほどから紹介しているgotestsですが、cweill/gotestsを使っています。READMEに記載されている通り、gotestsでは先ほどの単純なテストだけではなく、並列実行に対応したり(-parallel)、他のテンプレートを使用した(-template)テストを作成することが可能になります。
(開発が滞っていることもあるのか、なぜか-parallel-template=testifyが同時に動作してくれませんでした)
なお、VSCodeではGo: Generate Tests Flagsにオプションを追加することでこれらのオプションをつけてテストファイルを作成することができます。

traPtitech/traPortfolioで実例を見てみます。traPortfolioではClean Architectureに準じてユニットテストが作りやすいような構成になっています。今回は、例としてusecases/service/user.goGetUsersというメソッドのテストを作成します。ここからはコードは流し読みしてもらえれば十分です。
まず、元のファイルはこんな感じ(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をつけて実行すると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でせっかく自動作成したのにそこに毎度毎度t.Parallelを追加するのでは本末転倒なので、自分で設定してしまいます。以下の記事を参考にしました。

gotestsには-template_dirというオプションがあり、自分で作ったGoのテンプレートファイルを設定することができます。僕のはこれ。(ここではGoのテンプレートについて詳しく理解する必要はないです)

gotests標準のテンプレートからfunction.tmpl以外を手元にコピーし、function.tmplだけはtestifyを使ったテンプレートをコピーします。
最後に、function.tmplに以下の一行を追加すれば-parallelでトップレベルテストの並列化が可能になります。

func {{.TestName}}(t *testing.T) {
+    {{- if .Parallel}}t.Parallel(){{end}}

事前処理を追加する

実はこのテスト、自由度があまり高くありません。というのも、

tests := []struct {
		name      string
		fields    fields
		args      args
		want      []*domain.User
		assertion assert.ErrorAssertionFunc
	}

というフィールドとforループで共通処理を回しているだからです。テストによってはテストごとに違った処理が書きたいこともあります。そこで、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)
		})
	}
}

もっと

ここまでで、かなりいい感じのテストテンプレートができました。もう少し詰めてみます。ここからはざっくりと。

もしモックなどを使ってテストを行いたい場合はfor分で回される各ttについて、tt.fieldsにモックを詰めたくなります。要するに

	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のテンプレートは標準では機能が非常に少なく、continuebreakも最近1.17でやっと導入されたばかりです。当然標準でこんな複雑な文字列変換ができるわけはないので、Masterminds/sprigのようにテンプレートに関数を提供するツールが使いたくなります。

gotestsでもsprigを使おう!なissueが立っています。

、、、一年以上放置されていますね

どうしても使いたかったので、forkして自前で実装します。

これで正規表現やら文字列操作やらができるようになりました。go1.16以上であれば

    go install github.com/Ras96/gotests/gotests@latest

でcweill/gotestsの代わりにRas96/gotestsを使えるようになるはずです。テストも追加しておらずバグが起こりうるのであくまでも自己責任でお願いします。
※再度Go: install/update toolsを実行すると再びcweill/gotestsが入るのでやや実用性には欠けます。

最終的なテンプレートが先ほども紹介したRas96/gotests-templateです。

最後にこれとRas96/gotestsで作ったテストを紹介してこのブログを終わります。

package service

import (
	"context"
	"testing"

	"github.com/golang/mock/gomock"
	"github.com/stretchr/testify/assert"
	"github.com/traPtitech/traPortfolio/domain"
	"github.com/traPtitech/traPortfolio/usecases/repository"
	"github.com/traPtitech/traPortfolio/usecases/repository/mock_repository"
	"github.com/traPtitech/traPortfolio/util"
)

func TestUserService_GetUsers(t *testing.T) {
	t.Parallel()
	type fields struct {
		repo  *mock_repository.MockUserRepository
		event *mock_repository.MockEventRepository
	}
	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
	}{
		{
			name: "Success",
			args: args{ctx: context.Background()},
			want: []*domain.User{
				{
					ID:       util.UUID(),
					Name:     util.AlphaNumeric(5),
					RealName: util.AlphaNumeric(5),
				},
			},
			setup: func(f fields, args args, want []*domain.User) {
				f.repo.EXPECT().GetUsers().Return(want, nil)
			},
			assertion: assert.NoError,
		},
		{
			name: "Fail_Forbidden",
			args: args{ctx: context.Background()},
			want: nil,
			setup: func(f fields, args args, want []*domain.User) {
				f.repo.EXPECT().GetUsers().Return(want, repository.ErrForbidden)
			},
			assertion: assert.Error,
		},
	}
	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),
			}
			tt.setup(tt.fields, tt.args, tt.want)
			s := NewUserService(tt.fields.repo, tt.fields.event)
			// Assertion
			got, err := s.GetUsers(tt.args.ctx)
			tt.assertion(t, err)
			assert.Equal(t, tt.want, got)
		})
	}
}

いい感じ!!!!!!!!!!

追記 21/10/04

フォークしたリポジトリに機能を追加し、
-template ras96が使えるようになりました。
このオプションを指定するとRas作のテンプレートが使えるようになります。

明日の担当者は@temmaさん!楽しみ~~

Ras icon
この記事を書いた人
Ras

20B。アライグマです。

この記事をシェア

このエントリーをはてなブックマークに追加
共有

関連する記事

2021年8月12日
CPCTFを支えたWebshell
mazrean icon mazrean
2021年5月19日
CPCTF2021を実現させたスコアサーバー
xxpoxx icon xxpoxx
2021年9月21日
ISUCON11 traP CM制作についての小話
dan_dan icon dan_dan
2021年5月16日
CPCTFを支えたインフラ
mazrean icon mazrean
2021年4月2日
traQの検索機能が謎のエラーを吐いた話
toki icon toki
2021年9月8日
五度圏⊃自然音階って…コト!?
kotoki_bis icon kotoki_bis
記事一覧 タグ一覧 Google アナリティクスについて