この記事について
最近、『単体テストの考え方/使い方(Vladimir Khorikov著)』を読み、良いユニットテストについて学びました。
私は普段Goでコードを書く機会が多いので、Goのユニットテストを例に、書籍で学んだ内容を具体例を交えて説明してみたいと思います。
良いユニットテストを構成する4本の柱
『単体テストの考え方/使い方(Vladimir Khorikov著)』によると、良い単体テストは以下の4本の柱で構成されます。
- 退行(regression)に対する保護
- リファクタリングへの耐性
- 迅速なフィードバック
- 保守のしやすさ
テストケースの価値は、これらの4本の柱の掛け算で評価します。
そのため、4本のうち1本でも備えていない性質があれば、そのテストケースの価値は「0」になってしまうのです。
今回は、「リファクタリングへの耐性」が失われるユニットテストと失われないユニットテストを考えてみました。
壊れやすいユニットテストを書いてみる
まず、ユニットテストを書くために「DBからユーザの情報を取得する」ことを目的としたコードを用意しました。処理の流れは以下の通りです。
-
main.go
で,UserService.GetByUserId()
を実行 -
UserService.GetByUserId()
はUserPersister.GetByUserId()
を実行し,Persisterにユーザ情報の取得を命令 -
UserPersister
は DB からユーザの情報を取得する
本記事では、UserService.GetByUserId()
に対するユニットテストを書きます。
package service
import (
"qiita/UnitTest/dto"
"qiita/UnitTest/model"
"qiita/UnitTest/persister"
)
type UserService interface {
GetByUserId(id string) (*dto.GerUserByIdResponse, error)
}
type userService struct {
userPersister persister.UserPersister
}
func NewUserService(up persister.UserPersister) UserService {
return &userService{
userPersister: up,
}
}
// テスト対象の関数
func (us *userService) GetByUserId(id string) (*dto.GerUserByIdResponse, error) {
user, err := us.userPersister.GetByUserId(id)
if err != nil {
return nil, err
}
return buildGetUserByIdResponse(user), nil
}
func buildGetUserByIdResponse(user *model.User) *dto.GerUserByIdResponse {
return &dto.GerUserByIdResponse{
Id: user.Id,
Name: user.Name,
}
}
壊れやすいユニットテスト
まず、UserService.GetByUserId()
の処理は UserPersister
に依存しています。
そこで、UserPersister.GetByUserId()
をモック化することで、別の層の処理と分離した状態でその関数自体をテストすることができます。
ここでは、mockgen を使用し、UserPersister
のモックを自動生成します。
生成したモックを使用して、「id
で指定したユーザが存在する場合のテスト」を書くと、以下のようになりました。
package service
import (
"qiita/UnitTest/dto"
"qiita/UnitTest/model"
"qiita/UnitTest/persister"
"reflect"
"testing"
"github.com/golang/mock/gomock"
)
func Test_userService_GetByUserId(t *testing.T) {
type args struct {
id string
}
tests := []struct {
name string
args args
prepareMock func(userPersisterMock *persister.MockUserPersister)
want *dto.GerUserByIdResponse
wantErr error
}{
{
name: "User exists",
args: args{id: "1"},
prepareMock: func(userPersisterMock *persister.MockUserPersister) {
userPersisterMock.EXPECT().GetByUserId("1").Return(
&model.User{Id: "1", Name: "John"}, nil,
)
},
want: &dto.GerUserByIdResponse{Id: "1", Name: "John"},
wantErr: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctrl := gomock.NewController(t)
userPersisterMock := persister.NewMockUserPersister(ctrl)
tt.prepareMock(userPersisterMock)
us := &userService{
userPersister: userPersisterMock,
}
got, err := us.GetByUserId(tt.args.id)
if err != tt.wantErr {
t.Errorf("userService.GetByUserId() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("userService.GetByUserId() = %v, want %v", got, tt.want)
}
})
}
}
このテストで使用しているモックは、以下の2点を検証しており、満たされない場合はテストが失敗します。
-
userPereieter.GetByUserId()
が実行されること -
userPereieter.GetByUserId()
の引数が"1"
であること
それに加え、userPersister.GetByUserId()
の戻り値も設定しています。
userPersisterMock.EXPECT().GetByUserId("1").Return(&model.User{Id: "1", Name: "John"}, nil)
この段階では、ユニットテストは成功します。
$ go test
PASS
ok qiita/UnitTest/service 0.330s
実際に壊してみた
例えば、ユーザ管理機能に機能拡張が行われ、論理削除が実装された場合を考えてみましょう。userService.GetByUserId()
が、DBから有効なユーザだけを取得するために、userPersister.GetEnabledUserById()
を実行するように実装が変更されたとします。
func (us *userService) GetByUserId(id string) (*dto.GerUserByIdResponse, error) {
- user, err := us.userPersister.GetByUserId(id)
+ user, err := us.userPersister.GetEnabledUserById(id)
if err != nil {
return nil, err
}
return buildGetUserByIdResponse(user), nil
}
変更後にユニットテストを実行すると、以下のエラーが出ました。
$ go test
--- FAIL: Test_userService_GetByUserId (0.00s)
--- FAIL: Test_userService_GetByUserId/User_exists (0.00s)
user_service.go:27: because: there are no expected calls of the method "GetEnabledUserById" for that receiver
ユニットテストが壊れてしまいました。原因は、ユニットテストの内部で、userPereieter.GetByUserId()
が呼ばれることを検証していたからです。
つまり、実装の詳細をモック化したことで、「リファクタリングへの耐性」が失われました。
4本のうち1本でも備えていない性質があれば、そのテストケースの価値は「0」になります。よって、このテストケースは無価値です!!
壊れやすいテストから壊れにくいテストに修正する
先ほどのテストが壊れやすくなってしまったポイントはズバリ,別の層の処理と分離した状態で関数のテストをするために, 別の層の処理をモック化した点にあります。
モック化する対象を,実装の詳細ではなく外部から観測可能な振る舞いとすることで,ユニットテストを壊れにくくすることが可能です。 具体的には,以下の2点に気をつけます。
- スタブとのやり取りは検証しない(スタブとのやり取りは、実装の詳細に該当するため)
- テスト対象のアプリケーションの境界を超えるやり取りの場合、かつ、そのやり取りによる副作用が外部から観察できる場合だけモック化する
まず、「スタブとのやり取りを検証しているか」を確認すると、EXPECT()
を指定することで,検証していますね。
また、壊れやすいテストでは、userPersister
をモック化していました。しかし、userPersister
は、アプリケーション境界を超えない内部のやり取りに過ぎないため,モック化するべきではありません。
以上の課題を踏まえ、実装の詳細をモックしないようにした場合のユニットテストは以下のようになります。
package service
import (
"qiita/UnitTest/dto"
"qiita/UnitTest/mock"
"qiita/UnitTest/persister"
"reflect"
"testing"
- "github.com/golang/mock/gomock"
+ "github.com/DATA-DOG/go-sqlmock"
)
func Test_userService_GetByUserId(t *testing.T) {
type args struct {
id string
}
tests := []struct {
name string
args args
- prepareMock func(userPersisterMock *persister.MockUserPersister)
+ prepareMock func(mock sqlmock.Sqlmock)
want *dto.GerUserByIdResponse
wantErr error
}{
{
name: "User exists",
args: args{id: "1"},
- prepareMock: func(userPersisterMock *persister.MockUserPersister) {
- userPersisterMock.EXPECT().GetByUserId("1").Return(
- &model.User{Id: "1", Name: "John"}, nil,
- )
- },
+ prepareMock: func(mock sqlmock.Sqlmock) {
+ mock.ExpectQuery("").WillReturnRows(
+ mock.NewRows([]string{"id", "name"}).AddRow("1", "John"))
+ },
want: &dto.GerUserByIdResponse{Id: "1", Name: "John"},
wantErr: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
- ctrl := gomock.NewController(t)
- userPersisterMock := persister.NewMockUserPersister(ctrl)
-
- tt.prepareMock(userPersisterMock)
-
- us := &userService{
- userPersister: userPersisterMock,
- }
+ db, mock, err := mock.GetNewDBMock()
+
+ if err != nil {
+ t.Fatal("failed to prepare mock db")
+ }
+
+ tt.prepareMock(mock)
+
+ us := NewUserService(persister.NewPersister(db))
got, err := us.GetByUserId(tt.args.id)
if err != tt.wantErr {
t.Errorf("userService.GetByUserId() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("userService.GetByUserId() = %v, want %v", got, tt.want)
}
})
}
}
変更点
-
userPersister
をモック化しない (テスト対象のアプリケーション内部のやり取りであるためモック化しない) -
userPersister
の内部で行われる DB とのやり取りをスタブ化
DBとのやり取りをスタブ化するために、sqlmock
を使用しています。ただし、実行されるSQLの検証は実装の詳細となるため実施していません。
また、DBとのやり取りをモック化しない理由は、そのやり取りによって副作用が発生しないためです。
最後に、壊れやすいユニットテストを検証した時と同様に、UserService.GetByUserId()
から実行する関数を、
UserPersister.GetEnabledUserById()
に変更し、ユニットテストを実行してみます。
$ go test
PASS
ok qiita/UnitTest/service 0.403s
ユニットテストが成功しました。このように、UserService.GetByUserId()
の内部で実行する関数を変更した場合でも、ユニットテストを修正する必要はありません。
この結果より、壊れにくいテストの方が「リファクタリングへの耐性」を備えているといえるでしょう。
まとめ
ユニットテストには、古典学派とロンドン学派という2種類の派閥があります。古典学派は,「1つの振る舞い」を単体として捉えるのに対して、ロンドン学派は「1つのクラス」を単体として捉えます。
今回紹介した「壊れやすいテスト」は別のクラスをモック化するロンドン学派のユニットテストであるといえます。一方、「壊れにくいテスト」は古典学派に対応します。
今回は,『単体テストの考え方/使い方(Vladimir Khorikov著)』の内容をもとに記事を書きました。興味がある方は是非読んでみてください。
最後まで,読んでくれてありがとうございました。
補足
-
main.go
の詳細
package main
import (
"fmt"
"log"
"qiita/UnitTest/persister"
"qiita/UnitTest/service"
"gorm.io/driver/mysql"
"gorm.io/gorm"
)
func main() {
dsn := "dsn"
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
if err != nil {
log.Fatal("failed to connect DB.")
}
userPersister := persister.NewPersister(db)
userService := service.NewUserService(userPersister)
userResponse, err := userService.GetByUserId("id")
if err != nil {
log.Fatal("failed to get user")
}
fmt.Printf("userResponse: %v", userResponse)
}
-
UserPersister
の詳細
package persister
import (
"qiita/UnitTest/model"
"gorm.io/gorm"
)
type UserPersister interface {
GetByUserId(id string) (*model.User, error)
}
type userPersister struct {
db *gorm.DB
}
func NewPersister(db *gorm.DB) UserPersister {
return &userPersister{db: db}
}
func (up userPersister) GetByUserId(id string) (*model.User, error) {
var user model.User
queryDB := up.db.Session(&gorm.Session{})
err := queryDB.Where("id = ?", id).Find(&user).Error
return &user, err
}
-
model.User
とdto.GetUserByIdResponse
の詳細
package model
type User struct {
Id string `gorm:"column:id"`
Name string `gorm:"column:name"`
}
package dto
type GerUserByIdResponse struct {
Id string `json:"id"`
Name string `json:"name"`
}
- 壊れにくいテストで追加した
GetNewDBMock()
の詳細
package mock
import (
"github.com/DATA-DOG/go-sqlmock"
"gorm.io/driver/mysql"
"gorm.io/gorm"
)
func GetNewDBMock() (*gorm.DB, sqlmock.Sqlmock, error) {
db, mock, err := sqlmock.New()
if err != nil {
return nil, mock, err
}
gormDB, err := gorm.Open(mysql.New(mysql.Config{
Conn: db,
SkipInitializeWithVersion: true,
}), &gorm.Config{})
return gormDB, mock, err
}