🕌

GoのDBテスト(MySQL)を並列化して実行時間を短縮する

に公開

課題

Goで書かれたWebアプリケーションで、RDBMSはMySQLを使っており、自動テスト(Goテスト)ではモックではなくMySQLを使っている。
実行時間がローカル・リモートで100〜200秒ほどかかっていた。

結果

ソースコードの変更のみで、ローカルでは約1/6、リモートで約1/8まで実行時間を短縮できた。

環境

  • 言語: Go1.24
  • RDB: MySQL 8.0

既存テスト

  • データベースは1つ
  • コネクションはテストケースで共通
  • テストケース毎にマイグレーションやシード・終了時にテーブルのレコード全削除
  • テストケース毎に直列に実行

サンプルコード

package datastore

import (
	"database/sql"
	"fmt"
	"os/exec"
	"sync"
	"testing"

	_ "github.com/go-sql-driver/mysql"
	"github.com/google/uuid"
	mysqltest "github.com/lestrrat-go/test-mysqld"
	migrate "github.com/rubenv/sql-migrate"
)

var (
	testMysqld   *mysqltest.TestMysqld
	templateDB   string
	templateOnce sync.Once
)

func TestMain(m *testing.M) {
	var err error
	testMysqld, err = mysqltest.NewMysqld(nil)
	if err != nil {
		panic(fmt.Sprintf("failed to start mysqld: %v", err))
	}

	defer testMysqld.Stop()
	m.Run()
}

type DBConn struct {
	DB  *sql.DB
}

// initTemplateDB はマイグレーション済みのテンプレートDBを一度だけ作成する
func initTemplateDB(t *testing.T) {
	t.Helper()

	templateOnce.Do(func() {
		// mysqlデータベースに接続
		rootDB, err := sql.Open("mysql", buildDSN("mysql"))
		if err != nil {
			t.Fatalf("failed to open mysql: %v", err)
		}
		defer rootDB.Close()

		// テンプレートDB作成
		templateDB = "template_testdb"
		if _, err = rootDB.Exec(fmt.Sprintf("CREATE DATABASE IF NOT EXISTS `%s`", templateDB)); err != nil {
			t.Fatalf("failed to create template db: %v", err)
		}

		// テンプレートDBに接続してマイグレーション実行
		db, err := sql.Open("mysql", buildDSN(templateDB))
		if err != nil {
			t.Fatalf("failed to open template db: %v", err)
		}
		defer db.Close()

		if _, err = migrate.Exec(db, "mysql", migrate.FileMigrationSource{Dir: "../migrations"}, migrate.Up); err != nil {
			t.Fatalf("failed to run migrations on template db: %v", err)
		}
	})
}

func buildDSN(dbname string) string {
	return testMysqld.DSN(
		mysqltest.WithDbname(dbname),
		mysqltest.WithUser("root"),
		mysqltest.WithPassword(""),
		mysqltest.WithPort(0),
	) + "?parseTime=true&loc=Asia%2FTokyo"
}

func restoreDatabase(t *testing.T, dbName string) {
	t.Helper()

	sock := testMysqld.Socket()
	dumpCmd := exec.Command("mysqldump", "--socket="+sock, "-u", "root", templateDB)
	restoreCmd := exec.Command("mysql", "--socket="+sock, "-u", "root", dbName)

	// dumpの標準出力をrestoreの標準入力に接続
	pipe, err := dumpCmd.StdoutPipe()
	if err != nil {
		t.Fatalf("failed to create pipe: %v", err)
	}
	restoreCmd.Stdin = pipe

	if err := dumpCmd.Start(); err != nil {
		t.Fatalf("failed to start mysqldump: %v", err)
	}
	if err := restoreCmd.Start(); err != nil {
		t.Fatalf("failed to start mysql restore: %v", err)
	}
	if err := dumpCmd.Wait(); err != nil {
		t.Fatalf("mysqldump failed: %v", err)
	}
	if err := restoreCmd.Wait(); err != nil {
		t.Fatalf("mysql restore failed: %v", err)
	}
}

// SetUpDB は呼び出し毎にデータベースを作成し、任意のシードクエリを実行してから接続を返す。
// 作成するデータベースはパフォーマンス向上のためにテンプレートDBからリストアする。
// example:
//
//	func TestSomething(t *testing.T) {
//		t.Parallel()
//		ctx := context.Background()
//
//		query := "INSERT INTO xxx (...) VALUES (...);"
//		db := SetUpDB(t, query)
//		sut := DataStore{}
//		sut.Something(ctx, db)
//	}
func SetUpDB(t *testing.T, seeds ...string) *DBConn {
	t.Helper()

	if testMysqld == nil {
		t.Fatalf("mysqld is not initialized")
	}

	initTemplateDB(t)

	// 呼び出し毎にデータベース作成
	dbName := "testdb_" + uuid.NewString()
	rootDB, err := sql.Open("mysql", buildDSN("mysql"))
	if err != nil {
		t.Fatalf("failed to open mysql: %v", err)
	}
	defer rootDB.Close()

	if _, err = rootDB.Exec(fmt.Sprintf("CREATE DATABASE `%s`", dbName)); err != nil {
		t.Fatalf("failed to create db: %v", err)
	}

	// テンプレートDBからリストア
	restoreDatabase(t, dbName)

	// 作成したDBに接続
	db, err := sql.Open("mysql", buildDSN(dbName))
	if err != nil {
		t.Fatalf("failed to open test db: %v", err)
	}

	// シードデータを作成
	for _, seed := range seeds {
		if _, err := db.Exec(seed); err != nil {
			t.Fatalf("failed to execute seed (%s): %v", seed, err)
		}
	}

	t.Cleanup(func() {
		db.Close()
		if rootDB, err := sql.Open("mysql", buildDSN("mysql")); err == nil {
			rootDB.Exec(fmt.Sprintf("DROP DATABASE IF EXISTS `%s`", dbName))
			rootDB.Close()
		}
	})

	return &DBConn{
		DB:  db
	}
}

課題・やってないこと

  • テーブル数やテストケース数のスケール:現時点ではテーブル数30程度だが、全テーブルマイグレーション済みのデータベースをテンプレート化していることがボトルネックになるかもしれない
  • テスト実行分割: 一番時間がかかるパッケージとそれ以外でgo testコマンドを分割することで、よりCPUを効率的に使えるかもしれない
  • データベースは使い捨てではなく、使い終わったデータベースを再利用した方が効率的かもしれない

参考

Discussion