Go開発者への道 第7回 他のテストに影響を与えないテストコードの書き方

GOコラム

Go初心者向けに始めた記事の第7回になります。
今回は、Go言語でのテストコードの書き方について説明します。
テストコードの問題として、他のテストのデータの更新の影響を受けたり、テストの実行順序によって成功したり失敗したりすることがあります。今回は、他のテストに影響を与えないようにするための独立したテストコードの書き方についても紹介します。

他のテストコードに影響を与えないテストコードの書き方

結論として、他のテストに影響を与えないテストコードを作成する方法は、テストコードの前後でデータベースの状態を変更しないようにすることです。具体的な実装方法は、テストをトランザクション内で実行し、各テストケースの終了時にはロールバックを行います。

Goのテストの特徴

  • Goのテストコードのファイル名は「対象のテストコードファイル_test」にする。
  • テストメソッド名は「TestXXX」にする。
  • TDT(Table Driven Test)と呼ばれる形式で実装されていて、入力値と期待値などをまとめて定義して、テストの実行箇所を1箇所にまとめている。そうすることで新しくテストケースを追加する時、容易になります。

実装

インストール

テスト結果の比較に使用するgo-cmpをインストール

go get -u github.com/google/go-cmp/cmp

コード

db/mysql.go

テスト用の接続を追加した。
InTxメソッドで、テストを実行する無記名関数が渡され、テストをトランザクション内で実行させ、最後にロールバックをさせている。これにより常にDBの状態がテスト前後で変わらない。

package db

import (
	"context"
	"database/sql"
	"fmt"

	"github.com/SND1231/go-column/setting"
)

func GetDBconnection(dbSetting setting.DB) (*sql.DB, error) {
	var dataSource string = fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?parseTime=true",
		dbSetting.User,
		dbSetting.Password,
		dbSetting.Host,
		dbSetting.Port,
		dbSetting.Name,
	)
	dataSource = dataSource + "&loc=Asia%2FTokyo"
	db, err := sql.Open(dbSetting.Type, dataSource)
	return db, err
}

func NewUnitTestDB() *UnitTestDB {
	conn, err := GetDBconnectionForTest()
	if err != nil {
		panic(err)
	}
	return &UnitTestDB{
		conn: conn,
	}
}

func GetDBconnectionForTest() (*sql.DB, error) {
	dbSetting := setting.DB{
		Type:     "mysql",
		User:     "root",
		Password: "test",
		Host:     "mysql",
		Port:     3306,
		Name:     "unit_test",
	}
	return GetDBconnection(dbSetting)
}

type UnitTestDB struct {
	conn *sql.DB
}

// 他のテストに影響がないように、テスト終了後にロールバックをしてテスト前と同じ状態にする。
func (db *UnitTestDB) InTx(exec func(context.Context, *sql.Tx)) {
	if db.conn == nil {
		panic("connection is nil")
	}

	ctx := context.Background()
	tx, err := db.conn.BeginTx(ctx, &sql.TxOptions{})
	if err != nil {
		panic(err)
	}
	// ロールバックしてテスト中のデータ作成や更新は他のテストに影響ないようにしている。
	defer tx.Rollback()

	exec(ctx, tx)

}

usecase/user_test.go

usecase/user.goのテストコード。
casesで入力値と期待値などをまとめて定義している。
testDB.InTx()の中が実際にテストしている無記名関数。

package usecase

import (
	"context"
	"database/sql"
	"testing"

	"github.com/SND1231/go-column/db"
	"github.com/SND1231/go-column/models"
	"github.com/google/go-cmp/cmp"
	"github.com/google/go-cmp/cmp/cmpopts"

	_ "github.com/go-sql-driver/mysql"
)

func TestAdd(t *testing.T) {
	// テスト用のDB接続取得
	testDB := db.NewUnitTestDB()

	// Inputの情報で作成した後に、作成されたユーザーIDを元にユーザーを取得して、Wantと比較する。
	cases := []struct {
		label string
		Input AddUserInput
		Want  *models.User
	}{
		{
			"ユーザー登録のテスト",
			AddUserInput{
				Name: "John",
				Age:  40,
			},
			&models.User{
				Name: "John",
				Age:  40,
			},
		},
	}

	for _, c := range cases {
		t.Run(c.label, func(t *testing.T) {
			testDB.InTx(func(ctx context.Context, tx *sql.Tx) {
				usecase := NewUserUsecase(ctx, tx)
				output, err := usecase.Add(c.Input)
				if err != nil {
					t.Fatal("error:", err)
				}

				user, _ := models.FindUser(ctx, tx, output.ID)

				// IDは何が入るかわからないので、比較する絡むから外す。
				opts := []cmp.Option{
					cmpopts.IgnoreFields(models.User{}, "ID"),
				}

				// 作成したユーザーと、求める結果の比較をしている。
				// 差があった場合にエラーにしている。
				if diff := cmp.Diff(user, c.Want, opts...); diff != "" {
					t.Errorf(diff)
				}
			})
		})
	}
}

func TestGet(t *testing.T) {
	// テスト用のDB接続取得
	testDB := db.NewUnitTestDB()

	// Getした結果とWantの情報を比較する
	cases := []struct {
		label string
		Input GetUserInput
		Want  GetUserOutput
	}{
		{
			"ユーザー情報取得のテスト",
			GetUserInput{
				ID: 4,
			},
			GetUserOutput{
				ID:   4,
				Name: "taro",
				Age:  25,
			},
		},
	}

	for _, c := range cases {
		t.Run(c.label, func(t *testing.T) {
			testDB.InTx(func(ctx context.Context, tx *sql.Tx) {
				usecase := NewUserUsecase(ctx, tx)
				output, err := usecase.Get(c.Input)
				if err != nil {
					t.Fatal("error:", err)
				}

				// 取得したユーザーと、求める結果の比較をしている。
				// 差があった場合にエラーにしている。
				if diff := cmp.Diff(output, c.Want); diff != "" {
					t.Errorf(diff)
				}
			})
		})
	}
}

schema/test.sql

テスト用に追加した。

--- テスト用のデータベース作成
CREATE DATABASE unit_test;

--- テストデータ
insert into user (name, age) values 
('taro', 25),
('jiro', 23);

Makefile

テストコード実行を追加した。

.PHONY: build
build:
	go build .

.PHONY: sqlboiler
sqlboiler:
	sqlboiler mysql

.PHONY: test
test:
	# 毎回テスト実行時にキャッシュを削除
	go clean -testcache
	# shuffle=onでランダムに実行され、coverでテストのカバレッジを測定している。
	go test -shuffle=on -cover ./usecase

最終的に以下のリポジトリになる。

GitHub - SND1231/go-column at go-column-7
Go コラムのサンプルコードをこちらに残す. Contribute to SND1231/go-column development by creating an account on GitHub.

実行

テスト前の準備

テストの準備として、以下を実施

  1. test.sqlのテスト用のデータベース作成を実行
  2. init.sqlを実行
  3. test.sqlのテストデータを実行

テスト実施

「make test」を実行すると以下のようにテスト結果が出力される。

root@09b67536b671:/work# make test
go clean -testcache
go test -shuffle=on -cover ./usecase
ok      github.com/SND1231/go-column/usecase    0.015s  coverage: 71.4% of statements

まとめ

ここまで7回に渡ってGo開発者として必要な基礎知識は一通り解説してきました。
今後必須だと思った知識が分かり次第、「Go開発者への道」の続きを書いていこうと思います。
「Go開発者への道」以外にも、Goのselectや、mockを使用する方法の記事について書いてますので興味がありましたら読んでいただけたらと思います。

【おすすめ記事のリンク】

コメント

タイトルとURLをコピーしました