33
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

インテグレーションテストをもっと楽に!Testcontainers はじめました

Last updated at Posted at 2024-12-20

はじめに

この記事は TRIAL&RetailAI Advent Calendar 2024 の21日目の記事です。昨日は Ryusei Ito さんの Helmを「完全に理解した」 という記事でした。Helm入門者にとって「完全に理解した」に到達するための良い手引きとなるのではないでしょうか。よろしければ「いいね」をお願いします!

自己紹介

Retail AI にてバックエンドエンジニアをしています。主にGoでAPIを書いています。
今年のアドベントカレンダーも、そして2024年も残りわずかとなってきました。今年の個人的なハイライトは父になったことで、子育ての素晴らしさ・大変さを日々実感しています。世の中のお父さん・お母さんの皆さん、毎日本当にお疲れ様です!

どんな記事?

さて、本題に入っていきます。この記事では Testcontainers を紹介します。特別新しいツールというわけでもないのですが、知らないもしくは利用したことがない方にとって便利さを知るきっかけとなれば嬉しいです。

Testcontainers とは?

Testcontainers とは簡潔にいうと、自動テストにおける依存関係の解決を、実際の依存先に近しいもので、コードの中でサクッと行うためのオープンソースライブラリです。
Webアプリケーションの自動テストを実際の DB と同じものを使って行いたい場合、Docker などでコンテナを別途立ち上げ、その上でテストを実行するといったやり方が考えられます。ただこの場合、テスト実行の前にこの準備が整っている必要があり、正直面倒です。コンテナの起動が間に合わずテストが失敗する、といった本質的でない失敗が起きる可能性もあるかと思います。
Testcontainers を利用することで、テストコードの中でこの問題を解決することができ、DB は使い捨ての Docker コンテナとなります。モックやスタブを用意する必要もありません。(テスト戦略については各社、各人の考えがあるかと思いますのでここでは触れません)

本記事ではサンプルコードを Go で書いていますが、Testcontainers は他の言語にも対応しているので、詳しくは公式サイトをご確認ください。

必要なもの

  • Go(筆者の環境: 1.23.4 darwin/arm64)
  • Docker(筆者の環境: 27.4.0)

実際に使ってみる

今回はサンプルとして TODO アプリをイメージし、タスク作成用の API を書いてみます。/tasks に POST でタスクのタイトルを渡せば tasks テーブルにレコードを作成し、作成したタスクの ID をクライアントに返すようなものです。今回はミニマムな例として提示したいだけなので、簡略化のためにエラーハンドリングを省いていたり、ファイルやディレクトリの分割もさほど気にしていないなど、不完全な箇所が多くあります。その点はご留意ください。

まずはモジュールの初期化や Testcontainers のインストールなどを行います。

❯ go mod init testcontainers-sample
❯ go get github.com/testcontainers/testcontainers-go
❯ go get github.com/testcontainers/testcontainers-go/modules/postgres

次にコードを追加していきます。
ソースコード全体はこちらのリポジトリにありますので、ご参考までに。

ディレクトリ構成は以下のようになっています。

❯ tree ./
./
├── README.md
├── go.mod
├── go.sum
├── infra
│   └── db.go
├── main.go
└── task
    ├── task.go
    └── task_test.go

次に各ファイルのソースコードになります。
処理内容としては、DB を初期化し、ハンドラを通してテーブルにタスクのレコードを入れているくらいです。

main.go
package main

import (
	"log"
	"net/http"

	"testcontainers-sample/infra"
	"testcontainers-sample/task"

	_ "github.com/lib/pq"
)

func main() {
	db, _ := infra.InitDB("postgres://postgres:password@localhost:5432/postgres?sslmode=disable")
	_ = infra.CreateTables(db)

	taskRepo := &task.TaskRepository{DB: db}
	taskHandler := &task.TaskHandler{TaskRepository: taskRepo}

	mux := http.NewServeMux()
	mux.HandleFunc("POST /tasks", taskHandler.CreateTask)

	log.Println("Listening on :9999")
	log.Fatal(http.ListenAndServe(":9999", mux))
}
infra/db.go
package infra

import (
	"database/sql"
)

func InitDB(dsn string) (*sql.DB, error) {
	db, err := sql.Open("postgres", dsn)
	if err != nil {
		return nil, err
	}

	err = db.Ping()
	if err != nil {
		return nil, err
	}

	return db, nil
}

func CreateTables(db *sql.DB) error {
	_, err := db.Exec(`
		CREATE TABLE IF NOT EXISTS tasks (
			id SERIAL PRIMARY KEY,
			title TEXT NOT NULL,
			completed BOOLEAN DEFAULT FALSE,
			created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
		)
	`)
	if err != nil {
		return err
	}

	return nil
}
task/task.go
package task

import (
	"database/sql"
	"encoding/json"
	"net/http"
)

type TaskHandler struct {
	TaskRepository *TaskRepository
}

type TaskRepository struct {
	DB *sql.DB
}

type CreateTaskRequest struct {
	Title string `json:"title"`
}

func (h *TaskHandler) CreateTask(w http.ResponseWriter, r *http.Request) {
	var req CreateTaskRequest
	err := json.NewDecoder(r.Body).Decode(&req)
	if err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}

	id, err := h.TaskRepository.InsertTask(req.Title)
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}

	res := map[string]int{"id": id}
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusCreated)
	json.NewEncoder(w).Encode(res)
}

func (r *TaskRepository) InsertTask(title string) (int, error) {
	var id int
	err := r.DB.QueryRow(`
		INSERT INTO tasks (title)
		VALUES ($1)
		RETURNING id
	`, title).Scan(&id)
	if err != nil {
		return 0, err
	}

	return id, nil
}

DB は PostgreSQL を使っています。サクッと動作を確認したいだけでしたので、今回は以下のようにその場限りのコンテナを手動で起動していました。

❯ docker run --name postgres-for-testcontainers-sample \                                                       at 22:08:59
           -e POSTGRES_PASSWORD=password \
           -e POSTGRES_INITDB_ARGS="--encoding=UTF8 --no-locale" \
           -e TZ=Asia/Tokyo \
           -v postgresdb:/var/lib/postgresql/data \
           -p 5432:5432 \
           -d postgres

さて、ここからが本題の Testcontainers を利用した自動テストになります。
API をコールし、想定通りのレスポンスが返るか、渡したタイトルでレコードが正しく入ったのかどうかをチェックしたいものと仮定します。

task/task_test.go
package task_test

import (
	"context"
	"database/sql"
	"net/http"
	"net/http/httptest"
	"strings"
	"testcontainers-sample/infra"
	"testcontainers-sample/task"
	"testing"
	"time"

	_ "github.com/lib/pq"
	"github.com/testcontainers/testcontainers-go"
	"github.com/testcontainers/testcontainers-go/modules/postgres"
	"github.com/testcontainers/testcontainers-go/wait"
)

var db *sql.DB

func TestCreateTask(t *testing.T) {
	ctx := context.Background()
	pgContainer, err := postgres.Run(ctx,
		"postgres:16-alpine",
		postgres.WithDatabase("test"),
		postgres.WithUsername("user"),
		postgres.WithPassword("password"),
		testcontainers.WithWaitStrategy(
			wait.ForListeningPort("5432/tcp").WithStartupTimeout(3*time.Minute),
		),
	)

	if err != nil {
		t.Fatal(err)
	}

	t.Cleanup(func() {
		if err := pgContainer.Terminate(ctx); err != nil {
			t.Fatal(err)
		}
	})

	dsn, err := pgContainer.ConnectionString(ctx, "sslmode=disable")
	if err != nil {
		t.Fatal(err)
	}

	db, err = infra.InitDB(dsn)
	if err != nil {
		t.Fatal(err)
	}

	err = infra.CreateTables(db)
	if err != nil {
		t.Fatal(err)
	}

	req := httptest.NewRequest("POST", "/tasks", strings.NewReader(`{"title": "test task"}`))
	w := httptest.NewRecorder()

	taskHandler := &task.TaskHandler{
		TaskRepository: &task.TaskRepository{DB: db},
	}
	taskHandler.CreateTask(w, req)

	if w.Code != http.StatusCreated {
		t.Errorf("expected status code %d, got %d", http.StatusCreated, w.Code)
	}

	if w.Body.String() != "{\"id\":1}\n" {
		t.Errorf("expected body %q, got %q", `{"id":1}`, w.Body.String())
	}

	var id int
	var title string
	err = db.QueryRow("SELECT id, title FROM tasks").Scan(&id, &title)
	if err != nil {
		t.Fatal(err)
	}

	if id != 1 {
		t.Errorf("expected id %d, got %d", 1, id)
	}

	if title != "test task" {
		t.Errorf("expected title %q, got %q", "test task", title)
	}
}

今回は PostgreSQL を使っていることもあり、postgres.Run でコンテナのオブジェクトを取得しています。オプションとして渡している wait.ForListeningPort("5432/tcp").WithStartupTimeout(3*time.Minute) のようにポートを指定して待つことも簡単にできるので、DB側が起動する前にテストコードが実行されて失敗する、のようなことも防げます。

	pgContainer, err := postgres.Run(ctx,
		"postgres:16-alpine",
		postgres.WithDatabase("test"),
		postgres.WithUsername("user"),
		postgres.WithPassword("password"),
		testcontainers.WithWaitStrategy(
			wait.ForListeningPort("5432/tcp").WithStartupTimeout(3*time.Minute),
		),
	)

テスト終了時の片付けとしてコンテナを止め、削除するために Terminate の呼び出しを指定しています。

	t.Cleanup(func() {
		if err := pgContainer.Terminate(ctx); err != nil {
			t.Fatal(err)
		}
	})

あとは作成されたテスト用の DB のコンテナを向いている DSN を取得して、プロダクションコードと同じように DB のオブジェクトを作り、利用しているだけです。これだけで DB 接続を含めた自動テストができます。別途テストのためのコンテナを手動で立ち上げたり、利用後に片付けたりなどをする必要がなく、便利です。

テストを実行すると成功します。

❯ go test ./...
?       testcontainers-sample   [no test files]
?       testcontainers-sample/infra     [no test files]
ok      testcontainers-sample/task      2.456s
❯ 

最後に

今回はインテグレーションテストの自動化に役立つ Testcontainers を紹介しました。もし触ったことがなく、この手のテストに手間を感じている方にはぜひ一度試して頂きたいです。

明日は Plath さんの「GitLabからGitHubに移行した時に直面した課題と対策」という記事になります。個人的にもこのような移行作業を経験したことはないので、興味深いです。お楽しみに!

最後になりますが、トライアル、Retail AI ではエンジニア、デザイナー、プロジェクトマネージャーなど、各職種で採用活動中です!ご興味のある方は下記をご確認頂けると嬉しいです!
それでは、読んで頂きありがとうございました!

33
7
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
33
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?