Testing and Validation Strategy¶
How this project is tested, what the gates are, and the conventions to follow when adding
tests. For what each test file covers, see
CODEBASE_OVERVIEW.md §9.
Gates¶
CI (.github/workflows/test.yml) runs three jobs,
all three of which are required status checks on main:
| Job | What it runs |
|---|---|
test |
ruff check src/, the full pytest suite against a fresh postgres:14, and two coverage floors. |
frontend |
npx tsc -b --noEmit, npm run test:run, npm run build. |
security |
safety and bandit scans. |
Coverage floors: engine ≥92% (coverage report --include='src/engine/*') and
overall ≥60%. The engine floor has under a point of headroom and is deliberately not
ratcheted tighter — a tighter floor would make ordinary dead-code deletion fail CI.
Reproduce the gates locally before pushing:
ruff check src/
PYTHONPATH=src python -m pytest tests/ -q --cov=src --cov-report=
coverage report --include='src/engine/*' --fail-under=92
coverage report --fail-under=60
cd frontend && npx tsc -b --noEmit && npm run test:run && npm run build
The database trap¶
Database-dependent tests skip silently without SQLALCHEMY_DATABASE_URL (or
DIPLOMACY_DATABASE_URL, or a .env in the repository root). A local run without a
database looks green while testing almost nothing. If a test you expect to run is skipped,
the environment is wrong — never treat a no-DB green run as a pass. CI always provides a
fresh Postgres container.
Markers¶
Declared in pytest.ini; select with pytest -m <marker>.
| Marker | Meaning |
|---|---|
unit |
Isolated, fast, no external dependencies. |
integration |
Multiple components together; may use a real database. |
database |
Requires a database connection. |
datc |
DATC conformance cases. |
map |
Requires map files or CairoSVG rendering. |
telegram, channels |
Bot commands and channel integration (Telegram API mocked). |
slow |
Long-running scenarios and simulations. |
execution_context |
Code run as a script / under a production PYTHONPATH, not just imported. |
deployment, infrastructure |
Deployment scripts, systemd/nginx config validation. |
ai, performance |
Order-generation heuristics; benchmarks. |
asyncio_mode = auto, so async tests need no explicit marker.
filterwarnings is empty on purpose, and must stay that way. The blanket
--disable-warnings and ignore::DeprecationWarning entries that used to live there were
concealing 22 real naive-datetime bugs. Do not add blanket entries; fix or narrowly
silence the individual warning instead.
Test layers¶
Engine (tests/engine/, tests/datc/) — the engine is pure, so it needs no fixtures,
no database, and no mocks. This is where correctness actually lives:
tests/datc/— one test per official DATC case (6.A–6.J, ~154 cases). 144 pass; 10 are documentedxfails with the reason inline. Do not un-xfail one without the corresponding engine work (seeadjudication.md§11).tests/datc/harness.py—place_units/give_orders/adjudicate/assert_result/assert_dislodged. Use it rather than hand-rolling state.tests/datc/test_properties.py— Hypothesis properties: shuffling the order list never changes the outcome (determinism), ≤1 unit per province after resolution, unit conservation, every dislodged unit has a computed legal retreat set, and the engine imports nothing outside the standard library.
Service and API — GameService scenarios driven through the real public API
(create_game → submit_orders → process_turn → view), and route tests asserting the
GameState-native response shape. Prefer driving a real game to hand-building a GameState.
Clients — bot tests mock the Telegram API and assert against the HTTP layer; the bot
must never call the engine or renderer directly, and there is a test asserting exactly that.
Frontend tests use Vitest + React Testing Library — see
frontend/docs/TESTING.md.
Rendering — render through the real API-route functions and assert on PNG magic bytes and non-trivial size. For changes that should be behaviour-preserving (module splits, exception narrowing), compare sha256 of the rendered PNG before and after with the byte cache cleared. That check catches what the suite cannot: a swallowed exception handing back a subtly wrong image while every test still passes.
Conventions¶
- Use real topology. Build state from
Game.new_standard()or the DATC harness so adjacency comes frommaps/standard.map, never from hand-written adjacency data. - Assert something. Several historical test files collected zero tests or asserted
nothing at all (a
MemoryRouterwith no<Route>leavesuseParams()unresolved and tests only the spinner;*Testerclasses don't matchpython_classes = Test*). When a test passes suspiciously easily, verify it is exercising the real path. - No real sleeps. Use an injectable clock or call the function under test directly
(e.g.
check_and_send_reminders(now)). - Mock only at boundaries — the Telegram API, SMTP, the filesystem. Do not mock the
engine,
GameService, or the database when a real one is available. - Cover new database code.
DatabaseServiceis large; any new method needs a direct test, including one asserting that detached-object mutation patterns do not creep back in.