0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

価値のないユニットテスト(古典学派の主張)

Last updated at Posted at 2023-10-15

この記事について

最近、『単体テストの考え方/使い方(Vladimir Khorikov著)』を読み、良いユニットテストについて学びました。
私は普段Goでコードを書く機会が多いので、Goのユニットテストを例に、書籍で学んだ内容を具体例を交えて説明してみたいと思います。

良いユニットテストを構成する4本の柱

『単体テストの考え方/使い方(Vladimir Khorikov著)』によると、良い単体テストは以下の4本の柱で構成されます。

  1. 退行(regression)に対する保護
  2. リファクタリングへの耐性
  3. 迅速なフィードバック
  4. 保守のしやすさ

テストケースの価値は、これらの4本の柱の掛け算で評価します。
そのため、4本のうち1本でも備えていない性質があれば、そのテストケースの価値は「0」になってしまうのです。

今回は、「リファクタリングへの耐性」が失われるユニットテストと失われないユニットテストを考えてみました。

壊れやすいユニットテストを書いてみる

まず、ユニットテストを書くために「DBからユーザの情報を取得する」ことを目的としたコードを用意しました。処理の流れは以下の通りです。

  1. main.go で, UserService.GetByUserId() を実行
  2. UserService.GetByUserId()UserPersister.GetByUserId() を実行し,Persisterにユーザ情報の取得を命令
  3. UserPersister は DB からユーザの情報を取得する

本記事では、UserService.GetByUserId()に対するユニットテストを書きます。

service/user_service.go
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で指定したユーザが存在する場合のテスト」を書くと、以下のようになりました。

壊れやすいユニットテスト(user_service_test.go)
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点を検証しており、満たされない場合はテストが失敗します。

  1. userPereieter.GetByUserId()が実行されること
  2. 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()を実行するように実装が変更されたとします。

service/user_service.go
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点に気をつけます。

  1. スタブとのやり取りは検証しない(スタブとのやり取りは、実装の詳細に該当するため)
  2. テスト対象のアプリケーションの境界を超えるやり取りの場合、かつ、そのやり取りによる副作用が外部から観察できる場合だけモック化する

まず、「スタブとのやり取りを検証しているか」を確認すると、EXPECT()を指定することで,検証していますね。
また、壊れやすいテストでは、userPersisterをモック化していました。しかし、userPersisterは、アプリケーション境界を超えない内部のやり取りに過ぎないため,モック化するべきではありません。

以上の課題を踏まえ、実装の詳細をモックしないようにした場合のユニットテストは以下のようになります。

壊れにくいテスト(user_service_test.go)
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)
			}
		})
	}
}

変更点

  1. userPersister をモック化しない (テスト対象のアプリケーション内部のやり取りであるためモック化しない)
  2. 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 の詳細
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 の詳細
persister/user_persister.go
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.Userdto.GetUserByIdResponse の詳細
model/user.go
package model

type User struct {
	Id   string `gorm:"column:id"`
	Name string `gorm:"column:name"`
}
dto/user_response.go
package dto

type GerUserByIdResponse struct {
	Id   string `json:"id"`
	Name string `json:"name"`
}
  • 壊れにくいテストで追加した GetNewDBMock() の詳細
mock/db.go
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
}
0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?