From 02815ec403218b922d24e2027095bd60c791712a Mon Sep 17 00:00:00 2001 From: Georges-Antoine Assi Date: Fri, 19 Jun 2026 21:02:47 -0400 Subject: [PATCH] Run backend tests in parallel with pytest-xdist Add pytest-xdist and run the backend test suite across multiple workers (`-n 4` in CI). Each worker gets its own database so the autouse `clear_database` fixture can't wipe rows another worker is mid-test with: - Rootdir `backend/conftest.py` sets a per-worker `DB_NAME` (`romm_test_gw0`, ...) before any app module is imported, so each worker's engine binds to its own database. - `tests/conftest.py` creates the per-worker database on demand (mariadb/ mysql and postgresql paths) just before migrations run. - The test user's grant is widened to `*.*` (setup.sql + CI) so it can `CREATE DATABASE` for the workers. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/pytest.yml | 5 +++-- backend/conftest.py | 15 ++++++++++++++ backend/romm_test/setup.sql | 4 +++- backend/tests/conftest.py | 38 +++++++++++++++++++++++++++++++++++- pyproject.toml | 2 ++ uv.lock | 30 +++++++++++++++++++++++++--- 6 files changed, 87 insertions(+), 7 deletions(-) create mode 100644 backend/conftest.py diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 184c556ac..a6b1f44f2 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -71,7 +71,7 @@ jobs: - name: Initiate MariaDB database if: matrix.db == 'mariadb' run: | - mysql --host 127.0.0.1 --port ${{ job.services.mariadb.ports['3306'] }} -uroot -ppasswd -e "GRANT ALL PRIVILEGES ON romm_test.* TO 'romm_test'@'%' WITH GRANT OPTION; FLUSH PRIVILEGES;" + mysql --host 127.0.0.1 --port ${{ job.services.mariadb.ports['3306'] }} -uroot -ppasswd -e "GRANT ALL PRIVILEGES ON *.* TO 'romm_test'@'%' WITH GRANT OPTION; FLUSH PRIVILEGES;" - name: Run python tests env: @@ -83,7 +83,8 @@ jobs: HYPOTHESIS_PROFILE: ci run: | cd backend - uv run pytest -vv --maxfail=10 --junitxml=pytest-report.xml --cov --cov-report xml:coverage.xml --cov-config=.coveragerc . + # GitHub-hosted Linux runners have 4 vCPUs; run one worker per core. + uv run pytest -n 4 -vv --maxfail=10 --junitxml=pytest-report.xml --cov --cov-report xml:coverage.xml --cov-config=.coveragerc . - name: Publish test results uses: EnricoMi/publish-unit-test-result-action/linux@v2.20.0 diff --git a/backend/conftest.py b/backend/conftest.py new file mode 100644 index 000000000..614fdae22 --- /dev/null +++ b/backend/conftest.py @@ -0,0 +1,15 @@ +import os + +# When running under pytest-xdist, give each worker its own database so the +# autouse `clear_database` fixture in one worker can't wipe rows another worker +# is mid-test with. This must run before any application module (config / +# database handlers) is imported, so the engine built at import time binds to +# the per-worker name. As the rootdir conftest, this file is imported before +# `tests/conftest.py` (which imports those modules). +# +# The Redis cache needs no equivalent handling: under pytest it is an in-process +# FakeRedis, so each worker process is already isolated. +_xdist_worker = os.environ.get("PYTEST_XDIST_WORKER") +if _xdist_worker: + _base_db_name = os.environ.get("DB_NAME", "romm_test") + os.environ["DB_NAME"] = f"{_base_db_name}_{_xdist_worker}" diff --git a/backend/romm_test/setup.sql b/backend/romm_test/setup.sql index fb1f78088..91d1674a9 100644 --- a/backend/romm_test/setup.sql +++ b/backend/romm_test/setup.sql @@ -1,4 +1,6 @@ CREATE DATABASE IF NOT EXISTS romm_test; CREATE USER IF NOT EXISTS 'romm_test'@'%' IDENTIFIED BY 'passwd'; -GRANT ALL PRIVILEGES ON romm_test.* TO 'romm_test'@'%' WITH GRANT OPTION; +-- Grant on *.* (not just romm_test.*) so the test user can create the +-- per-worker databases (romm_test_gw0, ...) used when running under pytest-xdist. +GRANT ALL PRIVILEGES ON *.* TO 'romm_test'@'%' WITH GRANT OPTION; FLUSH PRIVILEGES; diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 64cad0bbd..9b3396168 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -5,9 +5,10 @@ import alembic.config import pytest from hypothesis import settings from joserfc import jwt -from sqlalchemy import create_engine +from sqlalchemy import create_engine, text from sqlalchemy.orm import sessionmaker +from config import ROMM_DB_DRIVER from config.config_manager import ConfigManager from handler.auth import auth_handler from handler.auth.base_handler import ALGORITHM, oct_key @@ -37,8 +38,43 @@ settings.register_profile("dev", max_examples=50, deadline=None) settings.load_profile(os.getenv("HYPOTHESIS_PROFILE", "dev")) +def _ensure_database_exists() -> None: + """Create the (possibly per-xdist-worker) test database if it's missing. + + The base `romm_test` database is provisioned by CI / local setup, but the + per-worker databases used under pytest-xdist (`romm_test_gw0`, ...) are + created on demand here, just before migrations run. + """ + url = ConfigManager.get_db_engine() + db_name = url.database + if not db_name: + return + + if ROMM_DB_DRIVER in ("mariadb", "mysql"): + # Connect to a maintenance schema that always exists; CREATE DATABASE is + # a server-level command regardless of the connected schema. + admin_engine = create_engine(url.set(database="information_schema")) + with admin_engine.begin() as conn: + conn.execute(text(f"CREATE DATABASE IF NOT EXISTS `{db_name}`")) + admin_engine.dispose() + elif ROMM_DB_DRIVER == "postgresql": + # CREATE DATABASE can't run inside a transaction. + admin_engine = create_engine( + url.set(database="postgres"), isolation_level="AUTOCOMMIT" + ) + with admin_engine.connect() as conn: + exists = conn.execute( + text("SELECT 1 FROM pg_database WHERE datname = :name"), + {"name": db_name}, + ).scalar() + if not exists: + conn.execute(text(f'CREATE DATABASE "{db_name}"')) + admin_engine.dispose() + + @pytest.fixture(scope="session", autouse=True) def setup_database(): + _ensure_database_exists() alembic.config.main(argv=["upgrade", "head"]) diff --git a/pyproject.toml b/pyproject.toml index 0caefab6a..2af12ab1f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -83,6 +83,7 @@ test = [ "pytest-env ~= 1.1", "pytest-mock ~= 3.12", "pytest-recording ~= 0.13", + "pytest-xdist ~= 3.6", ] [project.urls] @@ -135,6 +136,7 @@ DEP002 = [ # DEP002 rule: Project should not contain unused dependencies "pytest-env", "pytest-mock", "pytest-recording", + "pytest-xdist", ] [tool.uv] diff --git a/uv.lock b/uv.lock index c7ad1df72..1d8f1a901 100644 --- a/uv.lock +++ b/uv.lock @@ -7,12 +7,12 @@ resolution-markers = [ ] [options] -exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. +exclude-newer = "2026-06-12T13:49:31.696015Z" exclude-newer-span = "P7D" [options.exclude-newer-package] -starlette = "2026-05-22T22:00:00Z" -vcrpy = "2026-06-17T22:00:00Z" +starlette = "2026-05-23T04:00:00Z" +vcrpy = "2026-06-18T04:00:00Z" [[package]] name = "aiohappyeyeballs" @@ -551,6 +551,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d7/ee/bf0adb559ad3c786f12bcbc9296b3f5675f529199bef03e2df281fa1fadb/email_validator-2.2.0-py3-none-any.whl", hash = "sha256:561977c2d73ce3611850a06fa56b414621e0c8faa9d66f2611407d87465da631", size = 33521, upload-time = "2024-06-20T11:30:28.248Z" }, ] +[[package]] +name = "execnet" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/89/780e11f9588d9e7128a3f87788354c7946a9cbb1401ad38a48c4db9a4f07/execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd", size = 166622, upload-time = "2025-11-12T09:56:37.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" }, +] + [[package]] name = "executing" version = "2.2.0" @@ -2007,6 +2016,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/42/c2/ce34735972cc42d912173e79f200fe66530225190c06655c5632a9d88f1e/pytest_recording-0.13.4-py3-none-any.whl", hash = "sha256:ad49a434b51b1c4f78e85b1e6b74fdcc2a0a581ca16e52c798c6ace971f7f439", size = 13723, upload-time = "2025-05-08T10:41:09.684Z" }, ] +[[package]] +name = "pytest-xdist" +version = "3.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "execnet" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069, upload-time = "2025-07-01T13:30:59.346Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -2243,6 +2265,7 @@ test = [ { name = "pytest-env" }, { name = "pytest-mock" }, { name = "pytest-recording" }, + { name = "pytest-xdist" }, ] [package.metadata] @@ -2287,6 +2310,7 @@ requires-dist = [ { name = "pytest-env", marker = "extra == 'test'", specifier = "~=1.1" }, { name = "pytest-mock", marker = "extra == 'test'", specifier = "~=3.12" }, { name = "pytest-recording", marker = "extra == 'test'", specifier = "~=0.13" }, + { name = "pytest-xdist", marker = "extra == 'test'", specifier = "~=3.6" }, { name = "python-dotenv", specifier = "~=1.2" }, { name = "python-magic", specifier = "~=0.4" }, { name = "python-socketio", specifier = "~=5.11" },