統合テストにTestcontainersはいかがですか？

こんにちは！株式会社Schoo 新卒2年目の [@hiroto_0411](https://x.com/hiroto_0411)です！
私の所属するチームではバックエンドの統合テスト(インテグレーションテスト)を行うためにTestcontainersというライブラリを導入しました。
その際にTestcontainersがどのようなライブラリなのか調べたので、簡単に特徴と使い方をまとめてみようと思います！

## この記事でわかること
- Testcontainersってなに？
- 単体テストや統合テストでTestcontainersを使う方法
- Github ActionsでTestcontainersを利用したCIをする方法

## Testcontainersとは
Testcontainersは**テスト中に必要な外部サービス（DB、メッセージキュー、API）などDockerコンテナで実行できるものを軽量で使い捨て可能なインスタンスとして提供してくれます。**
Dockerコンテナにラップされた実際のサービスをプログラムから容易かつ軽量に起動し、ライフサイクルを管理することで、モックやインメモリサービスに頼らず、本番環境で使用するのと同等のサービスに依存した単体テストや統合テストを構築できます。分離された環境でのテスト実行、自動的なリソースのクリーンアップ、wait strategyによる安定したテストの実現、そしてCI/CD環境での容易な利用といった特徴もあります。Testcontainersを利用するには、Dockerがインストールされている必要があります。

https://testcontainers.com/

https://testcontainers.com/getting-started/

## Testcontainersの良いところ
- モックやインメモリサービスに頼らず、本番環境で使用するのと同等のサービスに依存した単体テストや統合テストを容易かつ軽量に行うことができる
- 既存のDockerイメージ、Dockerfile、Docker Composeファイルからコンテナを起動できるため、あらゆるDocker化されたサービスのテストで使用することが可能
- テストケースごとに、使い捨てのコンテナを簡単に用意することができる(テストケースごとにDockerfileなどを用意しなくても良い)
- テスト実行後に作成されたコンテナ、ボリューム、ネットワークを自動的に削除してくれる
- 様々なサービス(MySQL, Redis, LocalStack など)のモジュールが用意されており、自分で詳細の設定をせずに利用することができる(モジュールの使い方は後述します)
    - [利用できるモジュール](https://testcontainers.com/modules/)
- 多くの言語(Go, PHP, Python, Node.js, Ruby, Java など)に対応している
    - [利用できる言語](https://testcontainers.com/getting-started/#supported-languages-and-prerequisites)
- GitHub Actions, GitLab CI/CD などの主要なCI/CD環境で利用しやすいように設計されている

:::note info
テストで依存先を再現したい時にプログラム中から簡単に再現させられるライブラリである
:::

## Testcontainersを使ってテストを書いてみる
今回はGoの単体テストでTestcontainersを使い、MySQLを使ったテストを書いてみます。
実際の業務ではinterface層やhandler層など複数の関数を呼び出す必要があるテストや、統合テスト寄りのテストを書くような場面で使いたいことが多いかと思います。

https://golang.testcontainers.org/modules/mysql/

### 1. userIDをもとにuser情報をDBから取得するメソッドを作成
~~~go: user.go
package handler

import (
	"database/sql"
	"fmt"

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

type User struct {
	ID    string
	Name  string
	Email string
}

type UserRepository struct {
	DB *sql.DB
}

func NewUserRepository(db *sql.DB) *UserRepository {
	return &UserRepository{DB: db}
}

func (r *UserRepository) FetchUserByID(userID string) (*User, error) {
	row := r.DB.QueryRow("SELECT id, name, email FROM users WHERE id = ?", userID)

	var user User
	err := row.Scan(&user.ID, &user.Name, &user.Email)
	if err != nil {
		if err == sql.ErrNoRows {
			return nil, fmt.Errorf("no user found with id %s", userID)
		}
		return nil, err
	}

	return &user, nil

}

~~~

### 2. Testcontainersを使ってコンテナを起動させる関数を作成
Testcontainersを使ってテスト中で使うコンテナを起動させる関数を作成しようと思います。今回は、Testcontainersが用意してくれているモジュールを使った実装と使わない実装両方の例を作成してみました。
ここではテストに必要な最低限の設定を行なっています。詳細な設定も行うことができる設定になっています。詳細設定を行いたい場合は以下を参照してみてください。

https://golang.testcontainers.org/features/creating_container/

~~~go: モジュールなし
type MysqlContainer struct {
	mysqlContainer testcontainers.Container
	URI            string
}

func setupMysqlContainer(ctx context.Context, schemaPath string) (*MysqlContainer, error) {
	// MySQLコンテナの起動設定を作成（環境変数・ポート・初期SQLファイルの配置など）
	req := testcontainers.ContainerRequest{
		Image:        "mysql:8.0.36",
		ExposedPorts: []string{"3306/tcp", "33060/tcp"},
		Env: map[string]string{
			"MYSQL_USER":          "user",
			"MYSQL_ROOT_PASSWORD": "root",
			"MYSQL_PASSWORD":      "password",
			"MYSQL_DATABASE":      "test",
		},
		WaitingFor: wait.ForLog("port: 3306  MySQL Community Server"),
		Files: []testcontainers.ContainerFile{
			{
				HostFilePath:      schemaPath,
				ContainerFilePath: "/docker-entrypoint-initdb.d/schema.sql",
				FileMode:          0o644,
			},
		},
	}

	// コンテナ起動リクエストを作成
	genericContainerReq := testcontainers.GenericContainerRequest{
		ContainerRequest: req,
		Started:          true,
	}

	// コンテナを起動
	mysqlContainer, err := testcontainers.GenericContainer(ctx, genericContainerReq)
	if err != nil {
		return nil, fmt.Errorf("failed to start container: %s", err)
	}

	// コンテナのホストとポートを取得
	host, err := mysqlContainer.Host(ctx)
	if err != nil {
		return nil, fmt.Errorf("failed to get container host: %s", err)
	}

	port, err := mysqlContainer.MappedPort(ctx, "3306")
	if err != nil {
		return nil, fmt.Errorf("failed to get container port: %s", err)
	}

	// MySQLに接続
	dsn := fmt.Sprintf("user:password@tcp(%s:%s)/test", host, port.Port())

	mysqlC := &MysqlContainer{
		mysqlContainer: mysqlContainer,
		URI:            dsn,
	}

	return mysqlC, nil
}
~~~

~~~go: モジュールあり
type MysqlContainer struct {
	mysqlContainer testcontainers.Container
	URI            string
}

func setupMysqlContainerWithModule(ctx context.Context, schemaPath string) (*MysqlContainer, error) {
	mysqlContainer, err := mysql.Run(ctx,
		"mysql:8.0.36",
		mysql.WithScripts(schemaPath),

		// github.com/testcontainers/testcontainers-go/modules/mysqlを使ってデータベース名やユーザー名、パスワードを指定することも可能
		// mysql.WithDatabase("test"),
		// mysql.WithUsername("user"),
		// mysql.WithPassword("password"),
        
        //モジュールで設定されているデフォルトの値を変えることもできる
        //testcontainers.WithExposedPorts("8080/tcp", "9090/tcp"))
	)

	if err != nil {
		return nil, fmt.Errorf("failed to start container: %s", err)
	}

	// MySQLに接続
	dsn, err := mysqlContainer.ConnectionString(ctx)
	if err != nil {
		return nil, fmt.Errorf("failed to get connection string: %s", err)
	}

	mysqlC := &MysqlContainer{
		mysqlContainer: mysqlContainer,
		URI:            dsn,
	}

	return mysqlC, nil
}
~~~
モジュールを実装してコンテナを起動した方が、モジュール内でMySQLコンテナの初期設定をよしなに行なってくれているため、シンプルな実装になっています。色々なサービスのモジュールが用意されているので、基本的には用意されているモジュールを使う方針で問題なさそうな印象です。モジュールが用意されていない場合はOSSで自分で作成することもできそうです。

モジュールでどのような初期設定になっているかは以下のリポジトリから確認できます。

https://github.com/testcontainers/testcontainers-go/blob/main/modules/mysql/mysql.go

https://golang.testcontainers.org/modules/mysql/

### 3. テストコードの作成
~~~go: user_test.go
package handler

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

	"github.com/testcontainers/testcontainers-go"
	"github.com/testcontainers/testcontainers-go/modules/mysql"
)

func TestUser_FetchUserByID(t *testing.T) {
	ctx := context.Background()

	// コンテナ内で実行したいschema.sqlのパスを指定
	schemaPath, err := filepath.Abs("../models/schema.sql")
	if err != nil {
		t.Fatal(err)
	}

	// Testcontainersを使ってMySQLコンテナを起動させる関数を呼び出しコンテナを起動
	// 関数の実装については2. Testcontainersを使ってコンテナを起動させる関数を作成を参照
	mysqlContainer, err := setupMysqlContainerWithModule(ctx, schemaPath)
	if err != nil {
		t.Fatalf("failed to start MySQL container: %s", err)
	}

	// テスト終了後にコンテナをクリーンアップ
	testcontainers.CleanupContainer(t, mysqlContainer.mysqlContainer)

	db, err := sql.Open("mysql", mysqlContainer.URI)
	if err != nil {
		t.Fatal(err)
	}
	defer db.Close()

	r := NewUserRepository(db)

	// テストケースを作成
	type args struct {
		userID string
	}
	tests := []struct {
		name    string
		args    args
		want    *User
		wantErr bool
	}{
		{
			name: "userIDが1の時にデータベースからuserID 1に対応したユーザーを取得できる",
			args: args{
				userID: "1",
			},
			want: &User{
				ID:    "1",
				Name:  "yamada taro",
				Email: "taro.yamada@example.com",
			},
			wantErr: false,
		},
		{
			name: "データベースに登録していないuserIDを指定した時にエラーが発生する",
			args: args{
				userID: "999",
			},
			want:    nil,
			wantErr: true,
		},
	}

	// テスト実行
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			got, err := r.FetchUserByID(tt.args.userID)
			if (err != nil) != tt.wantErr {
				t.Errorf("User.FetchUserByID() error = %v, wantErr %v", err, tt.wantErr)
				return
			}
			if !reflect.DeepEqual(got, tt.want) {
				t.Errorf("User.FetchUserByID() = %v, want %v", got, tt.want)
			}
		})
	}
}

~~~

その他使用したファイル

<details>
<summary>schema.sql</summary>

~~~sql: schema.sql
CREATE TABLE users (
  id CHAR(36) PRIMARY KEY,         
  name VARCHAR(100) NOT NULL,
  email VARCHAR(255) UNIQUE NOT NULL,
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

INSERT INTO users (id, name, email) VALUES
('1', 'yamada taro', 'taro.yamada@example.com'),
('2', 'suzuki hanako', 'hanako.suzuki@example.com');
~~~

</details>




### 4. テスト実行してみる
~~~ : 実行
go test ./...
~~~

~~~ :結果
?       github.com/hiroto1220/go-playground/testcontainers      [no test files]
ok      github.com/hiroto1220/go-playground/testcontainers/models       13.643s
~~~

今回は、GitHub Actionsでもテストを実行してみました。
<details><summary>.github/workflows/ci.yaml</summary>

~~~yaml: .github/workflows/ci.yaml
name: Go Test (with Testcontainers)

on:
  push:
    branches:
      - main
      - feature/**
  pull_request:
    branches:
      - main
      - feature/**

jobs:
  test:
    name: Run Go Tests in testcontainers module
    runs-on: ubuntu-latest

    steps:
      - name: Checkout repository
        uses: actions/checkout@v3

      - name: Set up Go environment
        uses: actions/setup-go@v4
        with:
          go-version: '1.23'

      - name: Install dependencies
        run: go mod tidy
        working-directory: ./testcontainers


      - name: Run Go tests
        run: go test -v ./...
        working-directory: ./testcontainers

~~~

</details>

結果の詳細は以下のリンクから確認できます。
https://github.com/hiroto1220/go-playground/actions/runs/14483779833/job/40625523566

## まとめ
今回はGoのMySQLを利用した単体テストにTestcontainersを使ってみました。ローカルやCI環境でMySQLやRedisを事前に用意する必要がなく、テストごとにクリーンな環境が作られるため、状態に依存しない再現性の高いテストを書くことができ単体テスト・統合テストの両方で活用できると感じました。
テストにおける依存サービスの取り扱いに課題を感じている方は、ぜひ選択肢の1つにしてみてはいかがでしょうか！

---
**Schooでは一緒に働く仲間を募集しています！**

https://corp.schoo.jp/careers/graduates

https://corp.schoo.jp/careers