| name | backend-testing |
| description | MANDATORY rules and helpers for writing or running back-end (FastAPI/SQLAlchemy) tests in expAInses. Use this whenever you add, edit, or run a Python test, create a test file under backend/tests, set up pytest, or verify back-end behavior. Enforces: random test data only, a REAL on-disk SQLite database, and full data cleanup after every test.
|
Back-end testing
These rules are non-negotiable for every back-end test in this project.
Read them in full before writing or running any test.
The three hard rules
-
Random data only. Never hard-code field values (names, amounts, dates,
notes, colors, emojis). Generate every value through the helpers in
backend/tests/helpers.py. If a test needs a specific value, pass it as an
explicit override to a generator (e.g. random_transaction_payload(amount_milli=-1500))
so the rest of the model stays random. This keeps tests honest: they must
pass for arbitrary inputs, not one lucky fixture.
-
A real SQLite database — never :memory:. Tests run against an actual
on-disk SQLite file (created in a temp dir by the _engine fixture in
backend/tests/conftest.py). Do not swap in sqlite:///:memory:, do not
mock the session, and do not stub out SQLAlchemy. The point is to exercise
real SQL: constraints, foreign keys (PRAGMA foreign_keys=ON),
ondelete=SET NULL, the amount_milli != 0 check, and unique names.
-
Clean up after every test. The autouse _clean_tables fixture deletes
all rows from all tables after each test (children first, respecting FKs).
Every test therefore starts from an empty real database. Never rely on data
left behind by another test, and never disable this fixture. If you need a
pristine schema instead of just empty tables, recreate it with
Base.metadata.drop_all + create_all — but emptying rows is the default
and is enough.
The helper module
backend/tests/helpers.py is the single source of truth for assertions and
random (partial) model generation. Always import from it. Do not re-roll
randomness or copy assertion logic into individual test files.
Generators (all accept keyword overrides to produce partial models):
random_string, random_color, random_emoji, random_amount_milli,
random_date, random_note — primitive field generators.
random_category_payload(**overrides) / random_transaction_payload(**overrides)
— JSON-ready dicts for API (client) tests. date is an ISO string.
make_category(db, **overrides) / make_transaction(db, **overrides)
— persist a random ORM row to the real DB and return it (for DB-level tests
or for seeding state an API test depends on).
Assertions (always prefer these over bare assert for clearer failures):
assert_status(response, expected) — includes the response body on failure.
assert_has_keys(data, *keys)
assert_category_matches(data, expected) / assert_transaction_matches(data, expected)
assert_row_count(db, Model, expected)
When the app gains a new model, add its random_*_payload, make_*, and
assert_*_matches helpers to helpers.py first, then write the test.
Fixtures (in backend/tests/conftest.py)
_engine (session) — the real temp-file SQLite engine, with FKs enabled.
db (function) — a Session on that engine, for DB-level tests and factories.
client (function) — a TestClient with get_db overridden to the test
engine. Lifespan is intentionally not run, so the production
expainses.db is never touched and no auto-seeding happens — tests create the
exact random state they need.
_clean_tables (autouse) — wipes all rows after each test.
Running the tests
backend\.venv\Scripts\python -m pip install -r backend\requirements-test.txt # first time
backend\.venv\Scripts\python -m pytest backend\tests -q
backend/pytest.ini sets pythonpath = . and testpaths = tests, so run from
the backend directory or pass the path as above.
Worked example
from tests.helpers import (
assert_status, assert_transaction_matches,
make_category, random_transaction_payload,
)
def test_create_transaction_with_category(client, db):
category = make_category(db)
payload = random_transaction_payload(category_id=category.id)
response = client.post("/api/transactions", json=payload)
assert_status(response, 201)
body = response.json()
assert_transaction_matches(body, payload)
assert body["category"]["id"] == category.id