mirror of
https://github.com/rommapp/romm.git
synced 2026-06-27 22:35:57 +00:00
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:
5
.github/workflows/pytest.yml
vendored
5
.github/workflows/pytest.yml
vendored
@@ -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
15
backend/conftest.py
Normal 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}"
|
||||
@@ -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;
|
||||
|
||||
@@ -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"])
|
||||
|
||||
|
||||
|
||||
@@ -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
30
uv.lock
generated
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user