feature image

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

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

この記事は夏のブログリレー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でテストを楽に作るためのツールをインストールしましょう。

  1. ctrl+shift+Pでコマンドパレットを開きます
  2. ボックスにgo install toolsと入力し、Go: install/update toolsを選択します
  3. gotestsにチェックを入れ、インストールします。アップデートする際も同様にこちらから実行できます。
    go install/update tools

(他のツールも有用なので、インストールしていない方は一緒にインストールすることをお勧めします)

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

VSCodeでは先ほどのひな型の生成にcweill/gotestsを使っています。cweill/gotestsのREADMEに記載されている通り、gotestsでは先ほどの単純なテストだけではなく、並列実行に対応したり(-parallel)、他のテンプレートを使用した(-template)テストを作成することが可能になります。
(開発が滞っていることもあるのか、なぜか-parallel-template=testifyが同時に動作してくれませんでした)

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をつけて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

注意点

狭い需要ですので、基本的には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さん!楽しみ~~

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

20B。アライグマです。

この記事をシェア

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

関連する記事

2021年8月12日
CPCTFを支えたWebshell
mazrean icon mazrean
2021年5月19日
CPCTF2021を実現させたスコアサーバー
xxpoxx icon xxpoxx
2022年9月26日
競プロしかシラン人間が web アプリ QK Judge を作った話
tqk icon tqk
2022年9月16日
5日でゲームを作った #tararira
Komichi icon Komichi
2024年3月15日
個人開発として2週間でWebサービスを作ってみた話 〜「LABEL」の紹介〜
Natsuki icon Natsuki
2023年10月20日
DIGI-CON HACKATHON 参加記事「Comic DoQ」
mehm8128 icon mehm8128
記事一覧 タグ一覧 Google アナリティクスについて 特定商取引法に基づく表記