Skip to content

PostgreSQL

Real Postgres in a container (or wired to existing infra). Migrations, type-safe queries, pause/unpause for fault-injection tests.

Open in setup wizard

PostgreSQL — wizard-synced snippet

Gradle

testImplementation("com.trendyol:stove-postgres")

Stove configuration

Stove().with {
    postgresql {
      PostgresqlOptions(
        databaseName = "testdb",
        configureExposedConfiguration = { cfg ->
          listOf(
            "spring.datasource.url=${cfg.jdbcUrl}",
            "spring.datasource.username=${cfg.username}",
            "spring.datasource.password=${cfg.password}"
          )
        }
      )
    }
}

Test DSL

stove {
    postgresql {
      shouldExecute("SELECT 1")
    }
}

In 30 seconds Add stove-postgres. Register postgresql { PostgresqlOptions(...) } in Stove().with. Hand connection details to your app via configureExposedConfiguration. Run migrations through .migrations { register<T>() }. Query with shouldQuery<T>; execute DDL/DML with shouldExecute.

Configure

Stove().with {
  postgresql {
    PostgresqlOptions(
      databaseName = "testdb",
      configureExposedConfiguration = { cfg ->
        listOf(
          "spring.datasource.url=${cfg.jdbcUrl}",
          "spring.datasource.username=${cfg.username}",
          "spring.datasource.password=${cfg.password}"
        )
      }
    )
  }
}.run()

cfg exposes host, port, database, username, password, jdbcUrl. Mirror your app's property names.

Migrations

class InitialMigration : DatabaseMigration<PostgresSqlMigrationContext> {
  override val order: Int = 1

  override suspend fun execute(connection: PostgresSqlMigrationContext) {
    connection.operations.execute(
      """
      CREATE TABLE IF NOT EXISTS users (
        id serial PRIMARY KEY,
        name VARCHAR(100) NOT NULL,
        email VARCHAR(100) NOT NULL UNIQUE,
        created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
      );
      """.trimIndent()
    )
  }
}

Register:

postgresql {
  PostgresqlOptions(/* ... */).migrations {
    register<InitialMigration>()
  }
}

Query DSL

Execute DDL/DML

stove {
  postgresql {
    shouldExecute(
      """
      CREATE TABLE IF NOT EXISTS products (
        id serial PRIMARY KEY,
        name VARCHAR(100) NOT NULL,
        price DECIMAL(10, 2) NOT NULL
      )
      """.trimIndent()
    )

    shouldExecute("INSERT INTO products (name, price) VALUES ('Laptop', 999.99)")
    shouldExecute("UPDATE products SET price = 899.99 WHERE name = 'Laptop'")
    shouldExecute("DELETE FROM products WHERE price = 0")
  }
}

Type-safe query

data class Product(val id: Long, val name: String, val price: Double)

stove {
  postgresql {
    shouldQuery<Product>(
      query = "SELECT * FROM products WHERE price > 500",
      mapper = { row ->
        Product(
          id = row.long("id"),
          name = row.string("name"),
          price = row.double("price")
        )
      }
    ) { products ->
      products.size shouldBeGreaterThan 0
      products.all { it.price > 500 } shouldBe true
    }
  }
}

Use row.stringOrNull(...) for nullable columns. Joins / aggregations / CTEs work identically. Just write the SQL.

Direct operations

For advanced control (parameterized inserts, custom mappers):

stove {
  postgresql {
    val ops = operations()

    ops.execute(
      "INSERT INTO users (name, email) VALUES (?, ?)",
      Parameter("name", "Alice"),
      Parameter("email", "alice@example.com")
    )

    val users = ops.select("SELECT * FROM users") { row ->
      User(
        id = row.long("id"),
        name = row.string("name"),
        email = row.string("email")
      )
    }
  }
}

Fault injection: pause / unpause

Container mode only. Simulate database outage mid-test:

stove {
  postgresql {
    pause()

    http {
      get("/health") { it.status shouldBe 503 }  // app reports degraded
    }

    unpause()
  }
}

Complete example

test("create user via API, verify row + published event") {
  stove {
    val userName = "John Doe"
    val userEmail = "john@example.com"

    http {
      postAndExpectBody<UserResponse>(
        uri = "/users",
        body = CreateUserRequest(name = userName, email = userEmail).some()
      ) { response ->
        response.status shouldBe 201
        response.body().name shouldBe userName
      }
    }

    postgresql {
      shouldQuery<User>(
        query = "SELECT * FROM users WHERE email = ?",
        mapper = { row ->
          User(
            id = row.long("id"),
            name = row.string("name"),
            email = row.string("email")
          )
        }
      ) { users ->
        users.size shouldBe 1
        users.first().name shouldBe userName
      }
    }

    kafka {
      shouldBePublished<UserCreatedEvent> {
        actual.name == userName && actual.email == userEmail
      }
    }
  }
}

Multiple databases on one container

Production may have separate Postgres instances per bounded context. In tests, one container with multiple CREATE DATABASE calls is enough. Your app reads them as separate datasources.

class CreateDatabasesMigration : DatabaseMigration<PostgresSqlMigrationContext> {
  override val order: Int = 0

  override suspend fun execute(connection: PostgresSqlMigrationContext) {
    listOf("users_db", "orders_db", "analytics_db").forEach { db ->
      connection.operations.execute("CREATE DATABASE IF NOT EXISTS $db")
    }
  }
}

Stove().with {
  postgresql {
    PostgresqlOptions(
      databaseName = "main",
      configureExposedConfiguration = { cfg ->
        val base = "jdbc:postgresql://${cfg.host}:${cfg.port}"
        listOf(
          "db.users.url=$base/users_db",
          "db.users.username=${cfg.username}",
          "db.users.password=${cfg.password}",
          "db.orders.url=$base/orders_db",
          "db.orders.username=${cfg.username}",
          "db.orders.password=${cfg.password}",
          "db.analytics.url=$base/analytics_db",
          "db.analytics.username=${cfg.username}",
          "db.analytics.password=${cfg.password}"
        )
      }
    ).migrations {
      register<CreateDatabasesMigration>()
    }
  }
}

For genuinely independent test instances (different versions, different config), use keyed systems: postgresql(AppDb) { ... } plus postgresql(AnalyticsDb) { ... }.

Shared infrastructure

Use PostgresqlOptions.provided(...) to wire to existing Postgres. See Provided Instances for unique-prefix isolation patterns.

Pairs well with