📝

快適なテストコード記述ライフを実現する、gotestsを拡張したツール"tgen"について

2022/12/02に公開

はじめまして、普段はゲームのバックエンドの開発を行っている新卒のかずです。

この記事はGo Advent Calender 2022の2日目の記事とApplibot Advent Calenderの 2日目の記事です。

この記事は、私が普段の開発にいて快適なテストコード記述ライフを実現するために、開発したテストコード自動生成ツールtgenについての諸々の紹介記事です。
※全てのケースでうまく動作するとは限りませんのでご注意ください

紹介するツールのリポジトリを以下に載せておきます。
https://github.com/kazdevl/tgen

TL;DR

  • goのテストコードの自動生成ではgotestsというツールが有名
  • interfaceで依存注入を行っている構造体のメソッドのテストではモックを利用する
  • gotestsではモックの定義やテストケースの部分までは自動生成してくれない
  • そこでgotestsのオプション+ASTと型情報を活用して、上記を解決するツールtgenを作成した
  • tgenでは以下の形式のようなテストコードを自動生成してくれます
func TestSampleService_UpdateToRandomName(t *testing.T) {
	type fields struct {
		SampleRepository func(ctrl *gomock.Controller) repository.IFSampleRepository
		SampleClient     func(ctrl *gomock.Controller) thirdparty.IFSampleClient
	}
	type args struct {
		i int
	}
	tests := []struct {
		name    string
		fields  fields
		args    args
		wantErr error
	}{
		{
			name: "異常: 29行目のif文",
			fields: fields{
				SampleClient: func(ctrl *gomock.Controller) IFSampleClient {
					mock := thirdparty.NewMockIFSampleClient(ctrl)
					// TODO embed expected args and return values
					mock.EXPECT().GenrateRandomName().Return(nil)
					return mock
				},
				SampleRepository: func(ctrl *gomock.Controller) IFSampleRepository {
					mock := repository.NewMockIFSampleRepository(ctrl)
					// TODO embed expected args and return values
					mock.EXPECT().GetLastSaveTime(nil).Return(nil)
					return mock
				},
			},
		},
		{
			name: "正常",
			fields: fields{
				SampleClient: func(ctrl *gomock.Controller) IFSampleClient {
					mock := thirdparty.NewMockIFSampleClient(ctrl)
					// TODO embed expected args and return values
					mock.EXPECT().GenrateRandomName().Return(nil)
					return mock
				},
				SampleRepository: func(ctrl *gomock.Controller) IFSampleRepository {
					mock := repository.NewMockIFSampleRepository(ctrl)
					// TODO embed expected args and return values
					mock.EXPECT().GetLastSaveTime(nil).Return(nil)
					mock.EXPECT().Update(nil, nil).Return(nil)
					return mock
				},
			},
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			mockCtrl := gomock.NewController(t)
			defer mockCtrl.Finish()
			s := &SampleService{
				SampleRepository: tt.fields.SampleRepository(ctrl),
				SampleClient:     tt.fields.SampleClient(ctrl),
			}
			assert.True(t, errors.Is(tt.wantErr, s.UpdateToRandomName(tt.args.i)))
		})
	}
}

なぜtgenを作ったのか

皆さんが業務でプロダクトのコードを書く場合、動作を確認・保証をするためにテストを記述していると思います。
特に新しい機能の開発の場合だと、0からテストのコードを書くことでしょう。
もちろん人によると思いますが、私の場合は新しい機能の開発を行う際には、機能開発の時間3~5割・テストの記述時間3割・動作確認の時間2割、と1/3近くの時間をテストの記述に費やしていることが多いです。

プロダクトにおける機能を各役割ごとに層分けし、interfaceを活用して疎結合にしている場合、それらのメソッドのテストを記述する際には、依存部分の注入にモックを利用することで疎結合にテストできるようにしていることでしょう。

その際には、以下の例のようなモックの定義やモックを使用するためのコードを記載していることが多いと思います。

// sample_test.go
ctrl := gomock.NewController(t)
defer ctrl.Finish()

mock := repository.NewMockSampleRepository(ctrl)
mock.EXPECT().Get(1).Return("sample", nil)
mock.EXPECT().Save(1, "sample_hoge").Return(nil)

targetService := &TargetService{
	SampleRepository: mock,
}

※ goでモックを扱うとなった際にはgomockがメジャーだと思いますので、gomockを例にだしています。
※ gomock: https://github.com/golang/mock

もし以下のように、TargetService構造体が大量の依存関係を持っている場合、必要となるモックも多くなってしまい、それらを記述するだけでテストの記述が面倒になります。

// sample_test.go
ctrl := gomock.NewController(t)
defer ctrl.Finish()

mockOne := repository.NewMockSampleOneRepository(ctrl)
mockTwo := repository.NewMockSampleTwoRepository(ctrl)
mockThree := repository.NewMockSampleThreeRepository(ctrl)
....

mockOne.EXPECT().Get(1).Return("sample1", nil)
mockOne.EXPECT().Save(1, "sample_hoge1").Return(nil)
mockTwo.EXPECT().Get(2).Return("sample2", nil)
mockTwo.EXPECT().Save(2, "sample_hoge2").Return(nil)
mockThree.EXPECT().Get(3).Return("sample3", nil)
mockThree.EXPECT().Save(3, "sample_hoge3").Return(nil)
....

targetService := &TargetService{
	SampleOneRepository: mockOne,
	SampleTwoRepository: mockTwo,
	SampleThreeRepository: mockThree,
	....
}

※上記は例であり、普段の開発ではTable-driven Test形式のちゃんとしたテストコードを書いています。

また、プロダクトの機能のテストを行う際には色々なケースを網羅したテストが求められると思います(いわゆるカバレッジの高さですね)。
特に、以下の決済処理のようなプロダクトでも重要度が高い機能は、各条件が意図通り動作しているかはちゃんとテストするでしょう。

// sample.go
// 決済処理
func (s *PaymentService) ExecPayment(userId, charge int64) (int64, error) {
	user, err := s.userRepository.Get(userId)
	if err != nil {
		return 0, error
	}
	// 破産しているユーザか
	if user.IsBankruptcy() {
		return 0, errors.New("破産しているユーザは決済できません")
	}
	// 支払うことが可能か
	if user.CanPayment(charge) {
		return 0, errors.New("所持料金を超えていて支払いできません")
	}
	remainingAmount, err := user.Payment(charge)
	if err != nil {
		return err
	}
	return remainingAmount, s.userRepository.Save(user)
}

紹介したようなモックの定義やテストケースを網羅しようとすると、必然的に大量のコードを書く必要があり、とても面倒で大変です。読者の皆様でも、そういった大変さを感じている人は少なくないと思います。
そんな大変さをなくし、テストにかける時間を少しでも短縮したいという強烈な思いこそが、tgenを作るに至った動機です。

gotestsについて

goにはgotestsというgoのファイルからテストコードを自動生成するツールが存在します。
知名度も高く、githubのstar数は4千を超えています。
※ gotestsのリポジトリ: https://github.com/cweill/gotests

gotestsによるコードの自動生成では、Table-driven Testの形式でテストのコードが自動生成されます。
例として以下にテスト対象のコードと自動生成コードを記載します。
※ Table-driven Testとは: https://go.dev/blog/subtests

type SampleService struct {
	SampleRepository repository.IFSampleRepository
}

func (s *SampleService) Get(i int) (string, error) {
	return s.SampleRepository.GetName(i)
}

上記のコードに対して自動生成を行うと以下のコードが出力されます。

func TestSampleService_Get(t *testing.T) {
	type fields struct {
		SampleRepository repository.IFSampleRepository
	}
	type args struct {
		i int
	}
	tests := []struct {
		name    string
		fields  fields
		args    args
		want    string
		wantErr bool
	}{
		// TODO: Add test cases.
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			s := &SampleService{
				SampleRepository: tt.fields.SampleRepository,
				Sample:           tt.fields.Sample,
			}
			got, err := s.Get(tt.args.i)
			if (err != nil) != tt.wantErr {
				t.Errorf("SampleService.Get() error = %v, wantErr %v", err, tt.wantErr)
				return
			}
			if got != tt.want {
				t.Errorf("SampleService.Get() = %v, want %v", got, tt.want)
			}
		})
	}
}

gotestsでは実現できないこと

gotestsではTable-driven Test形式でのテストコードの自動生成は可能ですが、
1. テストケースの作成
2. モック定義の作成

上記は自分自身の手で手動で書く必要があります。
上記の部分はテスト対象のメソッドがファットであり、必要なモックが増えれば増えるほどテストコードの記述にかかる時間が増えていきます。

紹介した課題を解消する"tgen"の使い方

tgenでは内部的にgotestsを利用しているので、$GOPATH/bin配下にgotestsの実行ファイルが存在することが前提です。

tgenを利用する際は以下のステップです。

1. tgenをinstallする

go install github.com/kazdevl/tgen/cmd/tgen@v0.9.1

2. 利用したいプロダクトのリポジトリにテストを自動生成するためのテンプレートファイル一覧の用意

tgen用のテンプレートファイル一覧は、tgenのリポジトリのtemplateディレクトリに用意していますので、それらをそのまま利用してください(独自の設定を追加したい場合は、是非カスタマイズしてください)。
※用意しているテンプレートでは、interfaceとそのモックが同じパッケージに存在する想定にしています
※私もプロダクトで利用するために、カスタマイズしてます。

3. 実行時にオプションとファイルを設定する

tgenではテストの自動生成時にオプションを設定できます。
以下は、主に利用するであろうオプション一覧です。

-template_dir : 用意したテンプレートへのパス(デフォルトはtemplate)
-only         : 正規表現で指定した関数のみテストコードを生成する
-excl         : 正規表現で指定した関数を除く全ての関数のテストコードを生成する
-parallel     : 並行実行付きのテストコードを生成する

生成したい内容に応じて、オプションの指定をしてください。
そして、対象のテストファイルを指定してください。(複数設定可能)

tgen create -excl="New.*" pkg/service/sample.go

上記では"New任意の文字列"を除く関数やメソッドのテストコードを自動生成します。

上記の結果、以下のような形式のテストコードが自動で生成されます。

func TestSampleService_UpdateToRandomName(t *testing.T) {
	type fields struct {
		SampleRepository func(ctrl *gomock.Controller) repository.IFSampleRepository
		SampleClient     func(ctrl *gomock.Controller) thirdparty.IFSampleClient
	}
	type args struct {
		i int
	}
	tests := []struct {
		name    string
		fields  fields
		args    args
		wantErr error
	}{
		{
			name: "異常: 29行目のif文",
			fields: fields{
				SampleClient: func(ctrl *gomock.Controller) IFSampleClient {
					mock := thirdparty.NewMockIFSampleClient(ctrl)
					// TODO embed expected args and return values
					mock.EXPECT().GenrateRandomName().Return(nil)
					return mock
				},
				SampleRepository: func(ctrl *gomock.Controller) IFSampleRepository {
					mock := repository.NewMockIFSampleRepository(ctrl)
					// TODO embed expected args and return values
					mock.EXPECT().GetLastSaveTime(nil).Return(nil)
					return mock
				},
			},
		},
		{
			name: "正常",
			fields: fields{
				SampleClient: func(ctrl *gomock.Controller) IFSampleClient {
					mock := thirdparty.NewMockIFSampleClient(ctrl)
					// TODO embed expected args and return values
					mock.EXPECT().GenrateRandomName().Return(nil)
					return mock
				},
				SampleRepository: func(ctrl *gomock.Controller) IFSampleRepository {
					mock := repository.NewMockIFSampleRepository(ctrl)
					// TODO embed expected args and return values
					mock.EXPECT().GetLastSaveTime(nil).Return(nil)
					mock.EXPECT().Update(nil, nil).Return(nil)
					return mock
				},
			},
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			mockCtrl := gomock.NewController(t)
			defer mockCtrl.Finish()
			s := &SampleService{
				SampleRepository: tt.fields.SampleRepository(ctrl),
				SampleClient:     tt.fields.SampleClient(ctrl),
			}
			assert.True(t, errors.Is(tt.wantErr, s.UpdateToRandomName(tt.args.i)))
		})
	}
}

その他、tgenの詳細な説明は以下のドキュメントをお読み下さい!
tgenの制約や未対応内容についても記載しています。
https://github.com/kazdevl/tgen/blob/main/README.md

どのように実装をしたのか

技術的アプローチとその理由

tgenではgotestsを内部的に呼び出し、以下のオプションを活用しています。

  • template_params_file
    • テンプレートに追加のパラメータを渡せるようになる
    • 上記のパラメータを記述したjsonファイルへのパスを定義する
  • template_dir
    • カスタマイズしたテンプレートを使えるようにする
    • テンプレートファイルを含むディレクトリへのパスを定義

また、モック定義やテストケースなどのパラメータは、テスト対象ファイルのASTと型情報を抽出し、適切な形に変形することで取得しています。
以下はtgenの概要図をです。
tgenの概要図

gotestsを直接カスタマイズせずに、オプションを利用して拡張する形でアプローチした理由としては以下の通りです。

  • gotestsをカスタマイズするための内部実装の詳細把握が時間がかかるし面倒
  • astパッケージへの理解度を深めたかった

内部の主要ロジックのチラ見せ

※ より詳細を把握したい方は、直接コードを読んでいただいたほうが良いです。

ASTからモック化するインスタンスのメソッド情報の抽出

// https://github.com/kazdevl/tgen/blob/main/internal/parser.goより抜粋

// src = s.SampleClient.Sample()
// targetAbbreviationName = s
// fieldMap = "SampleClient": &FieldInfo{IsInterface: true}
func extractMockMethodFromCallExpr(src *ast.CallExpr, targetAbbreviationName string, fieldMap map[string]*FieldInfo) (*MockMethod, bool) {
	// selectorExprはSel.NameにSampleを持つ(メソッド名の取得)
	selectorExpr, ok := src.Fun.(*ast.SelectorExpr)
	if !ok {
		return nil, false
	}
	// selectorExprForFieldはSel.NameにSampleClientを持つ(フィールド名の取得)
	selectorExprForField, ok := selectorExpr.X.(*ast.SelectorExpr)
	if !ok {
		return nil, false
	}
	// identはNameにsを持つ(レシーバー名の取得)
	ident, ok := selectorExprForField.X.(*ast.Ident)
	if !ok {
		return nil, false
	}
	if targetAbbreviationName != ident.Name {
		return nil, false
	}
	fieldInfo, ok := fieldMap[selectorExprForField.Sel.Name]
	if !ok {
		return nil, false
	}
	if !fieldInfo.IsInterface {
		return nil, false
	}
	return &MockMethod{
		Field:    selectorExprForField.Sel.Name, // SampleClient
		Name:     selectorExpr.Sel.Name,         // Sample
		Position: src.Pos(),                     // srcのAST上での位置
		ArgLen:   len(src.Args),                 // メソッドの引数の数
	}, true
}

テスト対象のメソッドを持つ構造体のフィールド情報の抽出

参考にした記事: https://junchang1031.hatenablog.com/entry/2021/12/22/193522
上記の記事を参考に構造体の型情報を取得できるようにしました。

// 構造体のフィールド情報の抽出の流れの抜粋
import 	"golang.org/x/tools/go/packages"

cfg := &packages.Config{
	// 上記の記事にあるpackages.LoadAllSyntaxはgo1.19.2だと非推奨になっているので使用しない
	Mode: packages.NeedImports | packages.NeedTypes,
}
pkgs, err := packages.Load(cfg, src)
if err != nil {
	return nil, err
}
structObj := pkgs[0].Types.Scope().Lookup("構造体名")
structUnderLyingType, _ := structObj.Type().Underlying().(*types.Struct)
fieldMap = make(map[string]*FieldInfo, structUnderLyingType.NumFields())
for i := 0; i < structUnderLyingType.NumFields(); i++ {
	field := structUnderLyingType.Field(i)
	typeName := field.Type().String()

	fieldMap[field.Name()] = &FieldInfo{
		IsInterface:            strings.Contains(field.Type().Underlying().String(), "interface{"),
		TypeName:               typeName,
	}
}

※ 上記のコードは実際には関数分け・nilチェック・型名の抽出・パッケージ名の抽出・テンプレート用のパラメータ値作成などをしていますが、紹介のためまとめたり、省いたりしています。

vscode * tgenで圧倒的な快適さを 無念....

tgenは一度リファクタリングを行っており、リファクタリング以前では、色々とvscodeの設定をいじることで、従来のgotestsのようにエディタの操作だけでテストコードの自動生成を可能にすることができていました。

今回の記事で、その行い方を記載するためにも、修正を頑張ってみたのですが、残念なことにアドカレの投稿日時的に間に合わせることができませんでした。

あまりに無念なので、vscode対応ができたら、別記事で紹介したいと思います!

終わりに

本記事では、業務で手間になるテストコードの記述を快適にしてくれるtgenとその実装方法について紹介をしました。現状で、モック定義とテストケースまで自動生成するツールは存在していないという認識です。

著者もプロダクトでのテストコードを記載する際に利用していますが、テストコードにおける大部分が自動で生成されているので、テストにかかる時間も短縮できて、かなり有効に活用できています。

今後プロダクトの開発時に、gotestsを拡張したい・モック定義やテストケースも自動生成したいとなった際に、本記事とtegnのソースコードが参考になれば幸いです。

tgenは作り始めたばかりであり、考慮できていないところもたくさんあると思います。バグ報告や機能改善要望などは是非GitHubで気軽にissueを投げてください。starも頂ければ大きな活力になります。

Go Advent Calender 2022の3日目はhelloyuki_さんの記事です。
そして、Applibot Advent Calender の3日目はFirstSSさんの記事です。
お楽しみに!

Discussion