Dockerとtestcontainersを利用したMySQLテスト環境の構築と活用ガイド

## はじめに

本記事では、**Go言語でテスト用のMySQL環境を簡単に構築する方法**を解説します。`testcontainers`ライブラリを活用して、Dockerコンテナを使用したMySQLデータベースのセットアップとテストの実施手順を、一行ずつ丁寧に解説します。この記事を読むことで、手軽にテスト環境を構築し、データベース関連のテストを効果的に行えるようになります。

## コードとその解説
完成したコード
```go
package tester

import (
	"context"
	"fmt"
	"log"
	"net"
	"time"

	"github.com/stretchr/testify/suite"
	"github.com/testcontainers/testcontainers-go"
	"github.com/testcontainers/testcontainers-go/wait"

	"go-api-newspaper/app/models"
	"go-api-newspaper/configs"
)

// testcontainersを利用するには、Dockerが起動している必要
// CheckPortは指定されたホストとポートに接続可能かを確認する関数
func CheckPort(host string, port int) bool {
	// 指定されたホストとポートにTCP接続を試みる
	conn, err := net.Dial("tcp", fmt.Sprintf("%s:%d", host, port))
	if conn != nil {
		// 接続が成功した場合は閉じてfalseを返す（ポートが使用中）
		conn.Close()
		return false
	}
	if err != nil {
		// 接続が失敗した場合はtrueを返す（ポートが空いている）
		return true
	}
	return false
}

// WaitForPortは、指定したホストとポートが空くのを待つ関数
func WaitForPort(host string, port int, timeout time.Duration) bool {
	// 現在の時間にタイムアウトを加えた時刻を計算
	deadline := time.Now().Add(timeout)
	for time.Now().Before(deadline) {
		// ポートが空いているかを1秒ごとにチェック
		if CheckPort(host, port) {
			return true
		}
		time.Sleep(1 * time.Second)
	}
	// タイムアウトが経過しても空かなかった場合はfalseを返す
	return false
}

// Mysqlに接続するための構造体
type DBMySQLSuite struct {
	suite.Suite                             // testifyのSuite機能を埋め込む
	mySQLContainer testcontainers.Container // MySQLコンテナのインスタンス
	ctx            context.Context          // コンテナ操作用のコンテキスト
}

// SetupTestContainersは、MySQLコンテナをセットアップする関数
func (suite *DBMySQLSuite) SetupTestContainers() (err error) {
	// Dockerがポートを使用できるまで待機
	WaitForPort(configs.Config.DBHost, configs.Config.DBPort, 10*time.Second)
	suite.ctx = context.Background() // コンテキストを初期化
	req := testcontainers.ContainerRequest{ // コンテナに対する設定のリクエストを作成
		Image: "mysql:8",
		Env: map[string]string{
			"MYSQL_DATABASE":             configs.Config.DBName,
			"MYSQL_USER":                 configs.Config.DBUser,
			"MYSQL_PASSWORD":             configs.Config.DBPassword,
			"MYSQL_ALLOW_EMPTY_PASSWORD": "yes",
		},
		ExposedPorts: []string{fmt.Sprintf("%d:3306/tcp", configs.Config.DBPort)}, // ポートマッピング
		WaitingFor:   wait.ForLog("port: 3306  MySQL Community Server"),           // MySQLの準備完了をログで確認
	}
	// リクエストをもとにコンテナを作成
	suite.mySQLContainer, err = testcontainers.GenericContainer(suite.ctx, testcontainers.GenericContainerRequest{
		ContainerRequest: req,
		Started:          true,
	})

	if err != nil {
		log.Fatal(err.Error())
	}

	return nil
}

// SetupSuiteはテストスイート全体の初期設定を行う関数
func (suite *DBMySQLSuite) SetupSuite() {
	// MySQLコンテナのセットアップを実行
	err := suite.SetupTestContainers()
	suite.Assert().Nil(err)

	// モデルにMySQLデータベースを設定
	err = models.SetDatabase(models.InstanceMySQL)
	suite.Assert().Nil(err)

	for _, model := range models.GetModels() {
		err := models.DB.AutoMigrate(model)
		suite.Assert().Nil(err)
	}
}

// TearDownSuiteはテストスイート全体のクリーンアップを行う関数
func (suite *DBMySQLSuite) TearDownSuite() {
	if suite.mySQLContainer == nil {
		return
	}
	// MySQLコンテナを終了（停止と削除）する
	err := suite.mySQLContainer.Terminate(suite.ctx)
	suite.Assert().Nil(err)
}
```

パッケージでは、`testcontainers`を使ったテスト環境の設定と、MySQLコンテナのライフサイクル管理を行います。

### **Dockerが起動しているか確認する関数**

1. **`CheckPort`**
```go
   func CheckPort(host string, port int) bool {
       conn, err := net.Dial("tcp", fmt.Sprintf("%s:%d", host, port))
       if conn != nil {
           conn.Close()
           return false
       }
       if err != nil {
           return true
       }
       return false
   }
```

   - **目的**: 指定されたホストとポートに接続可能かを確認します。
   - **処理内容**: 
     - `net.Dial`でTCP接続を試みます。
     - 接続が成功した場合は`false`（ポート使用中）を返し、失敗した場合は`true`（ポート空き）を返します。

2. **`WaitForPort`**
```go
   func WaitForPort(host string, port int, timeout time.Duration) bool {
       deadline := time.Now().Add(timeout)
       for time.Now().Before(deadline) {
           if CheckPort(host, port) {
               return true
           }
           time.Sleep(1 * time.Second)
       }
       return false
   }
```

   - **目的**: ポートが空くのを待機します。
   - **処理内容**:
     - 指定したタイムアウトの間、ポートが空くのを毎秒確認します。

### **テストスイートの構造体と初期化**

1. **`DBMySQLSuite`**
```go
   type DBMySQLSuite struct {
       suite.Suite
       mySQLContainer testcontainers.Container
       ctx            context.Context
   }
```

   - **目的**: `suite.Suite`をベースにしたMySQLテストスイートを定義します。
   - **構造**:
     - **`mySQLContainer`**: テスト用MySQLコンテナのインスタンス。
     - **`ctx`**: コンテナ操作のためのコンテキスト。

### **TestcontainersによるMySQLのセットアップ**

1. **コンテナ設定**
```go
   req := testcontainers.ContainerRequest{
       Image: "mysql:8",
       Env: map[string]string{
           "MYSQL_DATABASE":             configs.Config.DBName,
           "MYSQL_USER":                 configs.Config.DBUser,
           "MYSQL_PASSWORD":             configs.Config.DBPassword,
           "MYSQL_ALLOW_EMPTY_PASSWORD": "yes",
       },
       ExposedPorts: []string{fmt.Sprintf("%d:3306/tcp", configs.Config.DBPort)},
       WaitingFor:   wait.ForLog("port: 3306  MySQL Community Server"),
   }
```

   - **目的**: MySQLコンテナの設定。
   - **設定内容**:
     - **`Image`**: 使用するDockerイメージ（MySQLのバージョン8）。
     - **`Env`**: コンテナ内での環境変数を指定。
     - **`ExposedPorts`**: コンテナのポートをホストにマッピング。
     - **`WaitingFor`**: コンテナが指定されたログを出力するまで待機。

2. **コンテナ作成**
```go
   suite.mySQLContainer, err = testcontainers.GenericContainer(suite.ctx, testcontainers.GenericContainerRequest{
       ContainerRequest: req,
       Started:          true,
   })
```

   - **目的**: 設定を基にコンテナを作成し、起動します。


### **テスト環境のセットアップとクリーンアップ**

1. **テストコンテナの初期化**
```go
   func (suite *DBMySQLSuite) SetupSuite() {
       err := suite.SetupTestContainers()
       suite.Assert().Nil(err)

       err = models.SetDatabase(models.InstanceMySQL)
       suite.Assert().Nil(err)

       for _, model := range models.GetModels() {
           err := models.DB.AutoMigrate(model)
           suite.Assert().Nil(err)
       }
   }
```

   - **目的**: テスト用コンテナとデータベースを初期化します。
   - **手順**:
     1. コンテナをセットアップ。
     2. データベース接続を設定。
     3. モデルを自動マイグレーション。

2. **コンテナのクリーンアップ**
```go
   func (suite *DBMySQLSuite) TearDownSuite() {
       if suite.mySQLContainer == nil {
           return
       }
       err := suite.mySQLContainer.Terminate(suite.ctx)
       suite.Assert().Nil(err)
   }
```

   - **目的**: テスト終了時にMySQLコンテナを停止・削除します。

## まとめ

この記事を参考にすることで、あなたのプロジェクトでも効率的なテスト環境を実現できるはずです。ぜひ試してみてください！