0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

embedded-postgresを使って単体テスト環境を作る

0
Last updated at Posted at 2026-08-30

みなさんごきげんよう。みなさんはバックエンドに Postgres を使用する場合、(ローカル)単体テスト環境をどのように構築しているでしょうか?事前に docker compose 等でDBを立てておく?testcontainers を使う?

今回は embedded-postgres を使って、別途 Postgres を立てずに DB 込みの単体テスト環境を構築していきます。

embedded-postgresとは

embedded-postgres は、postgres ネイティブバイナリを jar ライブラリとして管理するライブラリです。つまり、このライブラリを依存すれば、ネイティブの postgres が裏で勝手に立ち上がってくれます。そのため、単体テストのためにdb用の docker を用意する必要がなくなります。

構築方法

簡単な DB アクセスするようなプロジェクトを用意しましょう。プレーンなTODOアプリのバックエンドシステムを考えます。

project/build.properties
sbt.version=2.0.7
build.sbt
ThisBuild / scalaVersion := "3.8.4"

lazy val root = project
  .in(file("."))
  .settings(
    name := "todo-app",
    libraryDependencies ++= Seq(
      "io.getkyo" %% "kyo-core" % "1.0.0-RC6",
      "io.getkyo" %% "kyo-http" % "1.0.0-RC6",
      "org.postgresql" % "postgresql" % "42.7.13",
      "io.getquill" %% "quill-jdbc" % "4.8.6",
      "io.getkyo" %% "kyo-test-runner" % "1.0.0-RC6" % Test,
      "org.testcontainers" % "testcontainers-postgresql" % "2.0.5" % Test
    ),
    Test / testFrameworks += TestFramework("kyo.test.runner.SbtFramework")
  )
src/main/scala/Main.scala
import kyo.*
import java.sql.DriverManager

final case class Todo(id: Int, title: String, completed: Boolean) derives Schema
final case class CreateTodo(title: String) derives Schema
final case class UpdateTodo(title: String, completed: Boolean) derives Schema
final case class NotFound(error: String) derives Schema

final class TodoRepository(url: String, user: String, password: String) {
    import io.getquill.*
    import org.postgresql.ds.PGSimpleDataSource

    private val dataSource = {
        val ds = PGSimpleDataSource()
        ds.setUrl(url)
        ds.setUser(user)
        ds.setPassword(password)
        ds
    }

    private val ctx = PostgresJdbcContext(SnakeCase, dataSource)
    import ctx.*

    private inline def todos = quote(query[Todo])

    def init() = {
        Sync.defer {
            val conn = DriverManager.getConnection(url, user, password)
            try {
                conn.createStatement().execute(
                    """CREATE TABLE IF NOT EXISTS todo (
                      |  id SERIAL PRIMARY KEY,
                      |  title TEXT NOT NULL,
                      |  completed BOOLEAN NOT NULL DEFAULT FALSE
                      |)""".stripMargin
                )
            } finally conn.close()
        }
    }

    def list() = {
        Sync.defer(ctx.run(todos.sortBy(_.id)))
    }

    def find(id: Int) = {
        Sync.defer(ctx.run(todos.filter(_.id == lift(id))).headOption)
    }

    def create(title: String) = {
        Sync.defer {
            val id = ctx.run(todos.insertValue(lift(Todo(0, title, false))).returningGenerated(_.id))
            Todo(id, title, false)
        }
    }

    def update(id: Int, title: String, completed: Boolean) = {
        Sync.defer {
            val rows = ctx.run(todos.filter(_.id == lift(id)).update(_.title -> lift(title), _.completed -> lift(completed)))
            Option.when(rows > 0)(Todo(id, title, completed))
        }
    }

    def delete(id: Int) = {
        Sync.defer(ctx.run(todos.filter(_.id == lift(id)).delete) > 0)
    }
}

object TodoRepository {
    def fromEnv(): TodoRepository = new TodoRepository(
        sys.env.getOrElse("JDBC_URL", "jdbc:postgresql://localhost:5432/todo"),
        sys.env.getOrElse("JDBC_USER", "postgres"),
        sys.env.getOrElse("JDBC_PASSWORD", "postgres")
    )
}

object Main extends KyoApp {

    val repository = TodoRepository.fromEnv()

    val list = HttpHandler
        .getJson[List[Todo]]("todos") { _ =>
            repository.list()
        }

    val create = HttpRoute
        .postRaw("todos")
        .request(_.bodyJson[CreateTodo])
        .response(_.bodyJson[Todo])
        .handler { req =>
            repository.create(req.fields.body.title).map(HttpResponse.created)
        }

    val get = HttpRoute
        .getRaw("todos" / HttpPath.Capture[Int]("id"))
        .response(_.bodyJson[Todo].error[NotFound](HttpStatus.NotFound))
        .handler { req =>
            repository.find(req.fields.id).map {
                case Some(todo) => HttpResponse.ok(todo)
                case None       => Abort.fail(NotFound(s"Todo ${req.fields.id} not found"))
            }
        }

    val update = HttpRoute
        .putRaw("todos" / HttpPath.Capture[Int]("id"))
        .request(_.bodyJson[UpdateTodo])
        .response(_.bodyJson[Todo].error[NotFound](HttpStatus.NotFound))
        .handler { req =>
            val body = req.fields.body
            repository.update(req.fields.id, body.title, body.completed).map {
                case Some(todo) => HttpResponse.ok(todo)
                case None       => Abort.fail(NotFound(s"Todo ${req.fields.id} not found"))
            }
        }

    val delete = HttpRoute
        .deleteRaw("todos" / HttpPath.Capture[Int]("id"))
        .response(_.status(HttpStatus.NoContent).error[NotFound](HttpStatus.NotFound))
        .handler { req =>
            repository.delete(req.fields.id).map { deleted =>
                if (deleted) HttpResponse.noContent
                else Abort.fail(NotFound(s"Todo ${req.fields.id} not found"))
            }
        }

    run {
        for {
            _      <- repository.init()
            server <- HttpServer.init(HttpServerConfig.default.port(8080))(list, create, get, update, delete)
            _      <- Console.printLine(s"Todo app running on http://localhost:${server.port}")
            _      <- server.await
        } yield ()
    }
}

例では kyo-http を使いましたが、他のライブラリでもおそらく似たような感じになると思います。これを実行するには postgres がいるので、compose.yaml を用意しておきます。

compose.yaml
services:
  postgres:
    image: postgres:17
    environment:
      POSTGRES_DB: todo
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
    ports:
      - "5432:5432"
    volumes:
      - postgres-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      timeout: 5s
      retries: 5

volumes:
  postgres-data:

下記を実行することで DB を繋げて起動できます。

docker compose up --wait
sbt run

単体テストを書いておきましょう。ここでは testcontainers を使うことにします。

src/test/scala/TodoRepositoryTest.scala
import kyo.*
import kyo.test.*
import org.testcontainers.postgresql.PostgreSQLContainer

class TodoRepositoryTest extends Test[Any] {

    import TodoRepositoryTest.repository

    "TodoRepository" - {
        "作成したTodoが一覧・取得に反映される" in {
            for {
                created <- repository.create("牛乳を買う")
                found   <- repository.find(created.id)
                todos   <- repository.list()
            } yield {
                assert(found.contains(created))
                assert(todos.exists(_.id == created.id))
            }
        }

        "更新すると内容が変わる" in {
            for {
                created <- repository.create("牛乳を買う")
                updated <- repository.update(created.id, "牛乳を買う", completed = true)
            } yield assert(updated.contains(created.copy(completed = true)))
        }

        "削除すると取得できなくなる" in {
            for {
                created <- repository.create("牛乳を買う")
                deleted <- repository.delete(created.id)
                found   <- repository.find(created.id)
            } yield {
                assert(deleted)
                assert(found.isEmpty)
            }
        }

        "存在しないIDはNoneを返す" in {
            repository.find(999999).map(found => assert(found.isEmpty))
        }
    }
}

object TodoRepositoryTest {
    import AllowUnsafe.embrace.danger

    val container = PostgreSQLContainer("postgres:17")
    container.start()

    val repository = TodoRepository(container.getJdbcUrl, container.getUsername, container.getPassword)
    Sync.Unsafe.evalOrThrow(repository.init())
}

単体テストの組み込み

DB の単体テストを書いてみます。まず、embedded-postgres を依存関係に追加します。

ThisBuild / scalaVersion := "3.8.4"

lazy val root = project
  .in(file("."))
  .settings(
    name := "todo-app",
    libraryDependencies ++= Seq(
      "io.getkyo" %% "kyo-core" % "1.0.0-RC6",
      "io.getkyo" %% "kyo-http" % "1.0.0-RC6",
      "org.postgresql" % "postgresql" % "42.7.13",
      "io.getquill" %% "quill-jdbc" % "4.8.6",
      "io.getkyo" %% "kyo-test-runner" % "1.0.0-RC6" % Test,
-     "org.testcontainers" % "testcontainers-postgresql" % "2.0.5" % Test
+     "io.zonky.test" % "embedded-postgres" % "2.2.2" % Test,
+     // arm版バイナリは別途依存する必要がある。
+     "io.zonky.test.postgres" % "embedded-postgres-binaries-darwin-arm64v8" % "17.11.0" % Test, // Apple Silicon Mac
+     "io.zonky.test.postgres" % "embedded-postgres-binaries-linux-arm64v8" % "17.11.0" % Test   // Linux ARM64
    ),
    Test / testFrameworks += TestFramework("kyo.test.runner.SbtFramework")
  )

次に、単体テストで embedded-postgres に接続します。

import kyo.*
import kyo.test.*
- import org.testcontainers.postgresql.PostgreSQLContainer
+ import io.zonky.test.db.postgres.embedded.EmbeddedPostgres
object TodoRepositoryTest {
    import AllowUnsafe.embrace.danger

-   val container = PostgreSQLContainer("postgres:17")
-   container.start()
+   val pg = EmbeddedPostgres.builder().start()

-   val repository = TodoRepository(container.getJdbcUrl, container.getUsername, container.getPassword)
+   val repository = TodoRepository(pg.getJdbcUrl("postgres", "postgres"), "postgres", "postgres")
    Sync.Unsafe.evalOrThrow(repository.init())
}

これだけです。ネイティブの postgres が動くため、SQL は PostgreSQL を使えます。

一方で、通常起動(sbt run)は引き続き外部DBと接続します。起動には docker compose up が必要です。

まとめ

embedded-postgres を使うことで、単体テストは外部DBの用意が必要なく実行できます。外部DBに依存しないため、単体テスト実行がより安定します。昨今 AI コーディングが流行っていますが、外部依存しないため、ハーネスとしても気軽に導入できそうですね。外部 DB を用意しない単体テスト環境ではよく H2 が使われますが、こちらは完全な PostgreSQL で確認できます。変更差分も、ライブラリの追加と、単体テストでの接続先の切り替えだけです。お手軽にお試しできるので、小さなプロジェクトから味見してみるのも良いかもしれません。

今回は以上です。今回のようなテスト環境を含めて、エンジニアリング環境をより良くしていきましょう。

追記

Go でやりたい場合はこちら

0
0
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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?