python-testing
Apply backend pytest conventions: expects assertions, async fixtures, autospecced repository ports, test paths and layer-specific examples. Use when writing or refining a Python test under backend/tes
SKILL.md.claude/skills/python-testing/SKILL.md
GitHubPython Testing — SaaS Bootstrap Backend
Use this reference for test syntax, fixtures and placement after the behavior to verify is identified. verify-change owns test selection, diagnosis, coverage scope and evidence; TDD describes the incremental red/green method. Consulting these conventions does not start another workflow.
Applying the conventions
- Read the source file to test
- Determine the architectural layer (domain, application, infrastructure, presentation)
- Read existing fixtures in
tests/conftest.pyand relevant moduleconftest.pyfiles - Read the layer-specific patterns from references/patterns.md
- Write the test at the matching path for the selected behavior. Follow the project's verification guide for commands, markers and services; API tests require the API selector. Tests are not selected by source layer alone.
Path Convention
Source: backend/src/<module>/<layer>/<feature>/<file>.py
Test: backend/tests/<module>/<layer>/<feature>/test_<file>.py
Ensure __init__.py exists in every directory of the test path.
Core Rules
- Value assertions use
expects(expect(x).to(equal(y))), never bareassert. Known debt: some installed tests still use bareassert(tests/tenants/presentation/test_tenant_user_endpoint.py,tests/common/test_settings.py); do not copy it. - Exceptions:
with pytest.raises(SomeDomainError): await use_case.execute(). It awaits the coroutine inside the block, so it works for async code. Do not write try/except capture blocks, and do not use theraise_errormatcher on an async method (it never runs the coroutine body). - Calls on mocks: autospecced async methods are
AsyncMocks; assert withassert_awaited_once_with(...),assert_not_awaited()or readawait_args. - Standalone functions — never classes with
@staticmethod - AAA pattern — blank line before Assert block
- async by default —
async def test_...for async code.asyncio_mode = "auto"is configured inbackend/pyproject.toml; no@pytest.mark.asyncioneeded. - Cover the error path, and for tenant-scoped behavior both the allowed and a wrong tenant scope.
- Test naming:
test_<action>__<scenario>(double underscore separates action from scenario)
Available Fixtures
The conftest files and the [tool.pytest.ini_options] markers in
backend/pyproject.toml are the source of truth. This summary helps you find
them; read the fixture before relying on its shape, and trust the code if they
disagree.
Global (tests/conftest.py)
tenant_id: random UUIDtenant:Tenantdomain model (ACTIVE status, fixed slugtest-company)setup_database(session-scoped, requested by DB fixtures): creates an exclusive test database and all tables, drops it on teardowndatabase_config(session-scoped): the shared engine, disposed at the endsession_maker: itsasync_sessionmaker, to read back through a new sessionasync_session: function-scopedAsyncSessionfor DB operations
E2E API (tests/api/conftest.py)
BASE_URL(fromE2E_BASE_URL) andLoginTestContext: import them, never hard-code the URLapi_key_header: admin API key header dictnew_registered_user: registers test user via HTTPnew_registered_tenant: registers test tenant via HTTPlogin_user:LoginTestContextwithaccess_token,refresh_token,tenant_slug,tenant_id
Module-level (in tests/<module>/<layer>/conftest.py)
Place autospecced ports and buses here with
create_autospec(spec=Port, spec_set=True, instance=True); e.g.
tests/users/application/conftest.py provides query_bus and command_bus.
Inline (in test file)
Use for fixtures specific to that test file only (e.g., use_case, orm_instance).
Test Markers
@pytest.mark.single # Run with pytest -m single — for debugging one test
@pytest.mark.api # API/E2E test (module-level `pytestmark`)
@pytest.mark.skip(reason="...")
db is added automatically by pytest_collection_modifyitems in
tests/conftest.py to every test that requests setup_database or
async_session; do not add it by hand. Add it manually only for a DB test that
uses neither fixture (tests/common/database/test_migrations.py).
Database isolation gotchas
async_sessiondoes not wrap the test in a rolled-back transaction, and repository writes commit throughatomic_transaction. Rows persist in the test database for the rest of the pytest session.- Therefore generate unique slugs/emails per test (
f"t-{uuid4().hex[:8]}"); thetenantfixture's fixed slug collides if two tests persist it. - Do not use the
get_scoped_sessionfactories insrc/common/database/factories/from tests; they open their own engine outsideasync_session. - pytest-asyncio loop scope:
backend/pyproject.tomlsets bothasyncio_default_fixture_loop_scopeandasyncio_default_test_loop_scopetosession, so the session-scopeddatabase_configengine (disposed at the end) is shared safely;async_sessionopens a session from itssession_maker. - Repository adapters have integration tests against PostgreSQL
(
tests/users/infrastructure/repositories/); check durability by reading back through a new session, since a flush-only write looks correct inside the same one. - Warnings are not ignored globally: fix a new
DeprecationWarningor add a targeted filter with the reason.
Quick Reference by Layer
| Layer | DB? | Async? | Mocks? | Exemplar / fixture source |
|---|---|---|---|---|
| Domain | No | No | No | tests/common/domain/models/tenants/test_tenant_user.py |
| Application | No | Yes | Yes (ports, buses) | tests/users/application/use_cases/tenant_user/test_remover.py |
| Infrastructure | Yes | Yes | No | async_session |
| Presentation (unit) | No | Yes | Fakes via monkeypatch |
tests/tenants/presentation/test_tenant_user_endpoint.py |
| Presentation (API/E2E) | Stack | No* | No | tests/api/test_users.py, login_user |
*API tests use requests (sync HTTP client) against a running stack.
For detailed patterns and examples per layer, see references/patterns.md.