diff --git a/docs/module/pytest.md b/docs/module/pytest.md index 80d7f6a..cf6e448 100644 --- a/docs/module/pytest.md +++ b/docs/module/pytest.md @@ -89,7 +89,7 @@ async with create_db_session( ## Parallel testing with pytest-xdist -The fixtures above work with `pytest-xdist` out of the box. Each worker gets its own database suffixed with the worker name (e.g. `myapp_gw0`, `myapp_gw1`). +The fixtures above work with `pytest-xdist` out of the box. Each worker gets its own database named after the worker (e.g. `gw0`, `gw1`). Pass `prefix` to namespace the database (e.g. `prefix="myapp"` → `myapp_gw0`). Use [`worker_database_url`](../reference/pytest.md#fastapi_toolsets.pytest.utils.worker_database_url) to derive the per-worker URL manually if needed: @@ -97,6 +97,10 @@ Use [`worker_database_url`](../reference/pytest.md#fastapi_toolsets.pytest.utils from fastapi_toolsets.pytest import worker_database_url url = worker_database_url("postgresql+asyncpg://user:pass@localhost/myapp", default_test_db="test") +# → "postgresql+asyncpg://user:pass@localhost/gw0" under xdist +# → "postgresql+asyncpg://user:pass@localhost/test" otherwise + +url = worker_database_url("postgresql+asyncpg://user:pass@localhost/myapp", default_test_db="test", prefix="myapp") # → "postgresql+asyncpg://user:pass@localhost/myapp_gw0" under xdist # → "postgresql+asyncpg://user:pass@localhost/myapp_test" otherwise ``` diff --git a/src/fastapi_toolsets/pytest/utils.py b/src/fastapi_toolsets/pytest/utils.py index 40a4bdd..94f61bd 100644 --- a/src/fastapi_toolsets/pytest/utils.py +++ b/src/fastapi_toolsets/pytest/utils.py @@ -34,12 +34,18 @@ def _get_xdist_worker(default_test_db: str) -> str: return os.environ.get("PYTEST_XDIST_WORKER", default_test_db) -def worker_database_url(database_url: str, default_test_db: str) -> str: +def worker_database_url( + database_url: str, + default_test_db: str, + *, + prefix: str | None = None, +) -> str: """Derive a per-worker database URL for pytest-xdist parallel runs. - Appends ``_{worker_name}`` to the database name so each xdist worker - operates on its own database. When not running under xdist, - ``_{default_test_db}`` is appended instead. + Sets the database name to the worker name so each xdist worker operates + on its own database. When not running under xdist, *default_test_db* is + used instead. When *prefix* is provided, the name becomes + ``{prefix}_{worker}``. The worker name is read from the ``PYTEST_XDIST_WORKER`` environment variable (set automatically by xdist in each worker process). @@ -48,6 +54,9 @@ def worker_database_url(database_url: str, default_test_db: str) -> str: database_url: Original database connection URL. default_test_db: Suffix appended to the database name when ``PYTEST_XDIST_WORKER`` is not set. + prefix: Optional prefix prepended to the worker name + (e.g. ``"test"`` → ``"test_gw0"``). Without it, the database + name is just the worker name (e.g. ``"gw0"``). Returns: A database URL with a worker- or default-specific database name. @@ -55,7 +64,8 @@ def worker_database_url(database_url: str, default_test_db: str) -> str: worker = _get_xdist_worker(default_test_db=default_test_db) url = make_url(database_url) - url = url.set(database=f"{url.database}_{worker}") + db_name = f"{prefix}_{worker}" if prefix else worker + url = url.set(database=db_name) return url.render_as_string(hide_password=False) @@ -64,6 +74,7 @@ async def create_worker_database( database_url: str, default_test_db: str = "test_db", *, + prefix: str | None = None, server_url: str | None = None, ) -> AsyncGenerator[str, None]: """Create and drop a per-worker database for pytest-xdist isolation. @@ -80,6 +91,9 @@ async def create_worker_database( the worker database name). default_test_db: Suffix appended to the database name when ``PYTEST_XDIST_WORKER`` is not set. Defaults to ``"test_db"``. + prefix: Optional prefix prepended to the worker name + (e.g. ``prefix="test"`` → ``"test_gw0"``). Without it, the + database name is just the worker name (e.g. ``"gw0"``). server_url: URL used for server-level DDL (must point to an existing database on the same server). Defaults to *database_url* with the database omitted, letting asyncpg fall back to the username. @@ -107,7 +121,7 @@ async def create_worker_database( ``` """ worker_url = worker_database_url( - database_url=database_url, default_test_db=default_test_db + database_url=database_url, default_test_db=default_test_db, prefix=prefix ) worker_db_name = make_url(worker_url).database assert worker_db_name is not None diff --git a/tests/test_pytest.py b/tests/test_pytest.py index 7ae3f0c..6cd01e1 100644 --- a/tests/test_pytest.py +++ b/tests/test_pytest.py @@ -442,21 +442,19 @@ class TestGetXdistWorker: class TestWorkerDatabaseUrl: """Tests for worker_database_url helper.""" - def test_appends_default_test_db_without_xdist( - self, monkeypatch: pytest.MonkeyPatch - ): - """default_test_db is appended when not running under xdist.""" + def test_uses_default_test_db_without_xdist(self, monkeypatch: pytest.MonkeyPatch): + """default_test_db is used as the database name when not running under xdist.""" monkeypatch.delenv("PYTEST_XDIST_WORKER", raising=False) url = "postgresql+asyncpg://user:pass@localhost:5432/mydb" result = worker_database_url(url, default_test_db="fallback") - assert make_url(result).database == "mydb_fallback" + assert make_url(result).database == "fallback" - def test_appends_worker_id_to_database_name(self, monkeypatch: pytest.MonkeyPatch): - """Worker name is appended to the database name.""" + def test_uses_worker_id_as_database_name(self, monkeypatch: pytest.MonkeyPatch): + """Worker name is used as the database name.""" monkeypatch.setenv("PYTEST_XDIST_WORKER", "gw0") url = "postgresql+asyncpg://user:pass@localhost:5432/db" result = worker_database_url(url, default_test_db="unused") - assert make_url(result).database == "db_gw0" + assert make_url(result).database == "gw0" def test_preserves_url_components(self, monkeypatch: pytest.MonkeyPatch): """Host, port, username, password, and driver are preserved.""" @@ -469,7 +467,21 @@ class TestWorkerDatabaseUrl: assert result.password == "secret" assert result.host == "dbhost" assert result.port == 6543 - assert result.database == "testdb_gw2" + assert result.database == "gw2" + + def test_prefix_with_xdist(self, monkeypatch: pytest.MonkeyPatch): + """prefix is prepended to the worker name when running under xdist.""" + monkeypatch.setenv("PYTEST_XDIST_WORKER", "gw0") + url = "postgresql+asyncpg://user:pass@localhost:5432/mydb" + result = worker_database_url(url, default_test_db="unused", prefix="myapp") + assert make_url(result).database == "myapp_gw0" + + def test_prefix_without_xdist(self, monkeypatch: pytest.MonkeyPatch): + """prefix is prepended to default_test_db when not running under xdist.""" + monkeypatch.delenv("PYTEST_XDIST_WORKER", raising=False) + url = "postgresql+asyncpg://user:pass@localhost:5432/mydb" + result = worker_database_url(url, default_test_db="test", prefix="myapp") + assert make_url(result).database == "myapp_test" class TestCreateWorkerDatabase: @@ -479,7 +491,7 @@ class TestCreateWorkerDatabase: async def test_creates_default_db_without_xdist( self, monkeypatch: pytest.MonkeyPatch ): - """Without xdist, creates a database suffixed with default_test_db.""" + """Without xdist, creates a database named after default_test_db.""" monkeypatch.delenv("PYTEST_XDIST_WORKER", raising=False) default_test_db = "no_xdist_default" expected_db = make_url( @@ -655,6 +667,36 @@ class TestCreateWorkerDatabase: if lingering_engine: await lingering_engine.dispose() + @pytest.mark.anyio + async def test_prefix_names_database(self, monkeypatch: pytest.MonkeyPatch): + """prefix is prepended to the worker name in the created database.""" + monkeypatch.setenv("PYTEST_XDIST_WORKER", "gw_prefix") + expected_db = make_url( + worker_database_url(DATABASE_URL, default_test_db="unused", prefix="pfx") + ).database + assert expected_db == "pfx_gw_prefix" + + async with create_worker_database(DATABASE_URL, prefix="pfx") as url: + assert make_url(url).database == expected_db + + engine = create_async_engine(DATABASE_URL, isolation_level="AUTOCOMMIT") + async with engine.connect() as conn: + result = await conn.execute( + text("SELECT 1 FROM pg_database WHERE datname = :name"), + {"name": expected_db}, + ) + assert result.scalar() == 1 + await engine.dispose() + + engine = create_async_engine(DATABASE_URL, isolation_level="AUTOCOMMIT") + async with engine.connect() as conn: + result = await conn.execute( + text("SELECT 1 FROM pg_database WHERE datname = :name"), + {"name": expected_db}, + ) + assert result.scalar() is None + await engine.dispose() + class _LocalBase(DeclarativeBase): pass