From 514997614e8f2c4ee7e38830cb39bd650329cbb4 Mon Sep 17 00:00:00 2001 From: lovasoa Date: Mon, 28 Sep 2026 01:08:20 +0200 Subject: [PATCH] test: isolate local database runs and preserve user configuration --- .github/workflows/sqlx.yml | 2 +- test.sh | 71 +++++++++++++++++++++++----- tests/README.md | 56 +++++++++++++++-------- tests/test_local_runner.py | 94 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 192 insertions(+), 31 deletions(-) create mode 100644 tests/test_local_runner.py diff --git a/.github/workflows/sqlx.yml b/.github/workflows/sqlx.yml index d76ca66c7f..b4f3341b8c 100644 --- a/.github/workflows/sqlx.yml +++ b/.github/workflows/sqlx.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v6 - - run: python3 -m unittest discover -s tests -p 'test_wait_for_db.py' + - run: python3 -m unittest discover -s tests -p 'test_*.py' format: name: Format diff --git a/test.sh b/test.sh index fe9faf2a3e..bf75184f75 100755 --- a/test.sh +++ b/test.sh @@ -1,17 +1,64 @@ -#!/bin/sh -docker compose -f tests/docker-compose.yml run -it -p 5432:5432 --name postgres_16 postgres_16 -DATABASE_URL="postgres://postgres@localhost:5432/sqlx?sslmode=verify-ca&sslrootcert=./tests/certs/ca.crt&sslcert=./tests/certs/client.crt&sslkey=./tests/keys/client.key" cargo test --features any,postgres,macros,all-types,rustls -- +#!/usr/bin/env bash +set -euo pipefail +cd -- "$(dirname -- "${BASH_SOURCE[0]}")" -docker compose -f tests/docker-compose.yml run -it -p 1433:1433 --rm --name mssql_2022 mssql_2022 -DATABASE_URL='mssql://sa:Password123!@localhost/sqlx' cargo test --features any,mssql,macros,all-types,rustls -- +backend=${1:-all} +if [[ $backend == all ]]; then + for backend in postgres mssql mysql sqlite odbc; do + bash ./test.sh "$backend" + done + exit 0 +fi -docker compose -f tests/docker-compose.yml run -it -p 3306:3306 --name mysql_8 mysql_8 -DATABASE_URL='mysql://root:password@localhost/sqlx' cargo test --features any,mysql,macros,all-types,rustls -- +features="any,macros,all-types,rustls" +case "$backend" in + sqlite) + sqlite_dir=$(mktemp -d "${TMPDIR:-/tmp}/sqlx-local-sqlite.XXXXXX") + trap 'rm -rf -- "$sqlite_dir"' EXIT + cp tests/sqlite/sqlite.db "$sqlite_dir/sqlite.db" + export DATABASE_URL="sqlite://$sqlite_dir/sqlite.db" + cargo test --locked --no-default-features --features "$features,sqlite,migrate" + exit + ;; + postgres) service=postgres_14; port=5432; driver=postgres ;; + mysql) service=mysql_8; port=3306; driver=mysql ;; + mssql) service=mssql_2022; port=1433; driver=mssql ;; + odbc) service=postgres_16_no_ssl; port=5432; driver=postgres ;; + *) + echo "Usage: $0 [all|sqlite|postgres|mysql|mssql|odbc]" >&2 + exit 2 + ;; +esac -DATABASE_URL='sqlite://./tests/sqlite/sqlite.db' cargo test --features any,sqlite,macros,all-types,rustls -- +project="sqlx-local-test-$$-$RANDOM" +cleanup() { + status=$? + docker compose -p "$project" -f tests/docker-compose.yml \ + down --remove-orphans >/dev/null 2>&1 || true + exit "$status" +} +trap cleanup EXIT +# Each invocation owns one disposable container and a random loopback-only port. +container=$(docker compose -p "$project" -f tests/docker-compose.yml \ + run --rm -d -p "127.0.0.1::$port" "$service") +bash tests/wait-for-db.sh "$container" "$driver" +address=$(docker port "$container" "$port/tcp") +host_port=${address##*:} +if [[ ! $host_port =~ ^[0-9]+$ ]]; then + echo "Could not determine the database's published port: $address" >&2 + exit 1 +fi -# Copy odbc config from tests/odbc.ini to ~/.odbc.ini and run ODBC tests against Postgres -cp tests/odbc.ini ~/.odbc.ini -docker compose -f tests/docker-compose.yml run -p 5432:5432 --name postgres_16_no_ssl -it postgres_16_no_ssl -DATABASE_URL='DSN=SNOWFLAKE' cargo test --no-default-features --features any,odbc,all-types,macros,rustls \ No newline at end of file +case "$backend" in + postgres) DATABASE_URL="postgres://postgres:password@127.0.0.1:$host_port/sqlx" ;; + mysql) DATABASE_URL="mysql://root:password@127.0.0.1:$host_port/sqlx" ;; + mssql) DATABASE_URL="mssql://sa:Password123!@127.0.0.1:$host_port/sqlx" ;; + odbc) + # Use only the container we started; do not overwrite ~/.odbc.ini or use a saved DSN. + DATABASE_URL="Driver={PostgreSQL Unicode};Servername=127.0.0.1;Port=$host_port;Database=sqlx;Uid=postgres;Pwd=password" + ;; +esac +export DATABASE_URL +if [[ $backend != odbc ]]; then features+=",migrate"; fi +cargo test --locked --no-default-features --features "$features,$backend" diff --git a/tests/README.md b/tests/README.md index 60b60fccdf..c993dd54b9 100644 --- a/tests/README.md +++ b/tests/README.md @@ -1,18 +1,38 @@ - - -### Running Tests -SQLx uses docker to run many compatible database systems for integration testing. You'll need to [install docker](https://docs.docker.com/engine/) to run the full suite. You can validate your docker installation with: - - $ docker run hello-world - -Start the databases with `docker-compose` before running tests: - - $ docker-compose up - -Run all tests against all supported databases using: - - $ ./x.py - -If you see test failures, or want to run a more specific set of tests against a specific database, you can specify both the features to be tests and the DATABASE_URL. e.g. - - $ DATABASE_URL=mysql://root:password@127.0.0.1:49183/sqlx cargo test --no-default-features --features macros,offline,any,all-types,mysql,native-tls +# Running tests + +Install Rust through rustup and [Docker Compose](https://docs.docker.com/compose/install/). +Run the local test script from any directory: + +```sh +./test.sh # all backends, stopping at the first failure +./test.sh sqlite # SQLite only; no Docker needed +./test.sh postgres # PostgreSQL 14 +./test.sh mysql # MySQL 8 +./test.sh mssql # SQL Server 2022 +./test.sh odbc # ODBC against local PostgreSQL 16 +``` + +Each database run starts a disposable container, waits for its test schema, and +publishes a randomly assigned port on loopback. The script uses that container's +connection string and removes it when the test command exits, including failures. +SQLite runs against a temporary copy of its fixture database. +The script does not use an existing `DATABASE_URL`, a saved ODBC DSN, or modify `~/.odbc.ini`. +The ODBC run requires unixODBC and the **PostgreSQL Unicode** driver on the host. + +The PostgreSQL client-certificate authentication configuration is tested separately +by the **Postgres with SSL client cert** CI job. + +For custom databases, set `DATABASE_URL` explicitly when invoking Cargo. These tests +create and modify database objects: use a disposable test database. + +```sh +DATABASE_URL=mysql://root:password@127.0.0.1:3306/sqlx \ + cargo test --locked --no-default-features \ + --features macros,offline,any,all-types,mysql,native-tls +``` + +The test-runner and readiness regression tests need only Python and Bash: + +```sh +python3 -m unittest discover -s tests -p 'test_*.py' +``` diff --git a/tests/test_local_runner.py b/tests/test_local_runner.py new file mode 100644 index 0000000000..0d32305fcf --- /dev/null +++ b/tests/test_local_runner.py @@ -0,0 +1,94 @@ +"""Check local test isolation and cleanup without running databases or Cargo.""" +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest + + +class LocalRunnerTests(unittest.TestCase): + def run_backend(self, backend, cargo_status=0, docker_status=0): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + log = root / "commands.jsonl" + program = f"#!{sys.executable}\n" + ''' +import json +import os +from pathlib import Path +import sys + +name = Path(sys.argv[0]).name +with open(os.environ["COMMAND_LOG"], "a") as log: + log.write(json.dumps([name, sys.argv[1:], os.environ.get("DATABASE_URL")]) + "\\n") +if name == "cargo": + sys.exit(int(os.environ["CARGO_STATUS"])) +if sys.argv[1] == "compose": + if int(os.environ["DOCKER_STATUS"]): + sys.exit(int(os.environ["DOCKER_STATUS"])) + print("owned-container") +elif sys.argv[1] == "port": + print("127.0.0.1:49123") +elif sys.argv[1] == "inspect": + print("true") +''' + for name in ("docker", "cargo"): + tool = root / name + tool.write_text(program) + tool.chmod(0o755) + # A user's saved DSN must neither be overwritten nor selected. + config = root / ".odbc.ini" + config.write_text("[SNOWFLAKE]\nDatabase=production\n") + env = dict(os.environ, PATH=f"{root}{os.pathsep}{os.environ['PATH']}", + COMMAND_LOG=str(log), CARGO_STATUS=str(cargo_status), + DOCKER_STATUS=str(docker_status), ODBCINI=str(config), + DATABASE_URL="DSN=SNOWFLAKE") + result = subprocess.run( + ["bash", str(Path(__file__).resolve().parent.parent / "test.sh"), backend], + cwd=root, env=env, text=True, capture_output=True, timeout=10, + ) + self.assertEqual(config.read_text(), "[SNOWFLAKE]\nDatabase=production\n") + commands = [json.loads(line) for line in log.read_text().splitlines()] if log.exists() else [] + return result, commands + + def test_database_backends_use_owned_containers_and_random_loopback_ports(self): + for backend in ("postgres", "mysql", "mssql", "odbc"): + with self.subTest(backend=backend): + result, commands = self.run_backend(backend) + self.assertEqual(result.returncode, 0, result.stderr) + compose = next(args for name, args, _ in commands if name == "docker" and args[0] == "compose") + self.assertIn("-d", compose) + self.assertIn("--rm", compose) + self.assertTrue(compose[compose.index("-p", compose.index("run")) + 1].startswith("127.0.0.1::")) + cargo = next(command for command in commands if command[0] == "cargo") + self.assertIn("--locked", cargo[1]) + self.assertIn("49123", cargo[2]) + self.assertNotIn("DSN=", cargo[2]) + self.assertEqual(commands[-1][1][-2:], ["down", "--remove-orphans"]) + + def test_test_failure_is_preserved_after_cleanup(self): + result, commands = self.run_backend("odbc", cargo_status=17) + self.assertEqual(result.returncode, 17) + self.assertEqual(commands[-1][1][-2:], ["down", "--remove-orphans"]) + + def test_failed_container_start_never_runs_tests(self): + result, commands = self.run_backend("postgres", docker_status=19) + self.assertEqual(result.returncode, 19) + self.assertFalse(any(name == "cargo" for name, _, _ in commands)) + + def test_sqlite_does_not_start_docker(self): + result, commands = self.run_backend("sqlite") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual([name for name, _, _ in commands], ["cargo"]) + self.assertTrue(commands[0][2].startswith("sqlite:")) + self.assertFalse(Path(commands[0][2].removeprefix("sqlite://")).exists()) + + def test_invalid_backend_does_not_start_anything(self): + result, commands = self.run_backend("production") + self.assertEqual(result.returncode, 2) + self.assertEqual(commands, []) + + +if __name__ == "__main__": + unittest.main()