GoにおけるDBテスト dockertest vs testcontainers 比較してみた
JavaやKotlinを書いていたときにtestcontainersというライブラリが非常に便利だったのでGoでも使おうと思ったらdockertestというパッケージがあるようでどっち使ったらいいのかわからなかったのでどっちも使って比較してみたのでその備忘録です。
今回はDBにmongoを使用し、以下のようなUseRepositoryのテストをtestcontainersおよびdockertestで書いてみます。UserRepositoryはユーザー作成とIdでの検索の2つの関数を持つこととします。
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とは
テストコード上でコンテナの起動・停止まで行いテストを実行できる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とは
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の書き方参考にさせていただきました
おまけ
検証するなかではまったところ
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処理書きまくってコンテナ破棄されなくてあれー??ってなったやつ。
Discussion