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) <noreply@anthropic.com>
This commit is contained in:
Georges-Antoine Assi
2026-06-19 21:02:47 -04:00
parent 4b58479232
commit 02815ec403
6 changed files with 87 additions and 7 deletions

View File

@@ -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

15
backend/conftest.py Normal file
View File

@@ -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}"

View File

@@ -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;

View File

@@ -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"])

View File

@@ -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]

30
uv.lock generated
View File

@@ -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" },