この記事はNewsPicksアドベントカレンダー2022の21日目の記事です。
この記事の概要
この記事は、新規プロダクト(toBマルチテナントSaaS)の開発に参加しているインターン生がusecaseのユニットテストを実装するタスクに取り組んだ際に、
- どのような実装をしたか
- どのようなレビューをいただいたか
- どのように修正したか
といった部分を徒然なるままに書いた、なんちゃってインターン体験記 (自分語り) です。
AlphaDriveのインターンに参加した際に、どのような形で開発に関わっていけるかのイメージを掴むための一助となれば幸いです。
また、Go及びtestifyを用いたテストコードの書き方の一例として、温かい目で読んでいただければと思います。
自己紹介
2022年8月からAlphaDrive/NewsPicksにてインターンとしてお世話になっております。toshi-bpと申します。経営工学を専攻している大学4年生です(大学院に内部進学する予定です)。
インターンでは、新規プロダクト(toB マルチテナントSaaS)のサーバーサイドの開発をメインに行っております。
前提
- 私が開発に参加しているプロダクトのバックエンドはGoで実装されており、テストの実装に
testify
パッケージを使用しております。
- バックエンドのアーキテクチャには、クリーンアーキテクチャが採用されています。
- 本筋からは少々ずれますが、クリーンアーキテクチャについてのふんわりとした理解があった方が読みやすいです。
- 本記事ではユーザーIDからユーザー情報を取得するユースケースのテストを例にコードのイメージを示しています。
最初の実装
当初は以下の方針で実装を進めていました。
-
Suite
の定義-
Suite
を用いることで、テストスイートを構造体として構築し、setup
,testing
などのメソッドを作成し、go test
でテストを実行できます。 -
Suite
を導入したのは、以下のような背景があったからのようです(実際に導入を行った社員の方に聞いてみました)。-
setup
などの共通処理を各テストケース実行前後の適切なタイミングで実行させることができるため。 - JavaのJUnitと似たような形式のテストをGoでも書けるようになるため。
-
-
- テストケースごとに関数を作成・実装
- うまく動作する場合→
テストしたいユースケース+_OK
- エラーが出る場合→
テストしたいユースケース+hoge_Error or NotFound
- うまく動作する場合→
コードのイメージ
package usecase
import (
"context"
"testing"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/suite"
repositoryMock "mock"
)
// 簡単のため、User型をこちらに記述(本来はmodelパッケージなど書いておくのがスタンダードかもしれません。)
type User struct {
ID int
MailAddress string
}
// テスト用の入力値を定義
var testUserID = 1
var testUserMailAddress = "test@test.com"
// 実行した際に返ってきてほしい値
var expectedUser = &User{
ID: testUserID
MailAddress: testUserMailAddress
}
// suiteの定義
type UserInteractorSuite struct {
suite.Suite
// usecaseにて使用される関数をまとめたinterface
u usecase.IUserUsecase
// repositoryのmock
ur *repositoryMock.MockUserRepository
}
// suiteの設定
func (suite *UserInteractorSuite) SetupSuite(){
suite.ur = repositoryMock.NewMockUserRepository(suite.T())
suite.u = NewUserInteractor(
suite.ur,
)
}
// 全テストの実行
func TestUserInteractorSuite(t *testing.T) {
suite.Run(t, new(UserInteractorSuite))
}
// IDからUserの情報を取得するユースケースがうまく動作した場合
func (suite *UserInteractorSuite) Test_User_OK() {
suite.ur.EXPECT().User(
context.TODO(),
testUserID,
).Return(
expectedUser,
nil,
).Once()
actualUser, err := suite.u.User(
context.TODO(),
testUserID,
)
if suite.Nil(err) {
suite.Equal(expectedUser, actualUser)
}
}
// IDからUserの情報を取得しようとしたが該当ユーザーが見つからなかった場合
func (suite *UserInteractorSuite) Test_User_NotFound() {
suite.ur.EXPECT().User(
context.TODO(),
testUserID,
).Return(
nil,
"Not Found",
).Once()
actualUser, err := suite.u.User(
context.TODO(),
testUserID,
)
if suite.Nil(actualUser) {
suite.ErrorAs(err, &expectedErr)
actualErr := err.(IError)
suite.Equal(
actualErr.Code(),
1,
)
}
}
// 他のエラーが起こるケースについても同様にテストを記述する(ここでは割愛)
※コード内で使用されているtestifyのメソッドの内容については、公式ドキュメントや別記事に詳しい説明が掲載されているため、本記事では割愛します。
テーブル駆動テストの導入
いただいたレビュー内容・それを元にした方針
先で示したようなイメージのコードを実装した際に以下のような内容のレビューが返ってきました。
各関数のテストは、正常値を返す場合もエラーを返す場合も以下の3つで構成されている。
・呼び出すmockのセットアップ
・関数本体の呼び出し
・結果のアサーション
そのため、同じ部分は共通化して、変更部分のみパラメータ化した方がテストケースが増えた際にわざわざ新たな関数を増やすことなく、テストケースの差分に集中できるため、もう少し見やすくなるのではないでしょうか?
このようなテスト記法はGolangのWikiでもTable Driven Testとして紹介されています。
→いただいたレビューを元に、テーブル駆動テストの記法を取り入れたテストの記述・動作確認を行なった上で、デイリーミーティングにて確認を行い、本格的に取り入れるか否かの検討を行なっていく方針で進めることにしました。
テーブル駆動テストとは(簡単に)
ここで簡単に述べておくと、テーブル駆動テストとは、以下の
- テスト名
- (呼び出すmock→今回のケースの場合)
- 入力値
- 期待する出力
といった要素の集合を1つのテストケースとして、そのテストケースの集合について、繰り返し構文などを用いて各テストケースが期待した通りの動作を行うか否かを確認する記法です(間違いがあったら修正します)。
先で示したいただいたレビューにも記載されている通り、Goのwikiにて紹介されています。
テーブル駆動テストについての概要をより詳しく知りたい場合、Goのwikiを参照してください。
修正イメージ
※あくまでサンプルなので、エラーコードをマジックナンバーで記述しています。ご了承ください。また、テストコードの書き方自体は実用Go言語を参考にしております。(具体例)
// import文や変数定義は省略
func (suite *UserInteractorSuite) Test_User() {
// 入力
type args struct {
ctx context.Context
id int
}
// 期待する出力
type want struct {
u User
wantErr bool
errCode int
}
test_args := args{context.TODO(), testUserID}
// テストケース
tests := []struct {
name string
urSetup func(ur *repositoryMock.MockUserRepository)
args args
want want
}{
{
name: "OK",
urSetup: func(ur *repositoryMock.MockUserRepository) {
ur.EXPECT().User(context.TODO(), testUserID).
Return(expectedUser, nil).Once()
},
args: args{
test_args,
},
want: want{
expectedUser, false, 0,
},
},
{
name: "NotFound",
urSetup: func(ur *repositoryMock.MockUserRepository) {
ur.EXPECT().User(context.TODO(), testUserID).
Return(nil, "Not Found").Once()
},
args: args{
test_args,
},
want: want{
nil, true, 1,
},
},
{
name: "FetchError",
urSetup: func(ur *repositoryMock.MockUserRepository) {
ur.EXPECT().User(context.TODO(), testUserID).
Return(nil, "Fetch Error").Once()
},
args: args{
test_args,
},
want: want{
nil, true, 2,
},
},
}
// for文でテストケースを1つずつ回していく
for _, tt := range tests {
suite.Run(tt.name, func() {
if tt.urSetup != nil {
tt.urSetup(suite.ur)
}
actural, err := suite.u.User(
tt.args.ctx,
tt.args.id,
)
if tt.want.wantErr {
suite.NotNil(err)
if suite.ErrorAs(err, &uiExpectedErr) {
actualErr := err.(entity.IError)
suite.Equal(actualErr.Code(), tt.want.errCode)
}
} else {
suite.Nil(err)
suite.Equal(tt.want.u, actual)
}
})
}
}
より読みやすいテストコードの書き方を目指して
デイリーミーティングなどで、実際に開発を進めていく社員の方々から合意を得られたため、テーブル駆動テストの記法を用いてテストを書くことになり、先で示した修正イメージを元にテストコードの修正を行い、再度レビューをお願いしました。
また、先で示したイメージでも、当初のコードより見やすくなったように思われたが、依然としてコードの行数が膨大となってしまうため、テスト名をmapのキーとする形で書き換えを行いました。
なお、mapを用いたテストケースの記述については以下の記事を参考にしました。
再度いただいたレビュー・それに伴う修正
再度レビューをお願いした際に別のエンジニアの方から以下の内容のレビューをいただきました。
argsをtest_argsで固定化してるなら、このmapには必要ないと思われますが、どうでしょうか?
→map内からargs
を取り除き、テストケースを検証するfor文内テストケースの入力部分を書く形に修正。
wantErrとerrCodeの意味合いが重複しているように思われるため、
errCode
のみで十分ではないか?
その上で、const noErr = 0
のように正常値を返す場合のエラーコードを定数とした方が良いのではないか?
→「正常値を返す」ことと、「そのテストにおけるerrCodeは0である」ことは同値であるため、errCode
のみとする形に修正。また、正常値を返す場合のエラーコードを定数とする案を取り入ました。
最終的に以下のようなイメージのコードを実装しました。
最終的なコードのイメージ
// 中略
// 正常値を返す場合のerrCodeを定数として定義
const noErr = 0
func (suite *UserInteractorSuite) Test_User() {
// 入力値は固定であるため、for文内に記述する形に変更→args型が不要に。
type want struct {
u User
errCode int
}
// テストケースをテスト名をkeyとしたmapとする形に変更
tests := map[string]struct {
urSetup func(ur *repositoryMock.MockUserRepository)
want want
}{
"OK": {
urSetup: func(ur *repositoryMock.MockUserRepository) {
ur.EXPECT().User(
context.TODO(), testUserID,
).Return(expectedUser, nil).Once()
},
want: want{
expectedUser, noErr,
},
},
"Not found": {
urSetup: func(ur *repositoryMock.MockUserRepository) {
ur.EXPECT().User(
context.TODO(), testUserID,
).Return(nil, "Not Found").Once()
},
want: want{
nil, 1
},
},
"Fetch Error": {
urSetup: func(ur *repositoryMock.MockUserRepository) {
ur.EXPECT().User(
context.TODO(), testUserID,
).Return(nil, "Fetch Error").Once()
},
want: want{
nil, 2,
},
},
}
for testName, tt := range tests {
suite.Run(testName, func() {
if tt.urSetup != nil {
tt.urSetup(suite.ur)
}
// ここで書くテストケースの入力を行う。
actual, err := suite.u.User(
context.TODO(), testUserID,
)
if tt.want.errCode != noErr {
suite.NotNil(err)
if suite.ErrorAs(err, &expectedErr) {
actualErr := err.(entity.IError)
suite.Equal(tt.want.errCode, actualErr.Code())
}
} else {
suite.Nil(err)
suite.Equal(tt.want.u, actual)
}
})
}
}
当初の実装や、2つ目の実装と比較して、かなりコードがスッキリしたな、と個人的に感じております。
今後に向けて
テーブル駆動テストでユニットテストの実装を進めていき、コード自体もかなりスッキリしてきましたが、まだまだ課題も残っています。
以下に主な課題を示します。
テストの並列化について
- これから先、テストの数は必ず増加(少なくとも減ることはない)していくと思われるため、現在はあまり大きな問題とはなっていませんが、実行時間に関する問題については、どこかのタイミングで直面していかなければならないと考えています。
- 引き続き別のタスクを行いつつ、テスト並列化に関する学習も進めていきたいと考えております。
テーブル駆動テストを導入したことによるSuite
の必要性について
- テーブル駆動テストを導入したことにより、テストの前処理(テストケースの定義)、後処理(各テストケースの実行)が書きやすくなったため、
testing
パッケージのTestMain
を使用してテストを実装していく選択肢も浮上してきた。 - インターンで関わっているプロジェクトのテストは、基本的に
Suite
で実装されているため、もし置き換えを行う場合、他のタスクとの優先順位を考慮しつつ、実行していく必要があると考えられる。
まとめ
本タスクにて実装やレビューを通して、以下の部分について、学び、今後の開発に活かしてきたいと思いました。
- Goでユニットテストの実装を行っていく際に、各テストケースをslice or mapで持たせておき、それぞれのケースが正常に動作するか否かの検証ができるテーブル駆動テストの記法は、テストの構成などの各テストケースの共通部分が多い場合は、かなり有用である。
- テーブル駆動テストを取り入れることでコードはある程度スッキリするが、実装するテストケースが膨大である場合、コードの行数が膨大となってしまうため、共通部分を変数化したり、mapを用いた記法に変更したりするなどの工夫をしていく必要がある。
- コード内における変数同士の意味合いの重複や、固定値の使用される回数が限られている場合、定義する変数の数を絞っていくことも選択肢として考えておいた方が良い。
- 今後の課題として、テストの並列化への対応、
Suite
を使う必要性の確認(testing
パッケージで事足りるか否かの確認)が挙げられる。 - AlphaDriveのエンジニアさんたちはかなり優秀であり、かなりお世話になっております…!
AlphaDriveさんでインターンとして、個人開発では書けない規模のプロジェクトのコードを読んだり、実装できる環境をいただけているのはかなりありがたいことだと感じております。
引き続き精進します。
実装した際に参考にしたリンク集(再掲)
mock関連
testify関連
テーブル駆動テスト関連
testing関連
会社の公式サイトも載せておきます。