みなさんごきげんよう。みなさんはバックエンドに Postgres を使用する場合、(ローカル)単体テスト環境をどのように構築しているでしょうか?事前に docker compose 等でDBを立てておく?testcontainers を使う?
今回は embedded-postgres を使って、別途 Postgres を立てずに DB 込みの単体テスト環境を構築していきます。
embedded-postgresとは
embedded-postgres は、postgres ネイティブバイナリを jar ライブラリとして管理するライブラリです。つまり、このライブラリを依存すれば、ネイティブの postgres が裏で勝手に立ち上がってくれます。そのため、単体テストのためにdb用の docker を用意する必要がなくなります。
構築方法
簡単な DB アクセスするようなプロジェクトを用意しましょう。プレーンなTODOアプリのバックエンドシステムを考えます。
sbt.version=2.0.7
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")
)
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 を用意しておきます。
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 を使うことにします。
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 でやりたい場合はこちら