Add framework for executing tests against multiple postgresql versions using docker compose.

This commit is contained in:
eelke 2026-10-18 12:09:54 +02:00
parent 655c6fceb1
commit c3bf9e88e4
13 changed files with 457 additions and 0 deletions

View file

@ -0,0 +1,43 @@
---
name: pg-integration-tests
description: Write and run pgLab tests against a real PostgreSQL server (14–17) using the pgintegration harness (DbTest fixture, template database, REQUIRE_PG_AT_LEAST). Use whenever a test needs a live server, e.g. catalog introspection, generated SQL that must be executed, version-specific behaviour, or when the user mentions integration tests, IntegrationTests, docker-compose Postgres or TEST_DATABASE_URL.
---
# Integration tests against real PostgreSQL
Build/run commands live in `AGENTS.md` (Build and test); don't duplicate them here.
## When to use
Use for behaviour that depends on a real server: catalog queries, `pg_get_*` output, executing generated SQL, version differences. Pure string/logic tests belong in `PgsqlTests` / `pglabtests` instead.
## Layout
- `tests/pgintegration/` – static lib: `PgEnv` (global environment, builds template DB `tmpl_<pid>` from `fixtures/schema.sql`), `DbTest` fixture, helpers.
- `tests/integration/` – `IntegrationTests` executable, `tst_*.cpp`, `fixtures/schema.sql`, `docker-compose.yml`.
- The binary only reads `TEST_DATABASE_URL`; unset means every `DbTest` skips.
## Writing a test
```cpp
#include "DbTest.h"
#include "PgTestEnvironment.h"
class MyFeature : public pgtest::DbTest {};
TEST_F(MyFeature, Works)
{
REQUIRE_PG_AT_LEAST(15); // skip on older servers
auto r = conn().query("SELECT 1"); // Pgsql::Connection, throws on SQL error
EXPECT_EQ(r.rows(), 1);
}
```
- Derive from `pgtest::DbTest`. Each test gets a fresh database (`t_<n>_<pid>`) cloned from the template and dropped afterwards, so never rely on state from another test and freely modify data/schema.
- `conn()` is the test connection, `dbUrl()` gives a URL for code under test that opens its own connection (close those before the test ends; teardown uses `DROP DATABASE ... WITH (FORCE)`).
- Gate version-specific features with `REQUIRE_PG_AT_LEAST(v)`; use `pgtest::serverVersionNum()` for finer checks. Also test graceful behaviour on PG 14.
- `query()` throws on error; use `EXPECT_THROW` for expected failures.
## Adding things
- New test file: add it to the `add_executable(IntegrationTests ...)` list in `tests/integration/CMakeLists.txt` (no globbing).
- Shared schema: extend `tests/integration/fixtures/schema.sql` (plain multi-statement SQL, loaded once per process into the template). Keep it small; it must work on all of 14–17. Version-specific schema should be created inside the test.
- Cluster-wide objects (roles, tablespaces) are not isolated by cloning: use unique names (include `pgtest::processId()`) and drop them in `TearDown`.
## Running and checking
- One version manually: set `TEST_DATABASE_URL` and use `--gtest_filter` (see AGENTS.md); start servers with `docker compose -f tests/integration/docker-compose.yml up -d --wait pg16`, stop with `down -v`.
- After a run no `t_*` or `tmpl_*` databases should remain: `psql <url> -Atc "select datname from pg_database where datname ~ '^(t|tmpl)_'"`.

View file

@ -18,6 +18,7 @@ cmake --build build
ctest --test-dir build --output-on-failure
```
- Run one suite: `./build/tests/pglabtests/pglabtests --gtest_filter='SqlLexer*'` (same for `PgsqlTests`).
- Integration tests against real PostgreSQL 14–17 (`IntegrationTests`, label `integration`): configure with `-DPGLAB_INTEGRATION_TESTS=ON` (needs Docker), then `ctest --test-dir build -L integration -j4`. This starts `tests/integration/docker-compose.yml` (ports 54314–54317), runs the binary once per version and tears it down. Manual run against any server: `TEST_DATABASE_URL=postgresql://postgres:test@localhost:54316/postgres ./build/tests/integration/IntegrationTests --gtest_filter='Smoke*'`. Without the variable the tests skip. Developer-facing only, no reno note. See the `pg-integration-tests` skill.
- `-DBUILD_TESTS=OFF` skips tests. `build/` is git-ignored; the first configure builds dependencies and is slow.
- Run the app headless with `QT_QPA_PLATFORM=offscreen ./build/pglab/pglab`. It needs a PostgreSQL server for anything useful.

View file

@ -3,3 +3,5 @@ find_package(GTest CONFIG REQUIRED)
add_subdirectory(mygtestutils)
add_subdirectory(PgsqlTests)
add_subdirectory(pglabtests)
add_subdirectory(pgintegration)
add_subdirectory(integration)

View file

@ -0,0 +1,43 @@
add_executable(IntegrationTests
main.cpp
tst_Smoke.cpp
)
target_link_libraries(IntegrationTests PRIVATE
pgintegration
GTest::gtest
Qt6::Core
)
target_compile_definitions(IntegrationTests PRIVATE
PGTEST_FIXTURE_DIR="${CMAKE_CURRENT_SOURCE_DIR}/fixtures"
)
set_target_properties(IntegrationTests PROPERTIES
CXX_STANDARD 20
CXX_STANDARD_REQUIRED ON
)
option(PGLAB_INTEGRATION_TESTS "Register Docker based PostgreSQL integration tests" OFF)
if (PGLAB_INTEGRATION_TESTS)
set(PGTEST_COMPOSE_FILE ${CMAKE_CURRENT_SOURCE_DIR}/docker-compose.yml)
add_test(NAME db_up
COMMAND docker compose -f ${PGTEST_COMPOSE_FILE} up -d --wait)
add_test(NAME db_down
COMMAND docker compose -f ${PGTEST_COMPOSE_FILE} down -v)
set_tests_properties(db_up PROPERTIES FIXTURES_SETUP pgcluster LABELS integration)
set_tests_properties(db_down PROPERTIES FIXTURES_CLEANUP pgcluster LABELS integration)
foreach (v 14 15 16 17)
add_test(NAME integration_pg${v} COMMAND IntegrationTests)
set_tests_properties(integration_pg${v} PROPERTIES
FIXTURES_REQUIRED pgcluster
LABELS integration
ENVIRONMENT "TEST_DATABASE_URL=postgresql://postgres:test@localhost:543${v}/postgres")
endforeach ()
else ()
add_test(NAME IntegrationTests COMMAND IntegrationTests)
set_tests_properties(IntegrationTests PROPERTIES LABELS integration)
endif ()

View file

@ -0,0 +1,16 @@
x-pg: &pg
environment: { POSTGRES_PASSWORD: test }
tmpfs: [/var/lib/postgresql/data]
command: >
postgres -c fsync=off -c synchronous_commit=off
-c full_page_writes=off -c max_connections=200
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 1s
retries: 30
services:
pg14: { <<: *pg, image: "postgres:14", ports: ["54314:5432"] }
pg15: { <<: *pg, image: "postgres:15", ports: ["54315:5432"] }
pg16: { <<: *pg, image: "postgres:16", ports: ["54316:5432"] }
pg17: { <<: *pg, image: "postgres:17", ports: ["54317:5432"] }

View file

@ -0,0 +1,6 @@
CREATE TABLE fixture_item (
id integer PRIMARY KEY,
name text NOT NULL
);
INSERT INTO fixture_item (id, name) VALUES (1, 'one'), (2, 'two');

View file

@ -0,0 +1,10 @@
#include <gtest/gtest.h>
#include "PgTestEnvironment.h"
int main(int argc, char *argv[])
{
::testing::InitGoogleTest(&argc, argv);
::testing::AddGlobalTestEnvironment(new pgtest::PgEnv(PGTEST_FIXTURE_DIR));
return RUN_ALL_TESTS();
}

View file

@ -0,0 +1,63 @@
#include "DbTest.h"
#include "PgTestEnvironment.h"
#include "Pgsql_Result.h"
using namespace pgtest;
namespace {
int scalarInt(Pgsql::Connection &c, const char *sql)
{
Pgsql::Result r = c.query(sql);
EXPECT_EQ(r.rows(), 1);
return QString::fromUtf8(r.val(0, 0)).toInt();
}
} // namespace
class Smoke : public DbTest {};
TEST_F(Smoke, SelectOne)
{
EXPECT_EQ(scalarInt(conn(), "SELECT 1"), 1);
}
TEST_F(Smoke, RunsOnClonedDatabase)
{
Pgsql::Result r = conn().query("SELECT current_database()");
ASSERT_EQ(r.rows(), 1);
EXPECT_EQ(QString::fromUtf8(r.val(0, 0)), dbName());
EXPECT_TRUE(dbName().startsWith("t_"));
}
TEST_F(Smoke, ServerVersionAtLeast14)
{
EXPECT_GE(serverVersionNum(), 140000);
}
TEST_F(Smoke, FixtureSchemaPresent)
{
EXPECT_EQ(scalarInt(conn(), "SELECT count(*) FROM fixture_item"), 2);
}
// The two tests below each modify the database and expect the pristine
// fixture state, in whichever order they run.
TEST_F(Smoke, IsolationA)
{
EXPECT_EQ(scalarInt(conn(), "SELECT count(*) FROM fixture_item"), 2);
conn().query("INSERT INTO fixture_item VALUES (100, 'a')");
EXPECT_EQ(scalarInt(conn(), "SELECT count(*) FROM fixture_item"), 3);
}
TEST_F(Smoke, IsolationB)
{
EXPECT_EQ(scalarInt(conn(), "SELECT count(*) FROM fixture_item"), 2);
conn().query("DELETE FROM fixture_item");
EXPECT_EQ(scalarInt(conn(), "SELECT count(*) FROM fixture_item"), 0);
}
TEST_F(Smoke, VersionGate)
{
REQUIRE_PG_AT_LEAST(15);
EXPECT_GE(serverVersionNum(), 150000);
}

View file

@ -0,0 +1,21 @@
add_library(pgintegration STATIC
PgTestEnvironment.h
PgTestEnvironment.cpp
DbTest.h
DbTest.cpp
)
target_link_libraries(pgintegration PUBLIC
pgsql
GTest::gtest
Qt6::Core
)
target_include_directories(pgintegration PUBLIC
${CMAKE_CURRENT_SOURCE_DIR}
)
set_target_properties(pgintegration PROPERTIES
CXX_STANDARD 20
CXX_STANDARD_REQUIRED ON
)

View file

@ -0,0 +1,55 @@
#include "DbTest.h"
#include "PgTestEnvironment.h"
#include <atomic>
#include <stdexcept>
namespace pgtest {
namespace {
std::atomic<int> g_counter{0};
}
QString DbTest::dbUrl() const
{
return urlForDb(dbName_);
}
void DbTest::SetUp()
{
if (!available())
GTEST_SKIP() << "TEST_DATABASE_URL not set or template database unavailable";
dbName_ = QString("t_%1_%2").arg(g_counter++).arg(processId());
try {
{
Pgsql::Connection admin;
admin.connect(*baseUrl());
admin.query(QString("CREATE DATABASE %1 TEMPLATE %2")
.arg(quoteIdent(dbName_), quoteIdent(templateDbName())));
}
conn_ = std::make_unique<Pgsql::Connection>();
conn_->connect(dbUrl());
}
catch (const std::exception &e) {
FAIL() << "Creating test database failed: " << e.what();
}
}
void DbTest::TearDown()
{
conn_.reset();
if (dbName_.isEmpty())
return;
try {
Pgsql::Connection admin;
admin.connect(*baseUrl());
admin.query(QString("DROP DATABASE IF EXISTS %1 WITH (FORCE)").arg(quoteIdent(dbName_)));
}
catch (const std::exception &e) {
ADD_FAILURE() << "Dropping test database failed: " << e.what();
}
dbName_.clear();
}
} // namespace pgtest

View file

@ -0,0 +1,26 @@
#pragma once
#include <gtest/gtest.h>
#include <memory>
#include <QString>
#include "Pgsql_Connection.h"
namespace pgtest {
/// Fixture giving every test its own database cloned from the template.
class DbTest : public ::testing::Test {
protected:
void SetUp() override;
void TearDown() override;
Pgsql::Connection& conn() { return *conn_; }
QString dbName() const { return dbName_; }
QString dbUrl() const;
private:
QString dbName_;
std::unique_ptr<Pgsql::Connection> conn_;
};
} // namespace pgtest

View file

@ -0,0 +1,125 @@
#include "PgTestEnvironment.h"
#include <QCoreApplication>
#include <QFile>
#include <QUrl>
#include <iostream>
#include <stdexcept>
#include "Pgsql_Connection.h"
#include "Pgsql_Result.h"
namespace pgtest {
namespace {
bool g_available = false;
int g_versionNum = 0;
} // namespace
std::optional<QString> baseUrl()
{
QByteArray v = qgetenv("TEST_DATABASE_URL");
if (v.isEmpty())
return std::nullopt;
return QString::fromUtf8(v);
}
QString urlForDb(const QString &db)
{
auto base = baseUrl();
if (!base)
throw std::runtime_error("TEST_DATABASE_URL is not set");
QUrl url(*base);
url.setPath("/" + db);
return url.toString();
}
bool available()
{
return g_available;
}
int serverVersionNum()
{
return g_versionNum;
}
qint64 processId()
{
return QCoreApplication::applicationPid();
}
QString templateDbName()
{
return QString("tmpl_%1").arg(processId());
}
QString quoteIdent(const QString &name)
{
QString r = name;
r.replace('"', "\"\"");
return "\"" + r + "\"";
}
void PgEnv::SetUp()
{
auto base = baseUrl();
if (!base) {
std::cerr << "TEST_DATABASE_URL not set, integration tests will be skipped\n";
return;
}
const QString tmpl = templateDbName();
try {
{
Pgsql::Connection admin;
admin.connect(*base);
Pgsql::Result r = admin.query("SHOW server_version_num");
if (!r || r.rows() != 1)
throw std::runtime_error("could not query server_version_num");
g_versionNum = QString::fromUtf8(r.val(0, 0)).toInt();
admin.query(QString("DROP DATABASE IF EXISTS %1 WITH (FORCE)").arg(quoteIdent(tmpl)));
admin.query(QString("CREATE DATABASE %1").arg(quoteIdent(tmpl)));
}
{
QFile f(fixtureDir_ + "/schema.sql");
if (!f.open(QIODevice::ReadOnly))
throw std::runtime_error("cannot open " + f.fileName().toStdString());
QByteArray sql = f.readAll();
Pgsql::Connection c;
c.connect(urlForDb(tmpl));
c.query(sql.constData());
}
g_available = true;
}
catch (const std::exception &e) {
ADD_FAILURE() << "Setting up template database failed: " << e.what();
try {
Pgsql::Connection admin;
admin.connect(*base);
admin.query(QString("DROP DATABASE IF EXISTS %1 WITH (FORCE)").arg(quoteIdent(tmpl)));
}
catch (const std::exception &) {
}
}
}
void PgEnv::TearDown()
{
if (!g_available)
return;
try {
Pgsql::Connection admin;
admin.connect(*baseUrl());
admin.query(QString("DROP DATABASE IF EXISTS %1 WITH (FORCE)").arg(quoteIdent(templateDbName())));
}
catch (const std::exception &e) {
ADD_FAILURE() << "Dropping template database failed: " << e.what();
}
g_available = false;
}
} // namespace pgtest

View file

@ -0,0 +1,46 @@
#pragma once
#include <gtest/gtest.h>
#include <optional>
#include <QString>
namespace pgtest {
/// Value of the TEST_DATABASE_URL environment variable, if set and non-empty.
std::optional<QString> baseUrl();
/// Same as baseUrl() but with the database name replaced.
QString urlForDb(const QString &db);
/// True when a server is configured and the template database was created.
bool available();
/// server_version_num of the server under test (0 when unavailable).
int serverVersionNum();
/// Portable process id.
qint64 processId();
/// Name of the template database for this process.
QString templateDbName();
/// Quotes an identifier for use in SQL.
QString quoteIdent(const QString &name);
/// Global environment, register it in main():
/// ::testing::AddGlobalTestEnvironment(new pgtest::PgEnv(PGTEST_FIXTURE_DIR));
/// Creates a template database from <fixtureDir>/schema.sql once per process.
class PgEnv : public ::testing::Environment {
public:
explicit PgEnv(QString fixtureDir) : fixtureDir_(std::move(fixtureDir)) {}
void SetUp() override;
void TearDown() override;
private:
QString fixtureDir_;
};
} // namespace pgtest
#define REQUIRE_PG_AT_LEAST(v) \
if (pgtest::serverVersionNum() < (v) * 10000) GTEST_SKIP() << "needs PG " #v