📑

GoにおけるDBテスト dockertest vs testcontainers 比較してみた

2023/01/07に公開

JavaやKotlinを書いていたときにtestcontainersというライブラリが非常に便利だったのでGoでも使おうと思ったらdockertestというパッケージがあるようでどっち使ったらいいのかわからなかったのでどっちも使って比較してみたのでその備忘録です。

今回はDBにmongoを使用し、以下のようなUseRepositoryのテストをtestcontainersおよびdockertestで書いてみます。UserRepositoryはユーザー作成とIdでの検索の2つの関数を持つこととします。

user.go
package datastore

import (
	"JY8752/demo-app/constant"
	model "JY8752/demo-app/domain/model/user"
	repository "JY8752/demo-app/domain/repository/user"
	applicationerror "JY8752/demo-app/error"
	datastore "JY8752/demo-app/infrastructure/datastore/mongo"
	"context"
	"fmt"
	"time"

	"go.mongodb.org/mongo-driver/bson"
	"go.mongodb.org/mongo-driver/bson/primitive"
)

const COLLECTION_NAME = "Users"

type user struct {
	Id        primitive.ObjectID `bson:"_id"`
	Name      string             `bson:"nm"`
	UpdatedAt time.Time          `bson:"updAt"`
	CreatedAt time.Time          `bson:"crtAt"`
}

type userRepository struct {
	client *datastore.MongoClient
}

func NewUserRepository(client *datastore.MongoClient) repository.UserRepository {
	return &userRepository{client: client}
}

func (u *userRepository) Create(ctx context.Context, name string, time time.Time) (string, error) {
	doc := &user{Id: primitive.NewObjectID(), Name: name, UpdatedAt: time, CreatedAt: time}
	result, err := u.client.GetDB(constant.MONGO_MAIN_DB).Collection(COLLECTION_NAME).InsertOne(ctx, doc)

	if err != nil {
		return "", applicationerror.NewApplicationError("Fail create user.", err)
	}

	if oid, ok := result.InsertedID.(primitive.ObjectID); ok {
		return oid.Hex(), nil
	}

	return "", applicationerror.NewApplicationError(fmt.Sprintf("Fail cast to objectId. result: %v\n", result), nil)
}

func (u *userRepository) FindById(ctx context.Context, id string) (*model.User, error) {
	oid, err := primitive.ObjectIDFromHex(id)
	if err != nil {
		return nil, applicationerror.NewApplicationError(fmt.Sprintf("argument id is not ObjectId. id: %s\n", id), err)
	}

	filter := bson.D{{Key: "_id", Value: oid}}
	var user user
	if err := u.client.GetDB(constant.MONGO_MAIN_DB).Collection(COLLECTION_NAME).FindOne(ctx, filter).Decode(&user); err != nil {
		return nil, applicationerror.NewApplicationError(fmt.Sprintf("Fail findById id: %s\n", id), err)
	}

	return &model.User{
		Id:        user.Id.Hex(),
		Name:      user.Name,
		UpdatedAt: user.UpdatedAt,
		CreatedAt: user.CreatedAt,
	}, nil
}

testcontainersとは

https://golang.testcontainers.org/

テストコード上でコンテナの起動・停止まで行いテストを実行できるJavaのライブラリ。JavaだけでなくPython, Node, Rustなどでも提供されておりGoでもパッケージが用意されている。testcontainersはRyukという名前の監視用のコンテナが必ず立ち上がりいい感じに使用済みのコンテナの破棄などを自動でやってくれる。

以下パッケージをインストール。

go get github.com/testcontainers/testcontainers-go

testcontainersでテストを書いてみる

以下のようにmonogコンテナのセットアップを準備する。

package container_testcontainers

import (
	"context"
	"time"

	"github.com/docker/go-connections/nat"
	"github.com/testcontainers/testcontainers-go"
	"github.com/testcontainers/testcontainers-go/wait"
)

type mongoContainer struct {
	testcontainers.Container
}

func SetupMongo(ctx context.Context) (*mongoContainer, error) {
	port, _ := nat.NewPort("", "27017")
	timeout := 2 * time.Minute // default

	req := testcontainers.ContainerRequest{
		Image:        "mongo:latest", // dockerイメージ
		ExposedPorts: []string{"27017/tcp"}, // port
		Env: map[string]string{
			"MONGO_INITDB_ROOT_USERNAME": "user", // 初期ユーザー
			"MONGO_INITDB_ROOT_PASSWORD": "password", // 初期ユーザーパスワード
		},
		WaitingFor: wait.ForListeningPort(port).WithStartupTimeout(timeout), // 起動まで待機
	}

	container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
		ContainerRequest: req,
		Started:          true,
	})
	if err != nil {
		return nil, err
	}

	return &mongoContainer{Container: container}, nil
}

上記のセットアップはTestMainでテストを開始する前に実行し、実行したコンテナに接続する。

package datastore

import (
	"context"
	"fmt"
	"log"
	"os"
	"testing"
	"time"

	repository "JY8752/demo-app/domain/repository/user"
	datastore "JY8752/demo-app/infrastructure/datastore/mongo"
	container_testcontainers "JY8752/demo-app/test/container/testcontainers"

	"go.mongodb.org/mongo-driver/mongo"
	"go.mongodb.org/mongo-driver/mongo/options"
)

var rep repository.ItemRepository

func TestMain(m *testing.M) {
	ctx := context.Background()
	container, err := container_testcontainers.SetupMongo(ctx)
	if err != nil {
		log.Fatal(err)
	}

	host, _ := container.Host(ctx)
	p, _ := container.MappedPort(ctx, "27017/tcp")

	connectionString := fmt.Sprintf("mongodb://user:password@%s:%d/?connect=direct", host, uint(p.Int()))
	mongoClient, err := mongo.Connect(ctx, options.Client().ApplyURI(
		connectionString,
	))
	if err != nil {
		log.Fatal(err)
	}

	client := datastore.NewMongoClient(mongoClient)
	rep = NewItemRepository(client)

	code := m.Run()

	if err = mongoClient.Disconnect(ctx); err != nil {
		log.Fatal(err)
	}

	os.Exit(code)
}

func TestUser(t *testing.T) {
	time := time.Date(2022, 1, 3, 0, 0, 0, 0, time.UTC)

	id, err := rep.Create(context.Background(), "user", time)
	if err != nil {
		t.Fatalf("fail create user. err: %s\n", err.Error())
	}

	user, err := rep.FindById(context.Background(), id)
	if err != nil {
		t.Fatalf("fail find user. err: %s\n", err.Error())
	}

	if user.Id != id {
		t.Fatalf("expect id is %s, but %s\n", id, user.Id)
	}

	if user.Name != "user" {
		t.Fatalf("expect name is 'user', but %s\n", user.Name)
	}

	if user.UpdatedAt != time {
		t.Fatalf("expect updatedAt is %v, but %v\n", time, user.UpdatedAt)
	}

	if user.CreatedAt != time {
		t.Fatalf("expect createdAt is %v, but %v\n", time, user.CreatedAt)
	}
}

実行結果

2023/01/07 11:08:48 github.com/testcontainers/testcontainers-go - Connected to docker:
  Server Version: 20.10.17
  API Version: 1.41
  Operating System: Docker Desktop
  Total Memory: 1973 MB
2023/01/07 11:08:48 Starting container id: f21c2c07a63d image: docker.io/testcontainers/ryuk:0.3.4
2023/01/07 11:08:48 Waiting for container id f21c2c07a63d image: docker.io/testcontainers/ryuk:0.3.4
2023/01/07 11:08:48 Container is ready id: f21c2c07a63d image: docker.io/testcontainers/ryuk:0.3.4
2023/01/07 11:08:48 Starting container id: d2d1f0f698b9 image: mongo:latest
2023/01/07 11:08:49 Waiting for container id d2d1f0f698b9 image: mongo:latest
2023/01/07 11:08:50 Container is ready id: d2d1f0f698b9 image: mongo:latest
=== RUN   TestIUser
--- PASS: TestUser (2.53s)
PASS
ok      JY8752/demo-app/infrastructure/datastore/mongo/user    4.370s

ryukという名前のコンテナとmongoコンテナの2つが起動していることがわかります。
テストも問題なくパスしています。

dockertestとは

https://github.com/ory/dockertest

testcontainers同様、テストコード内でdockerコンテナを起動しテストを書くことができるGo製のテストツール。スター数は執筆時点でtestcontainersの倍くらいある。リリースがdockertestは2016年ごろと比較してtestcontainersが2019年あたりなのとバージョンもまだ試筆時点で0.17.0なのでtestcontainers-goの方が後発でまだ開発中なイメージ。

以下パッケージをインストール。

go get -u github.com/ory/dockertest/v3

dockertestでテストを書いてみる

以下のようなdockertestの処理をラップした関数を用意します。

package container_dockertest

import (
	"context"
	"fmt"
	"log"

	"github.com/ory/dockertest/v3"
	"github.com/ory/dockertest/v3/docker"
	"go.mongodb.org/mongo-driver/mongo"
	"go.mongodb.org/mongo-driver/mongo/options"
)

func Start() (*mongo.Client, func(), error) {
	// uses a sensible default on windows (tcp/http) and linux/osx (socket)
	pool, err := dockertest.NewPool("")
	if err != nil {
		log.Printf("Could not construct pool: %s\n", err)
		return nil, nil, err
	}

	// uses pool to try to connect to Docker
	err = pool.Client.Ping()
	if err != nil {
		log.Printf("Could not connect to Docker: %s", err)
		return nil, nil, err
	}

	runOptions := &dockertest.RunOptions{
		Repository: "mongo",
		Tag:        "latest",
		Env: []string{
			"MONGO_INITDB_ROOT_USERNAME=user",
			"MONGO_INITDB_ROOT_PASSWORD=password",
		},
	}

	resource, err := pool.RunWithOptions(runOptions,
		func(hc *docker.HostConfig) {
			hc.AutoRemove = true
			hc.RestartPolicy = docker.RestartPolicy{
				Name: "no",
			}
		},
	)

	port := resource.GetPort("27017/tcp")

        // 起動するまで待機
	var dbClient *mongo.Client
	pool.Retry(func() error {
		dbClient, err = mongo.Connect(
			context.TODO(),
			options.Client().ApplyURI(
				fmt.Sprintf("mongodb://user:password@localhost:%s", port),
			),
		)
		if err != nil {
			return err
		}
		return dbClient.Ping(context.TODO(), nil)
	})

	if err != nil {
		log.Printf("Could not connect to docker: %s", err)
		return nil, nil, err
	}

	fmt.Println("start mongo container🐳")

        // mongoクライアントとクローズ関数を返却
	return dbClient, func() { close(dbClient, pool, resource) }, nil
}

func close(m *mongo.Client, pool *dockertest.Pool, resource *dockertest.Resource) {
	// disconnect mongodb client
	if err := m.Disconnect(context.TODO()); err != nil {
		panic(err)
	}

	// When you're done, kill and remove the container
	if err := pool.Purge(resource); err != nil {
		panic(err)
	}

	fmt.Println("close mongo container🐳")
}

テストはtestcontainers同様、TestMainに記述しテスト開始前にコンテナを起動する。

package datastore

import (
	repository "JY8752/demo-app/domain/repository/user"
	datastore "JY8752/demo-app/infrastructure/datastore/mongo"
	container "JY8752/demo-app/test/container/dockertest"
	"context"
	"log"
	"os"
	"testing"
	"time"
)

var rep repository.UserRepository

func TestMain(m *testing.M) {
	mongoClient, close, err := container.Start()
	if err != nil {
		log.Fatal(err)
	}

	client := datastore.NewMongoClient(mongoClient)

	rep = NewUserRepository(client)

	code := m.Run()

	close()

	os.Exit(code)
}

func TestUser(t *testing.T) {
  // 略
}

実行結果

start mongo container🐳
=== RUN   TestUser
--- PASS: TestUser (0.01s)
PASS
close mongo container🐳
ok  	JY8752/demo-app/infrastructure/datastore/mongo/user	3.707s

testcontainers同様、問題なくパスしました。

まとめ

dockertestの方が若干早い

何回か実行しましたがdockertestの方が実行速度は速かったです。これがtestcontainersの方が起動コンテナ数が多いからなのか詳しくはわからないのですが時間がかかるのはコンテナの起動と破棄でテストケースの処理時間には影響が出ないと思うのでそこまで深く考えなくてもいいかなと思ってます。ただ、今回はテスト1ケースでしか試せていないのでテスト数が増えてきたときにもっと差がでるかもしれません。

処理をラップした関数はあったほうがいい

テストごとに毎回書くことになるので、どちらを使うにしろラップした関数のようなものを用意したほうがいい。testcontainersの方が記述が楽な印象を受けたけどラップしておけば毎回書かなくていいのでどっちを使ってもいいかなという印象。

dockertestの方が枯れていそう?

Java製のtestcontainersがリリースされたのは2015年ごろではあるが、Go製のtestcontainersが使えるよになったのは2019年ごろのためtestcontainers-goが出てくる前にGoでテストコード内でコンテナを起動する場合にはdockertestが採用されることが多かったのだと思います。実際dockertestの方がネットでも情報は多そう。複雑なことをしなければそんなにハマるポイントもないような気がするがdockertestを採用したほうがハマったときに情報が多いかもしれない。ただ、testcontainersはまだ開発中な感じもするのでこれからどんどん良くなっていくような気はする。

結論

どっちでもいい

個人的にはJavaでだいぶお世話になったのでtestcontainers推し。実際のプロダクトで採用するならdockertestの方がいいかもしれないが、DBのテストをするだけならどっちを使ってもいいと思います。

参考

dockertest x mongoの書き方参考にさせていただきました
https://qiita.com/Domao/items/37f571f96ac7dd5ba3cd

おまけ

検証するなかではまったところ

dockertestのコンテナ破棄が終わらない

この処理でPurge()を実行したあとにDisconnect()を書いていたのだけどテスト完了するのに30秒近くかかってしまってハマった。以下のようにmongo切断した後にPurge()するようにしたら解決した。でも、dockertestのexampleがPurge()してからmongo切断してるのでよくわかってない。

func close(m *mongo.Client, pool *dockertest.Pool, resource *dockertest.Resource) {
	// disconnect mongodb client
	if err := m.Disconnect(context.TODO()); err != nil {
		panic(err)
	}

	// When you're done, kill and remove the container
	if err := pool.Purge(resource); err != nil {
		panic(err)
	}

	fmt.Println("close mongo container🐳")
}

dockertesetのAutoRemoveが機能してない?

ここ

	resource, err := pool.RunWithOptions(&dockertest.RunOptions{
		Repository: "mongo",
		Tag:        "5.0",
		Env: []string{
			// username and password for mongodb superuser
			"MONGO_INITDB_ROOT_USERNAME=root",
			"MONGO_INITDB_ROOT_PASSWORD=password",
		},
	}, func(config *docker.HostConfig) {
		// set AutoRemove to true so that stopped container goes away by itself
		config.AutoRemove = true
		config.RestartPolicy = docker.RestartPolicy{
			Name: "no",
		}
	})

この設定が有効なら

	// When you're done, kill and remove the container
	if err = pool.Purge(resource); err != nil {
		log.Fatalf("Could not purge resource: %s", err)
	}

この処理なくてもコンテナ破棄されるのかと思ったけどそんなことはなく上記のPurge()がないとコンテナが起動して残り続ける。じゃあAutoRemoveの設定何?ってなったけどよくわかってない。とりあえずdockertesetを使用するならちゃんとPurge関数を使ってコンテナを確実に破棄するようにしたほうがいい。ちなみに、testcontainersはここらへんRyukがいい感じにやってくれるのであんまり考えなくて良いのもtestcontainersの良さだと思ってる。

os.Exit()のあとにdeferは処理されない

MainTest内にdeferでclose処理書きまくってコンテナ破棄されなくてあれー??ってなったやつ。

https://qiita.com/umanoda/items/39eb03c9651bd43bc657

GitHubで編集を提案

Discussion