テストコード
今回はDDDとは?GOとは?って状態からAPIをつくってみたで作ったプログラムにテストコードを作ってみます。
まず、テストコードはどこからどこまで作成するのか、と言うのが大きく意見が分かれてくるところです。
個人的にはテストコードを書くなら、徹底的に書く。書かないならまったく書かない。と言うのが好きです。
と言うのは、引き継いだプロジェクトで「テストコード書いててCI環境も構築してコミットしたらテストコードが走るようになってるから。」と言われて安心してたんだけど、テストコードは中途半端に実装されているだけで、当然バグとして発見されるべきものが発見されず、リリース後に気づくということが頻発したことがありました。
テストコードのおかげでバグを見つけられることもあり、捨てるのはもったいなく、メソッドに変更を加えるとテストコードの変更も加えないといけない、時間のある時にテストコードの漏れを補っていかないといけない。手動のテストもやらないといけない。と言う経験がありました。
controllerもテストコードを書くのか、modelだけにするのか、結合テストは? e2eのテストは?いろんな考え方があると思う。
それはプロジェクトで判断すればいいのかなと思います。
ここでは、基本的なGolangでのテストコードの書き方を学んでいきたいと思います。
testingパッケージ
testingパッケージ を利用します。
命名規則
testingパッケージ利用に際してルールが2つあります。
- テストファイル名はxxx_test.go
- テスト関数名はTestXxxもしくは Test_xxx
アッパーキャメルかスネーク。個人的には2種類あるのが気持ち悪い。こういうのは統一するか完全自由なのかが好き。今回は、アッパーキャメルとする。と決めたがVS Codeで作られるテンプレートがスネークだった。
ファイルの場所
テスト対象のファイルとテストファイルは同じディレクトリに配置するのが推奨されている。
個人的には、1ディレクトリにファイルがたくさんあるのは好きじゃないのだけれど、別のフォルダに置くとカバレッジの取得ができなくなるらしいので、推奨通りに同一ディレクトリに置くのが良さそう。
テスト実行コマンド
go test ./…
…をつけるとディレクトリ下にあるテストファイルも実行します。
オプション
オプション | 内容 |
---|---|
-v | 詳細を表示する |
--short | フラグのあるテストファイルをスキップ |
-cover | カバレッジを取得 |
テスト時のログ出力
log.Println()
または log.Printf()
失敗したテストのログだけでなく成功した場合もログを表示する
t.Log()
または t.Logf()
失敗したテストのログだけ表示する
テスト失敗時の強制終了
t.FailNow()
または t.Fatalf()
を使います。t.Fatalf()はログを表示します。
テストのキャッシュを削除
前回のテストから対象のファイルやテストコードに変更がない場合は、キャッシュを利用します。強制的にキャッシュを削除したいときは以下を使います。
go clean -testcache
テストの並列実行
時間のある時に試すメモ
tt := tt
と t.Parallel()
を追加。(正直意味は分かっていない)
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := add(tt.args.a, tt.args.b); got != tt.want {
t.Errorf("add() = %v, want %v", got, tt.want)
}
})
}
テストの前処理と後処理
データベースに初期データを投入して、テスト終了後に削除する場合など。
func TestMain(m *testing.M) {
fmt.Println("前処理")
status := m.Run()
fmt.Println("後処理")
os.Exit(status)
}
m.Run()
でテストが実行される。
VS Code
ctrl + shift + p でコマンドパレットを開いて、Go: install/update tools
gotestsを選択してインストール
標準で用意されているので便利。
実装
プログラムは DDDとは?GOとは?って状態からAPIをつくってみた を利用します。
domain層
今回domain層はmodelとrepositoryに分けています。modelには構造体とビジネスロジックを定義しています。
player.goでは以下のようになっています。
package model
import (
"errors"
)
// Playerの構造体
type Player struct {
ID int
FirstName string
LastName string
Team string
}
// コンストラクタの戻りはポインタにするのが一般的
func NewPlayer(firstName string, lastName string, team string) (*Player, error) {
if firstName == "" {
return nil, errors.New("First Nameを入力してください")
}
if lastName == "" {
return nil, errors.New("Last Nameを入力してください")
}
if team == "" {
return nil, errors.New("Teamを入力してください")
}
player := &Player{
FirstName: firstName,
LastName: lastName,
Team: team,
}
return player, nil
}
// コンストラクタの戻りはポインタにするのが一般的
func NewExistingPlayer(id int, firstName, lastName, team string) (*Player, error) {
if id <= 0 {
return nil, errors.New("正しいIDを入力してください")
}
player, err := NewPlayer(firstName, lastName, team)
if err != nil {
return nil, err
}
// IDを設定して返却
player.ID = id
return player, nil
}
構造体とそのコンストラクタ(メソッド)になります。完全コンストラクタになるよう、エラーチェックはここで行っています。
(今は最小限のチェックしかないですが)
ふと思っただけだけど、typeormにあるようなclass-validatorみたいなのもあるんだろうなぁ。
①テストしたいファイルを開いて、Ctrl + Shift + pのコマンドパレットに、Go: Generate Unit Tests for File
を選択
②テストファイルのテンプレートが作成される
③このテンプレートをもとにテストコードを作成
テンプレートがあるとほとんど設定値だけでよい。と言うか、なんとなくで実装できた。
ざっくりとnameにテスト名、argsにテスト値、wantに期待する結果(return value)、wantErrにエラーが発生するかどうかのbool値
静的型付け言語の良いところは、intはintしか来ないよってことが保証されていること。intのところにstringのパラメータが来たらどうする?とかテストしなくてもコンパイル時に発見される。
package model
import (
"reflect"
"testing"
)
func TestNewPlayer(t *testing.T) {
type args struct {
firstName string
lastName string
position []string
team string
}
tests := []struct {
name string
args args
want *Player
wantErr bool
}{
{"ファーストネーム必須確認", args{firstName: "", lastName: "Curry", position: []string{"PG"}, team: "GSW"}, nil, true},
{"ラストネーム必須確認", args{firstName: "Stephen", lastName: "", position: []string{"PG"}, team: "GSW"}, nil, true},
{"チーム必須確認", args{firstName: "Stephen", lastName: "Curry", position: []string{"PG"}, team: ""}, nil, true},
{"正常値確認", args{firstName: "Stephen", lastName: "Curry", position: []string{"PG"}, team: "GSW"}, &Player{FirstName: "Stephen", LastName: "Curry", Team: "GSW"}, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := NewPlayer(tt.args.firstName, tt.args.lastName, tt.args.team)
if (err != nil) != tt.wantErr {
t.Errorf("NewPlayer() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("NewPlayer() = %v, want %v", got, tt.want)
}
})
}
}
func TestNewExistingPlayer(t *testing.T) {
type args struct {
id int
firstName string
lastName string
position string
team string
}
tests := []struct {
name string
args args
want *Player
wantErr bool
}{
{"ID必須確認", args{id: 0, firstName: "Stephen", lastName: "Curry", position: "PG", team: "GSW"}, nil, true},
{"ファーストネーム必須確認", args{id: 1, firstName: "", lastName: "Curry", position: "PG", team: "GSW"}, nil, true},
{"ラストネーム必須確認", args{id: 1, firstName: "Stephen", lastName: "", position: "PG", team: "GSW"}, nil, true},
{"チーム必須確認", args{id: 1, firstName: "Stephen", lastName: "Curry", position: "PG", team: ""}, nil, true},
{"正常値確認", args{id: 1, firstName: "Stephen", lastName: "Curry", position: "PG", team: "GSW"}, &Player{ID: 1, FirstName: "Stephen", LastName: "Curry", Team: "GSW"}, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := NewExistingPlayer(tt.args.id, tt.args.firstName, tt.args.lastName, tt.args.team)
if (err != nil) != tt.wantErr {
t.Errorf("NewExistingPlayer() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("NewExistingPlayer() = %v, want %v", got, tt.want)
}
})
}
}
テストコードはセンスが問われる。テストパターンはいくつでも増やせる。例えば、上記例の場合、組み合わせのテストはしなくていいのか?firstNameとlastName両方が未指定だとどうなるか?とか。作り手がアーリーリターンしてるから組み合わせのテストなんてしなくて大丈夫!って気持ちはわかる。・・・けどそれで良いのだろうか?と言う気持ちもある。
実行
シンプルな結果表示。
go test ./...
? go_sample/api [no test files]
? go_sample/config [no test files]
ok go_sample/domain/model 0.088s
? go_sample/domain/queryservice [no test files]
? go_sample/domain/repository [no test files]
? go_sample/infra [no test files]
? go_sample/interface/handler [no test files]
? go_sample/usecase [no test files]
go test ./... --v
で詳細を確認できる。
infrastracture層
infrastracture層は技術への関心事が書かれている層です。今回は、DBの操作CRUDを見ていきます。
この時DBをどうするかという問題になります。
dockerで環境を作っている、ローカルDBを自由に使えるとかの環境であれば、テストDBを使えるでしょう。
今回は、Mock DBを利用してみます。
モックのライブラリとしてgo-sqlmockを利用します。
go get github.com/DATA-DOG/go-sqlmock
player.goは以下のようになっています。
package infra
import (
"go/domain/model"
"go/domain/repository"
"github.com/jinzhu/gorm"
)
// player repositoryの構造体
type PlayerRepository struct {
Conn *gorm.DB
}
// returnはinterfaceとすること
func NewPlayerRepository(conn *gorm.DB) repository.IPlayerRepository {
return &PlayerRepository{Conn: conn}
}
// playerの新規作成
// この書き方に慣れないのでコメントを残す
// PlayerRepositoryのstructにCreateというメソッドを実装し、引数にmodel.Playerをポインタで受け取り、model.Playerのポインタを返す
func (repository *PlayerRepository) Create(player *model.Player) (*model.Player, error) {
// ↓のif文がポインタを理解するのにいいかも。playerをポインタで渡すことで、参照渡しとなって、returnで受け取らなくてもplayerは書き換わってる(idが入ってくる)
if err := repository.Conn.Create(&player).Error; err != nil {
return nil, err
}
return player, nil
}
// playerの更新
func (repository *PlayerRepository) Update(player *model.Player) (*model.Player, error) {
if err := repository.Conn.Model(&player).Update(&player).Error; err != nil {
return nil, err
}
return player, nil
}
// playerの削除
func (repository *PlayerRepository) Delete(id int) error {
if err := repository.Conn.Where("id = ?", id).Delete(&model.Player{}).Error; err != nil {
return err
}
return nil
}
testは以下の通り
ちゃんと調べてやらないとダメだと思ったが、なんとなく使い方は分かってきた。
ここまでやってあれだけど、go-sqlmockじゃなくて、dockerでtest db作るのが自然かな。
テストパターンはもちろんもっと作らないといけないんだけど、今回はお試しと言うことでいくつかだけ記載します。
きちんと調べずにこんなものだろでやったら、いろんなところに引っかかりました。ざっくりとポイントは以下の通りです。
これを理解しておくだけでもかなり違うと思います。
- DBMockの取得は頻繁にあるので関数で切り出す
gormでopenするときに環境に合わせて(mysql)指定すること。returnとかsql文のダブルクォート、バッククォートの違いに関係してくる
もっときれいに書けそうなんだけど、サボりました。 - クエリーのチェックは正規表現で行われる
ExpectExecとExpectQueryメソッドを実行した時点で、クエリーのチェックとreturn、recordのチェックを行う。
つまりは、メソッドのreturn値の比較ではなく、dbの比較であることに注意する - メソッドの戻り値の比較は、他のテストと同じように
reflect.DeepEqual(actual, tt.want)
で行う。
go-sqlmockは、便利だなぁと思う反面、エラー内容がわかりずらいです。SQLのチェックで正規表現をちゃんと書けばいいんだけど、
ORMで吐き出されるSQLをコピーしてテストコードに貼り付けるという、わけのわからない作業をやってしまいます。
ORMで吐き出されるSQLを目視で確認するのはいいことだと思うけど、そもそもSQLに詳しくない人が目視で正しいかどうか理解できるのかが疑問。
だったら、SQL直で書けばいいと思った・・・好みの問題なのかなぁ。
そうは言っても、クエリーが変更になった(プログラムを改修した)時にテストコードがFAILになるのでこれは大きなメリットでテストコードは書いたほうが良い。
package infra
import (
"go/domain/model"
"reflect"
"regexp"
"testing"
"github.com/DATA-DOG/go-sqlmock"
"github.com/jinzhu/gorm"
)
func GetDBMock() (*gorm.DB, sqlmock.Sqlmock, error) {
db, mock, err := sqlmock.New()
if err != nil {
return nil, nil, err
}
gdb, err := gorm.Open("mysql", db)
if err != nil {
return nil, nil, err
}
return gdb, mock, nil
}
func TestPlayerRepository_Create(t *testing.T) {
type args struct {
player *model.Player
}
tests := []struct {
name string
args args
want *model.Player
wantErr bool
}{
{"created", args{&model.Player{ID: 1, FirstName: "Stephen", LastName: "Curry", Team: "GSW"}}, &model.Player{ID: 1, FirstName: "Stephen", LastName: "Curry", Team: "GSW"}, false},
}
db, mock, err := GetDBMock()
if err != nil {
t.Fatalf("an error '%s' was not expected when opening a stub database connection", err)
}
defer db.Close()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mock.ExpectBegin()
// insert文が正しいかqueryチェック
mock.ExpectExec(regexp.QuoteMeta("INSERT INTO `players` (`id`,`first_name`,`last_name`,`team`)")).
WithArgs(tt.args.player.ID, tt.args.player.FirstName, tt.args.player.LastName, tt.args.player.Team).
WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectCommit()
repository := NewPlayerRepository(db)
actual, err := repository.Create(tt.args.player)
if (err != nil) != tt.wantErr {
t.Errorf("PlayerRepository.Create() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !reflect.DeepEqual(actual, tt.want) {
t.Errorf("PlayerRepository.Create() = %v, want %v", actual, tt.want)
}
})
}
}
func TestPlayerRepository_Update(t *testing.T) {
type args struct {
player *model.Player
}
tests := []struct {
name string
args args
want *model.Player
wantErr bool
}{
{"updated", args{&model.Player{ID: 1, FirstName: "Klay", LastName: "Thompson", Team: "GSW"}}, &model.Player{ID: 1, FirstName: "Klay", LastName: "Thompson", Team: "GSW"}, false},
}
db, mock, err := GetDBMock()
if err != nil {
t.Fatalf("an error '%s' was not expected when opening a stub database connection", err)
}
defer db.Close()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mock.ExpectBegin()
// update文が正しいかqueryチェック
mock.ExpectExec(regexp.QuoteMeta("UPDATE `players` SET `first_name` = ?, `id` = ?, `last_name` = ?, `team` = ? WHERE `players`.`id` = ?")).
WithArgs(tt.args.player.FirstName, tt.args.player.ID, tt.args.player.LastName, tt.args.player.Team, tt.args.player.ID).
WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectCommit()
repository := NewPlayerRepository(db)
actual, err := repository.Update(tt.args.player)
if (err != nil) != tt.wantErr {
t.Errorf("PlayerRepository.Update() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !reflect.DeepEqual(actual, tt.want) {
t.Errorf("PlayerRepository.Update() = %v, want %v", actual, tt.want)
}
})
}
}
func TestPlayerRepository_Delete(t *testing.T) {
type args struct {
id int
}
tests := []struct {
name string
args args
wantErr bool
}{
{"deleted", args{id: 1}, false},
}
db, mock, err := GetDBMock()
if err != nil {
t.Fatalf("an error '%s' was not expected when opening a stub database connection", err)
}
defer db.Close()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mock.ExpectBegin()
// delete文が正しいかqueryチェック
mock.ExpectExec(regexp.QuoteMeta("DELETE FROM `players` WHERE (id = ?)")).
WithArgs(tt.args.id).
WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectCommit()
repository := NewPlayerRepository(db)
if err := repository.Delete(tt.args.id); (err != nil) != tt.wantErr {
t.Errorf("PlayerRepository.Delete() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
今回CQRSパターンを試したのでSELECTは別ファイルに定義しています。
JOINしたりreturnのdata objectが配列で返ってくるところとかが難しかった。
Usecaseでオブジェクトを関連付けてれば簡単だったのかなあと思うけど、
システムをシンプルにするためにサービスの一部機能を犠牲にするのはあり得ないので、文句は言わない。
playerDisplay.goは以下の通り
package infra
import (
"go/domain/model"
"go/domain/queryservice"
"github.com/jinzhu/gorm"
)
type PlayerDisplayQueryService struct {
Conn *gorm.DB
}
// returnはinterfaceとすること
func NewPlayerDisplayQueryService(conn *gorm.DB) queryservice.IPlayerQueryService {
return &PlayerDisplayQueryService{Conn: conn}
}
// playerをIDで取得
func (queryService *PlayerDisplayQueryService) FindByID(id int) (*model.PlayerDisplay, error) {
query := queryService.Conn.Table("players").
Select("players.*, positions.name").
Joins("INNER JOIN positions ON players.ID = positions.player_id").
Where("players.id = ?", id)
rows, err := query.Rows()
if err != nil {
return nil, err
}
var playerDisplayWithPosition struct {
model.PlayerDisplay
Name string
}
var positions []string
for rows.Next() {
err := query.ScanRows(rows, &playerDisplayWithPosition)
if err != nil {
return nil, err
}
positions = append(positions, *&playerDisplayWithPosition.Name)
}
playerDisplay := playerDisplayWithPosition.PlayerDisplay
playerDisplay.Position = positions
return &playerDisplay, nil
}
テストは以下の通り
package infra
import (
"go/domain/model"
"go/domain/queryservice"
"reflect"
"regexp"
"testing"
"github.com/DATA-DOG/go-sqlmock"
"github.com/jinzhu/gorm"
)
func TestNewPlayerDisplayQueryService(t *testing.T) {
type args struct {
conn *gorm.DB
}
db, _, err := GetDBMock()
if err != nil {
t.Fatalf("an error '%s' was not expected when opening a stub database connection", err)
}
defer db.Close()
tests := []struct {
name string
args args
want queryservice.IPlayerQueryService
}{
{name: "constructer", args: args{db}, want: &PlayerDisplayQueryService{Conn: db}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := NewPlayerDisplayQueryService(tt.args.conn); !reflect.DeepEqual(got, tt.want) {
t.Errorf("NewPlayerDisplayQueryService() = %v, want %v", got, tt.want)
}
})
}
}
func TestPlayerDisplayQueryService_FindByID(t *testing.T) {
type args struct {
id int
}
tests := []struct {
name string
args args
want *model.PlayerDisplay
wantErr bool
}{
{name: "selected", args: args{id: 1}, want: &model.PlayerDisplay{ID: 1, FirstName: "Draymond", LastName: "Green", Team: "GSW", Position: []string{"PF", "C"}}, wantErr: false},
}
db, mock, err := GetDBMock()
if err != nil {
t.Fatalf("an error '%s' was not expected when opening a stub database connection", err)
}
defer db.Close()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mock.ExpectQuery(regexp.QuoteMeta("SELECT players.*, positions.name FROM `players` INNER JOIN positions ON players.ID = positions.player_id WHERE (players.id = ?)")).
WithArgs(tt.args.id).
WillReturnRows(sqlmock.NewRows([]string{"id", "first_name", "last_name", "team", "name"}).AddRow(1, "Draymond", "Green", "GSW", "PF").AddRow(1, "Draymond", "Green", "GSW", "C"))
queryService := NewPlayerDisplayQueryService(db)
got, err := queryService.FindByID(tt.args.id)
if (err != nil) != tt.wantErr {
t.Errorf("PlayerDisplayQueryService.FindByID() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("PlayerDisplayQueryService.FindByID() = %v, want %v", got, tt.want)
}
})
}
}
usecase層
Usecase層は今回のプログラムでは、存在意義が問われるくらい薄い層となっているので、サクッとテストコードを書いてしまう。
と思ったけど、モックが必要なので、mockパッケージを利用する。今回は、testifyを利用します。
もっときれいに書けると思うんだけど、テストプログラムが大変なことになった。
誤解を恐れずに言うと、テスト対象メソッドないでinterfaceを介して参照しているメソッドにモックをつかって、固定値を返しちゃおうぜ。ってイメージ。
(今回のUsecaseでは、Usecaseのメソッドからinterfaceを介してdbにアクセスしているメソッドを呼んでいる。でも、実体はできていないからモックを使って固定値を返す。)
player.go は以下の通り
package usecase
import (
"go/domain/model"
"go/domain/queryservice"
"go/domain/repository"
)
// player usecaseのinterface
type IPlayerUsecase interface {
Create(firstName string, lastName string, position []string, team string) (*model.Player, error)
FindByID(id int) (*model.PlayerDisplay, error)
Update(id int, firstName, lastName string, position []string, team string) (*model.Player, error)
Delete(id int) error
}
type playerUsecase struct {
playerRepository repository.IPlayerRepository
positionRepository repository.IPositionRepository
playerQueryService queryservice.IPlayerQueryService
}
// player usecaseのコンストラクタ
func NewPlayerUsecase(playerRepository repository.IPlayerRepository, positionRepository repository.IPositionRepository, playerQueryService queryservice.IPlayerQueryService) IPlayerUsecase {
return &playerUsecase{playerRepository: playerRepository, positionRepository: positionRepository, playerQueryService: playerQueryService}
}
// Create playerを保存するときのユースケース
func (usecase *playerUsecase) Create(firstName string, lastName string, position []string, team string) (*model.Player, error) {
// package modelのNewPlayerメソッドを実行と言う意味で、modelインスタンスのNewPlayerメソッドと言うことではない。
player, err := model.NewPlayer(firstName, lastName, team)
if err != nil {
return nil, err
}
createdPlayer, err := usecase.playerRepository.Create(player)
if err != nil {
return nil, err
}
// positionを別オブジェクト(別テーブル)としたので登録
for _, v := range position {
position, err := model.NewPosition(createdPlayer.ID, v)
if err != nil {
return nil, err
}
usecase.positionRepository.Create(position)
}
return createdPlayer, nil
}
// FindByID playerをIDで取得するときのユースケース
func (usecase *playerUsecase) FindByID(id int) (*model.PlayerDisplay, error) {
player, err := usecase.playerQueryService.FindByID(id)
if err != nil {
return nil, err
}
return player, nil
}
// playerを更新するときのユースケース
func (usecase *playerUsecase) Update(id int, firstName, lastName string, position []string, team string) (*model.Player, error) {
player, err := model.NewExistingPlayer(id, firstName, lastName, team)
if err != nil {
return nil, err
}
updatedPlayer, err := usecase.playerRepository.Update(player)
if err != nil {
return nil, err
}
// バグあり updateが0件のとき、insertされてしまう・・・。
// poisitonを更新 1対nの関係なのでdelete & insertとする
if err := usecase.positionRepository.DeleteByPlayerID(id); err != nil {
return nil, err
}
for _, v := range position {
position, err := model.NewPosition(id, v)
if err != nil {
return nil, err
}
usecase.positionRepository.Create(position)
}
return updatedPlayer, nil
}
// Delete
func (usecase *playerUsecase) Delete(id int) error {
err := usecase.playerRepository.Delete(id)
if err != nil {
return err
}
return usecase.positionRepository.DeleteByPlayerID(id)
}
実装はstructにわたってくるinterfaceの実体をモックで作ってあげる。それをstructに注入してあげる。
文章にするのは難しいので、自分で理解しながらやるしかないかも。
テストは以下の通り
package usecase
import (
"go/domain/model"
"reflect"
"testing"
mock "github.com/stretchr/testify/mock"
)
type MockIPlayerRepository struct {
mock.Mock
}
func (_m *MockIPlayerRepository) Create(player *model.Player) (*model.Player, error) {
ret := _m.Called(player)
return ret.Get(0).(*model.Player), ret.Error(1)
}
func (_m *MockIPlayerRepository) Update(player *model.Player) (*model.Player, error) {
ret := _m.Called(player)
return ret.Get(0).(*model.Player), ret.Error(1)
}
func (_m *MockIPlayerRepository) Delete(id int) error {
ret := _m.Called(id)
return ret.Error(0)
}
type MockIPositionRepository struct {
mock.Mock
}
func (_m *MockIPositionRepository) Create(position *model.Position) (*model.Position, error) {
ret := _m.Called(position)
return ret.Get(0).(*model.Position), ret.Error(1)
}
func (_m *MockIPositionRepository) DeleteByPlayerID(playerID int) error {
ret := _m.Called(playerID)
return ret.Error(0)
}
type MockIPlayerQueryService struct {
mock.Mock
}
func (_m *MockIPlayerQueryService) FindByID(id int) (*model.PlayerDisplay, error) {
ret := _m.Called(id)
return ret.Get(0).(*model.PlayerDisplay), ret.Error(1)
}
func Test_playerUsecase_Create(t *testing.T) {
type args struct {
firstName string
lastName string
position []string
team string
}
tests := []struct {
name string
args args
want *model.Player
wantErr bool
}{
{name: "created", args: args{firstName: "Stephen", lastName: "Curry", position: []string{"PG"}, team: "GSW"}, want: &model.Player{ID: 1, FirstName: "Stephen", LastName: "Curry", Team: "GSW"}, wantErr: false},
}
mockPlayerRepo := new(MockIPlayerRepository)
mockPlayerRepo.On("Create", &model.Player{FirstName: "Stephen", LastName: "Curry", Team: "GSW"}).Return(&model.Player{ID: 1, FirstName: "Stephen", LastName: "Curry", Team: "GSW"}, nil)
mockPositionRepository := new(MockIPositionRepository)
mockPositionRepository.On("Create", &model.Position{PlayerID: 1, Name: "PG"}).Return(&model.Position{ID: 1, PlayerID: 1, Name: "PG"}, nil)
mockPlayerQueryService := new(MockIPlayerQueryService)
playerUsecase := NewPlayerUsecase(mockPlayerRepo, mockPositionRepository, mockPlayerQueryService)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := playerUsecase.Create(tt.args.firstName, tt.args.lastName, tt.args.position, tt.args.team)
if (err != nil) != tt.wantErr {
t.Errorf("playerUsecase.Create() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("playerUsecase.Create() = %v, want %v", got, tt.want)
}
})
}
}
func Test_playerUsecase_FindByID(t *testing.T) {
type args struct {
id int
}
tests := []struct {
name string
args args
want *model.PlayerDisplay
wantErr bool
}{
{name: "selected", args: args{id: 1}, want: &model.PlayerDisplay{ID: 1, FirstName: "Draymond", LastName: "Green", Team: "GSW", Position: []string{"PF", "C"}}, wantErr: false},
}
mockPlayerRepo := new(MockIPlayerRepository)
mockPositionRepository := new(MockIPositionRepository)
mockPlayerQueryService := new(MockIPlayerQueryService)
mockPlayerQueryService.On("FindByID", 1).Return(&model.PlayerDisplay{ID: 1, FirstName: "Draymond", LastName: "Green", Team: "GSW", Position: []string{"PF", "C"}}, nil)
playerUsecase := NewPlayerUsecase(mockPlayerRepo, mockPositionRepository, mockPlayerQueryService)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := playerUsecase.FindByID(tt.args.id)
if (err != nil) != tt.wantErr {
t.Errorf("playerUsecase.FindByID() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("playerUsecase.FindByID() = %v, want %v", got, tt.want)
}
})
}
}
func Test_playerUsecase_Update(t *testing.T) {
type args struct {
id int
firstName string
lastName string
position []string
team string
}
tests := []struct {
name string
args args
want *model.Player
wantErr bool
}{
{name: "updated", args: args{id: 1, firstName: "Klay", lastName: "Thompson", position: []string{"SG"}, team: "GSW"}, want: &model.Player{ID: 1, FirstName: "Klay", LastName: "Thompson", Team: "GSW"}, wantErr: false},
}
mockPlayerRepo := new(MockIPlayerRepository)
mockPlayerRepo.On("Update", &model.Player{ID: 1, FirstName: "Klay", LastName: "Thompson", Team: "GSW"}).Return(&model.Player{ID: 1, FirstName: "Klay", LastName: "Thompson", Team: "GSW"}, nil)
mockPositionRepository := new(MockIPositionRepository)
mockPositionRepository.On("Create", &model.Position{PlayerID: 1, Name: "SG"}).Return(&model.Position{ID: 1, PlayerID: 1, Name: "SG"}, nil)
mockPositionRepository.On("DeleteByPlayerID", 1).Return(nil)
mockPlayerQueryService := new(MockIPlayerQueryService)
playerUsecase := NewPlayerUsecase(mockPlayerRepo, mockPositionRepository, mockPlayerQueryService)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := playerUsecase.Update(tt.args.id, tt.args.firstName, tt.args.lastName, tt.args.position, tt.args.team)
if (err != nil) != tt.wantErr {
t.Errorf("playerUsecase.Update() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("playerUsecase.Update() = %v, want %v", got, tt.want)
}
})
}
}
func Test_playerUsecase_Delete(t *testing.T) {
type args struct {
id int
}
tests := []struct {
name string
args args
wantErr bool
}{
{name: "deleted", args: args{id: 1}, wantErr: false},
}
mockPlayerRepo := new(MockIPlayerRepository)
mockPlayerRepo.On("Delete", 1).Return(nil)
mockPositionRepository := new(MockIPositionRepository)
mockPositionRepository.On("DeleteByPlayerID", 1).Return(nil)
mockPlayerQueryService := new(MockIPlayerQueryService)
playerUsecase := NewPlayerUsecase(mockPlayerRepo, mockPositionRepository, mockPlayerQueryService)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if err := playerUsecase.Delete(tt.args.id); (err != nil) != tt.wantErr {
t.Errorf("playerUsecase.Delete() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
同じことを何度も書いてきれいなプログラムとは言えない。今回はサンプルプログラムなのでこのままにしておく。
interface層
interface層(ハンドラー)のテストコードを作ってみます。
ここでは、routerで振り分けられたメソッドが、Http parameterを取得してUsecaseに渡して、Usecaseからobjectを受け取ります。
受け取ったobjectを決められたフォーマットでresponseします。
MVCのcontrollerです。controllerをユニットテストしない場合もあると思いますが、今回は勉強のためやってみます。
player.goは以下の通り
package handler
import (
"net/http"
"strconv"
"go/usecase"
"github.com/labstack/echo"
)
// Player handlerのinterface
type IPlayerHandler interface {
Post() echo.HandlerFunc
Get() echo.HandlerFunc
Put() echo.HandlerFunc
Delete() echo.HandlerFunc
}
type playerHandler struct {
playerUsecase usecase.IPlayerUsecase
}
// player handlerのコンストラクタ
func NewPlayerHandler(playerUsecase usecase.IPlayerUsecase) IPlayerHandler {
return &playerHandler{playerUsecase: playerUsecase}
}
// goの書式とjsonの書式の違いを吸収するためここで定義する
// 外部から参照されないように小文字で宣言
type requestPlayer struct {
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Position []string `json:"position"`
Team string `json:"team"`
}
// 外部から参照されないように小文字で宣言
type responsePlayer struct {
ID int `json:"id"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Team string `json:"team"`
}
// Post playerを保存するときのハンドラー
// funcをreturnしているので一瞬戸惑う javascriptでやるあれか。
func (handler *playerHandler) Post() echo.HandlerFunc {
return func(context echo.Context) error {
var req requestPlayer
if err := context.Bind(&req); err != nil {
return context.JSON(http.StatusBadRequest, err.Error())
}
player, err := handler.playerUsecase.Create(req.FirstName, req.LastName, req.Position, req.Team)
if err != nil {
return context.JSON(http.StatusBadRequest, err.Error())
}
res := responsePlayer{
ID: player.ID,
FirstName: player.FirstName,
LastName: player.LastName,
Team: player.Team,
}
return context.JSON(http.StatusCreated, res)
}
}
// playerを取得するときのハンドラー
func (handler *playerHandler) Get() echo.HandlerFunc {
return func(context echo.Context) error {
id, err := strconv.Atoi((context.Param("id")))
if err != nil {
return context.JSON(http.StatusBadRequest, err.Error())
}
player, err := handler.playerUsecase.FindByID(id)
if err != nil {
return context.JSON(http.StatusBadRequest, err.Error())
}
res := responsePlayer{
ID: player.ID,
FirstName: player.FirstName,
LastName: player.LastName,
Team: player.Team,
}
return context.JSON(http.StatusOK, res)
}
}
// playerを更新するときのハンドラー
func (handler *playerHandler) Put() echo.HandlerFunc {
return func(context echo.Context) error {
// Atoi関数は、引数に指定された文字列を数値型に変換して返します。
id, err := strconv.Atoi(context.Param("id"))
if err != nil {
return context.JSON(http.StatusBadRequest, err.Error())
}
var req requestPlayer
if err := context.Bind(&req); err != nil {
return context.JSON(http.StatusBadRequest, err.Error())
}
player, err := handler.playerUsecase.Update(id, req.FirstName, req.LastName, req.Position, req.Team)
if err != nil {
return context.JSON(http.StatusBadRequest, err.Error())
}
res := responsePlayer{
ID: player.ID,
FirstName: player.FirstName,
LastName: player.LastName,
Team: player.Team,
}
return context.JSON(http.StatusOK, res)
}
}
// playerを削除するときのハンドラー
func (handler *playerHandler) Delete() echo.HandlerFunc {
return func(context echo.Context) error {
id, err := strconv.Atoi(context.Param("id"))
if err != nil {
return context.JSON(http.StatusBadRequest, err.Error())
}
err = handler.playerUsecase.Delete(id)
if err != nil {
return context.JSON(http.StatusBadRequest, err.Error())
}
return context.NoContent(http.StatusNoContent)
}
}
テストは以下の通り
net/http/httptest 標準パッケージを利用します。
正直めちゃくちゃ苦労した。そして、きれいにもかけていなければ正しいかどうかも不安。
同じような処理を何度も書いてます。
ハマったのが、Echoがどこまでやってくれているのかよくわかっていなかった。
/player/1
みたいにURLにパラメータをいれてても、echo.Context.param("id")
でパラメータの値を取得できない。
ちゃんとテストプログラムでSetしてあげないとダメ。知ってしまえば、そらそうか。ってなるけど、ハマるときハマる。Hallo World系のサンプルには載ってないんだよなぁ。
c.SetParamNames("id")
c.SetParamValues(strconv.Itoa(tt.args))
package handler
import (
"bytes"
"encoding/json"
"fmt"
"go/domain/model"
"go/usecase"
"io/ioutil"
"net/http"
"net/http/httptest"
"reflect"
"strconv"
"testing"
"github.com/labstack/echo"
mock "github.com/stretchr/testify/mock"
)
type MockIPlayerRepository struct {
mock.Mock
}
func (_m *MockIPlayerRepository) Create(player *model.Player) (*model.Player, error) {
ret := _m.Called(player)
return ret.Get(0).(*model.Player), ret.Error(1)
}
func (_m *MockIPlayerRepository) Update(player *model.Player) (*model.Player, error) {
ret := _m.Called(player)
return ret.Get(0).(*model.Player), ret.Error(1)
}
func (_m *MockIPlayerRepository) Delete(id int) error {
ret := _m.Called(id)
return ret.Error(0)
}
type MockIPositionRepository struct {
mock.Mock
}
func (_m *MockIPositionRepository) Create(position *model.Position) (*model.Position, error) {
ret := _m.Called(position)
return ret.Get(0).(*model.Position), ret.Error(1)
}
func (_m *MockIPositionRepository) DeleteByPlayerID(playerID int) error {
ret := _m.Called(playerID)
return ret.Error(0)
}
type MockIPlayerQueryService struct {
mock.Mock
}
func (_m *MockIPlayerQueryService) FindByID(id int) (*model.PlayerDisplay, error) {
ret := _m.Called(id)
return ret.Get(0).(*model.PlayerDisplay), ret.Error(1)
}
func Test_playerHandler_Post(t *testing.T) {
tests := []struct {
name string
args *requestPlayer
want responsePlayer
}{
{name: "Post handler", args: &requestPlayer{FirstName: "Stephen", LastName: "Curry", Position: []string{"PG"}, Team: "GSW"}, want: responsePlayer{ID: 1, FirstName: "Stephen", LastName: "Curry", Team: "GSW"}},
}
e := echo.New()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// test request作成
requestJson, _ := json.Marshal(tt.args)
req := httptest.NewRequest(http.MethodPost, "/player", bytes.NewBuffer(requestJson))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
// mock作成し必要なものだけ注入。
mockPlayerRepo := new(MockIPlayerRepository)
mockPlayerRepo.On("Create", &model.Player{FirstName: "Stephen", LastName: "Curry", Team: "GSW"}).Return(&model.Player{ID: 1, FirstName: "Stephen", LastName: "Curry", Team: "GSW"}, nil)
mockPositionRepository := new(MockIPositionRepository)
mockPositionRepository.On("Create", &model.Position{PlayerID: 1, Name: "PG"}).Return(&model.Position{ID: 1, PlayerID: 1, Name: "PG"}, nil)
mockPlayerQueryService := new(MockIPlayerQueryService)
playerUsecase := usecase.NewPlayerUsecase(mockPlayerRepo, mockPositionRepository, mockPlayerQueryService)
playerHandler := NewPlayerHandler(playerUsecase)
playerHandler.Post()(c)
// test request実行
res := rec.Result()
defer res.Body.Close()
// 結果が正常終了でなければエラー
if res.StatusCode < 200 || res.StatusCode >= 300 {
t.Errorf("response StatusCode error = %v", res.StatusCode)
return
}
// response bodyをparse
body, err := ioutil.ReadAll(res.Body)
if err != nil {
t.Errorf("response body error = %v", err)
return
}
// jsonに変換
var responsePlayer responsePlayer
if err := json.Unmarshal(body, &responsePlayer); err != nil {
t.Errorf("json format error = %v", err)
return
}
// 期待するjsonかどうかテスト
if !reflect.DeepEqual(responsePlayer, tt.want) {
t.Errorf("playerHandler.Post() = %v, want %v", responsePlayer, tt.want)
}
})
}
}
func Test_playerHandler_Get(t *testing.T) {
tests := []struct {
name string
args int
want responsePlayer
}{
{name: "Get handler", args: 1, want: responsePlayer{ID: 1, FirstName: "Draymond", LastName: "Green", Team: "GSW"}},
}
e := echo.New()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// test request作成
req := httptest.NewRequest(http.MethodGet, "/player", nil)
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
c.SetParamNames("id")
c.SetParamValues(strconv.Itoa(tt.args))
// mock作成し必要なものだけ注入。
mockPlayerRepo := new(MockIPlayerRepository)
mockPositionRepository := new(MockIPositionRepository)
mockPlayerQueryService := new(MockIPlayerQueryService)
mockPlayerQueryService.On("FindByID", 1).Return(&model.PlayerDisplay{ID: 1, FirstName: "Draymond", LastName: "Green", Team: "GSW", Position: []string{"PF", "C"}}, nil)
playerUsecase := usecase.NewPlayerUsecase(mockPlayerRepo, mockPositionRepository, mockPlayerQueryService)
playerHandler := NewPlayerHandler(playerUsecase)
playerHandler.Get()(c)
// test request実行
res := rec.Result()
defer res.Body.Close()
// 結果が正常終了でなければエラー
if res.StatusCode < 200 || res.StatusCode >= 300 {
t.Errorf("response StatusCode error = %v", res.StatusCode)
return
}
// response bodyをparse
body, err := ioutil.ReadAll(res.Body)
if err != nil {
t.Errorf("response body error = %v", err)
return
}
// jsonに変換
var responsePlayer responsePlayer
if err := json.Unmarshal(body, &responsePlayer); err != nil {
t.Errorf("json format error = %v", err)
return
}
// 期待するjsonかどうかテスト
if !reflect.DeepEqual(responsePlayer, tt.want) {
t.Errorf("playerHandler.Post() = %v, want %v", responsePlayer, tt.want)
}
})
}
}
func Test_playerHandler_Put(t *testing.T) {
type args struct {
id int
requestPlayer *requestPlayer
}
tests := []struct {
name string
args args
want responsePlayer
}{
{name: "Put handler", args: args{1, &requestPlayer{FirstName: "Klay", LastName: "Thompson", Position: []string{"SG"}, Team: "GSW"}}, want: responsePlayer{ID: 1, FirstName: "Klay", LastName: "Thompson", Team: "GSW"}},
}
e := echo.New()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// test request作成
requestJson, _ := json.Marshal(tt.args.requestPlayer)
req := httptest.NewRequest(http.MethodPut, "/player", bytes.NewBuffer(requestJson))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
c.SetParamNames("id")
c.SetParamValues(strconv.Itoa(tt.args.id))
// mock作成し必要なものだけ注入。
mockPlayerRepo := new(MockIPlayerRepository)
mockPlayerRepo.On("Update", &model.Player{ID: 1, FirstName: "Klay", LastName: "Thompson", Team: "GSW"}).Return(&model.Player{ID: 1, FirstName: "Klay", LastName: "Thompson", Team: "GSW"}, nil)
mockPositionRepository := new(MockIPositionRepository)
mockPositionRepository.On("Create", &model.Position{PlayerID: 1, Name: "SG"}).Return(&model.Position{ID: 1, PlayerID: 1, Name: "SG"}, nil)
mockPositionRepository.On("DeleteByPlayerID", 1).Return(nil)
mockPlayerQueryService := new(MockIPlayerQueryService)
playerUsecase := usecase.NewPlayerUsecase(mockPlayerRepo, mockPositionRepository, mockPlayerQueryService)
playerHandler := NewPlayerHandler(playerUsecase)
playerHandler.Put()(c)
// test request実行
res := rec.Result()
defer res.Body.Close()
// 結果が正常終了でなければエラー
if res.StatusCode < 200 || res.StatusCode >= 300 {
t.Errorf("response StatusCode error = %v", res.StatusCode)
return
}
// response bodyをparse
body, err := ioutil.ReadAll(res.Body)
if err != nil {
t.Errorf("response body error = %v", err)
return
}
// jsonに変換
var responsePlayer responsePlayer
if err := json.Unmarshal(body, &responsePlayer); err != nil {
t.Errorf("json format error = %v", err)
return
}
// 期待するjsonかどうかテスト
if !reflect.DeepEqual(responsePlayer, tt.want) {
t.Errorf("playerHandler.Post() = %v, want %v", responsePlayer, tt.want)
}
})
}
}
func Test_playerHandler_Delete(t *testing.T) {
type args struct {
id int
requestPlayer *requestPlayer
}
tests := []struct {
name string
args int
wantErr bool
}{
{name: "Delete handler", args: 1, wantErr: false},
}
e := echo.New()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// test request作成
req := httptest.NewRequest(http.MethodDelete, "/player", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
c.SetParamNames("id")
c.SetParamValues(strconv.Itoa(tt.args))
// mock作成し必要なものだけ注入。
mockPlayerRepo := new(MockIPlayerRepository)
mockPlayerRepo.On("Delete", 1).Return(nil)
mockPositionRepository := new(MockIPositionRepository)
mockPositionRepository.On("DeleteByPlayerID", 1).Return(nil)
mockPlayerQueryService := new(MockIPlayerQueryService)
playerUsecase := usecase.NewPlayerUsecase(mockPlayerRepo, mockPositionRepository, mockPlayerQueryService)
playerHandler := NewPlayerHandler(playerUsecase)
playerHandler.Delete()(c)
// test request実行
res := rec.Result()
defer res.Body.Close()
// 結果が正常終了でなければエラー
if res.StatusCode < 200 || res.StatusCode >= 300 {
t.Errorf("response StatusCode error = %v", res.StatusCode)
return
}
// response bodyをparse
body, err := ioutil.ReadAll(res.Body)
if err != nil {
t.Errorf("response body error = %v", err)
return
}
fmt.Printf("%s", body)
})
}
}
ちょっとした確認したいときとか、Go初心者だと大変だなぁと思った。例えば、コンソールに表示するときもアドレスが表示されて、実体はどうやって表示するんだっけ?
とか、toString()みたいにstring()でイケるんでしょ?と思ってたら違ったり。
これ書いてて Itoa
ってInteger to Alphabet で数字を文字にってことか。イトアってなんやねん。覚えられん。と思ってたwww
感想
今回は各レイヤーでのテストがどうなるのかサンプル的に作ってみただけなので、テストケースは全然足りない。だけど、このくらい経験しておけば、あとは応用で何とかなるのかなぁ。
ただ、やっぱりUnit Testって大変だなぁと思う。いろんなテストツールが用意されてるんだけど、その使い方調べたりしてると、実際のプログラムの倍の時間がかかる。
ちょっとしたプログラムだとUnit Testは作んなくて良いかなぁってなる。でもつくっとかないと、変化があった時に大変。チーム開発でも大変なことになりえる。
でもなー、100%の自信があるメソッドに対してテストプログラム書くのに倍の時間かけるのもなぁと言う思いもある。
とは言え、なんだかんだ苦労したことで、Goの理解が深まった。これは良かった。