Testing
FlexTool has a multi-layer test pyramid: fast unit/integration tests on
the engine building blocks, scenario goldens built on demand
from JSON fixtures, and end-to-end scenario runs that compare output
CSVs against committed reference files. This page is a developer
pointer — where the tests live, how the JSON-fixture architecture
works, how the CI verify chain enforces drift detection, and how the
markers gate CI.
For the high-level test-layout inventory see
architecture.md ("Test layout and fixture
architecture"). For the engine internals being tested see
engine.md. For the JSON-fixture rollout
narrative see the CHANGELOG.md entry for v3.47.0.
Layer 1 — engine unit & integration
Lives under tests/engine/. Covers the LP build path, derived
parameter expansion, writer port, scaling pipeline (see
scaling.md), decomposition (see
decomposition.md), warm-start handoff, fast load,
stochastic / PBT pieces, and the Surface A audit subdirectories:
loaders/— A-tier loader tests (test_a01_a02_temporal_node.py,test_a03_a11_a12_process_topology.py, …) covering temporal-node, topology, online-ramp, storage-handoff, invest, costs/CO2, user-cstr/stochastic, groups/reserve/ladder, delay/dc loaders.constraints/— B-tier constraint tests (test_b01_b02_balance_storage.py…test_b09_b10_b11_dc_co2_user.py, plus emission / storage-handoff wiring).objective/— B16–B20 objective-term tests (varcost, invest cost, slack & reserve penalties, delay/DR/ladder).
Each subdirectory has its own conftest.py and a
test_fixtures_solve.py that exercises the shared fixture solve
fixtures.
Tests range from millisecond-class unit tests (block layout, derived
params) to solver-touching parity tests. Synthetic flextool-flavoured
fixtures live in tests/engine/fixtures/ and are auto-added to
sys.path by tests/engine/conftest.py so test modules can
from flex_toy_<feature> import … directly.
Meta-provider invariants.
tests/engine/test_meta_provider_invariants.py enforces the
Provider-only cascade contract as executable tests, not convention:
test_no_disk_csv_reads_in_cascade— fails if a cascade module re-introduces acsv.reader/open(...).read()against a CSV path without an explicit justification entry in the allowlist.test_no_photocopier_lru_cache— fails on module-level@lru_cacheover frame-returning helpers (cross-test contamination trap).test_no_module_level_frame_globals— fails on module-levelpl.DataFrame/pl.LazyFrameglobals.test_provider_or_shims_have_justification— every disk read still in the cascade must be accompanied by a documented reason.test_allowlists_cover_only_existing_files— the allowlists themselves cannot drift past the files they reference.
A cascade module that reads from disk silently is a meta-test failure, not a code-review catch.
Layer 2 — scenario fixtures, goldens, and parity
tests/fixtures/*.json is the single authoritative source
Every SQLite DB used by tests is built on demand from a JSON fixture.
The previous ~521 MB of tracked / gitignored SQLite + CSV fixtures has
collapsed to ~5 KB of JSON plus the on-demand cascade. Schema
migration covers the fixture set via
flextool.update_flextool.test_fixtures.migrate_all, and the CI
verify chain (see below) fails the build if any fixture drifts from
the current FLEXTOOL_DB_VERSION.
Canonical fixtures currently in tests/fixtures/:
| Fixture | Role |
|---|---|
tests.json |
Main fixture; tests/scenarios.yaml and most engine tests build their work-folders from here. Also the source for the generated templates_examples.json canonical DB. |
stochastics.json |
Stochastic / forecast-branch scenarios. Source for the generated howto_stochastics.json canonical DB. |
stochastics_pbt_inflow.json |
PBT inflow scenarios (period-block-time stochastic inflow). |
lh2_three_region.json |
LH2 three-region monolithic + Benders parity fixture (see decomposition.md). |
h2_trade_parity.json |
H2-trade Benders parity fixture. |
multi_ts_branch1.json |
Multi-timeseries branch-1 fixture. |
branch2_parent_period.json |
Branch-2 / parent-period stochastic fixture. |
case14_dc_power_flow.json |
PGLib IEEE case14 MATPOWER export (~8200 lines), used by test_flex_dc_power_flow.py. |
The _augment_*.py, build_*.py and regen_lh2_three_region.py scripts
alongside these JSONs are builders / overlays kept for provenance, not
runtime dependencies. They take the schema sections from
spinedb_schema.json (fixture_schema.schema_sections), and
tests/update_flextool/test_fixture_builders.py checks that each still
reproduces the data of its committed fixture. Every fixture carries
exactly the schema's sections (test_fixture_schema_drift.py).
scenario_workdir — on-demand cascade fixture
scenario_workdir(scenario_name, db_fixture="main"), defined in
tests/conftest.py, is the session-scoped fixture that
replaced the historical tests/engine/data/work_*/ snapshot
pattern. It shares one cached run with scenario_run (which returns the
tests._support.load.ScenarioRun instead of the folder). For a requested
(scenario, db_fixture) pair it:
- Resolves
db_fixtureagainst the per-fixture session-scoped DB (built once per session from the matching JSON). - Runs the full cascade (
tests._support.load.run_scenario:csv_dump=False,keep_solutions=True) in atmp_path_factorywork folder. - Copies the DB to
tests.sqliteand snapshots the last step's provider intoinput/+solve_data/, for tests that read those files. Nothing in a run reads them back: to get a sub-solve'sFlexData, usescenario_run+load_scenario(the production loader call: the step's provider + a DB reader). - Caches the result per
(scenario, db_fixture)— each scenario pays its ~2 s cascade cost once per test session regardless of how many tests consume it.
Valid db_fixture keys map 1:1 to the JSONs listed above (main,
stochastic, lh2, case14, h2_trade_parity, multi_ts_branch1,
stochastics_pbt_inflow, branch2_parent_period).
def test_foo(scenario_workdir):
work = scenario_workdir("coal_ladder_annual", db_fixture="main")
# work is a Path to a populated workdir with input/output CSVs.
The two tests/engine/data/work_<scenario>/ directories that
survive after Stage 4 (work_lh2_three_region, work_test_a_lot)
hold only a single golden_obj.json golden objective each. No
migrated test depends on the gitignored bulk CSV snapshot pattern;
the entry stays on .gitignore for back-compat only.
extend_tests_fixture — agent-friendly append-only edits
flextool.update_flextool.extend_tests_fixture is a YAML-delta
workflow for append-only additions to tests/fixtures/*.json —
new entities, alternatives, scalar parameter values, and scenarios.
It exists because rewriting authoritative JSON by hand is risky:
- Validates every name against
spinedb_schema.jsonwith did-you-mean suggestions. - Rejects edits to existing entries (append-only enforcement).
- Rejects structured (
Map/ time-series) values, pointing the caller at the SpineDB editor for those. - Eight unit tests under
tests/spinedb_backend/lock the contract.
Agents add to fixtures without rewriting them; structured / curated edits remain a human-with-SpineDB-editor job.
generate_canonical — projected canonical DBs
flextool.update_flextool.generate_canonical projects authoritative
fixtures into derived canonical JSONs. The recipes in
flextool/schemas/canonical_databases/_recipes.yaml declare two
projections today:
tests/fixtures/tests.json→templates_examples.jsontests/fixtures/stochastics.json→howto_stochastics.json
The remaining howto_*.json files plus
templates_time_settings_only.json stay individually authoritative —
purpose-specific minimal teaching examples whose curation goals
differ from test-coverage curation. generate_canonical --verify
fails when a projected canonical drifts from its source.
Golden objectives & scenario goldens
tests/scenarios.yaml defines a parametrised end-to-end scenario
set that runs in tests/test_scenarios.py:
- Build the session-scoped SQLite DB from
tests/fixtures/<fixture>.json. - Run
FlexToolRunnerfor the scenario in an isolated tmp workdir. - Compare selected output CSVs against
tests/expected/<scenario>/*.csv. - If the scenario YAML entry sets
expected_objective, assert thatsummary_solve.csvlands withinexpected_objective_tolerance(default1e-3, often tightened to1e-5for stable scenarios). - If
time_budget_secondsis set, assert the full write-input → solve → write-outputs cycle finishes in budget.
Standalone golden integration tests live alongside (e.g.
tests/test_lh2_three_region.py pins the LH2 three-region monolithic
objective against tests/expected/lh2_three_region/objective.json at
relative tolerance 1e-4). The same fixture also backs the native
Benders gap-to-monolithic check — see
decomposition.md and
slack_convention.md.
Layer 3 — full integration
The tests/test_scenarios.py driver above covers Layer 3 for the
in-tree scenarios (tests/scenarios.yaml parametrises it, goldens
live in tests/expected/<scenario>/). The separate execution_tests/
directory contains Spine Toolbox workflow execution tests
(test_execution.py, tool.py) that exercise the same scenarios
through the Toolbox DAG rather than the Python runner directly.
Three-tier gate cadence (matches the
architecture.md wording so contributors converge
on the same loop):
- Inner loop — module-specific tests after every edit, typically
pytest tests/engine/<area>/. Run continuously while iterating. - Broader gate —
pytest tests/engine/full sweep, once per commit batch (~27 minutes on a recent reference run). - Activation truth —
pytest tests/(Layer 1 + scenario integration suite +tests/model/+tests/gui/+tests/spinedb_backend/), run rarely (overnight / pre-release).
Inner loop ≠ confidence to merge; activation truth is the only gate that signals end-to-end health.
CI verify chain
.github/workflows/tests.yml::template-check enforces four drift
checks before any Linux/Windows/macOS test job runs. The same
commands are valid as pre-push sanity locally:
| Step | Command | What it checks |
|---|---|---|
| Master template parity | python -m flextool.update_flextool.sync_master_json_template --verify |
schemas/spinedb_schema.json matches the migration chain (see db_schema.md). |
| Test fixtures schema | python -m flextool.update_flextool.test_fixtures verify |
All tests/fixtures/*.json are at the current FLEXTOOL_DB_VERSION. |
| Generated canonicals | python -m flextool.update_flextool.generate_canonical --verify |
Generated canonical JSONs (templates_examples.json, howto_stochastics.json) match what generate_canonical would emit from the current authoritative sources. |
| Canonical schema | python -m flextool.update_flextool.canonical_databases verify |
The authoritative canonical JSONs themselves are at the current schema. |
A drift in any of the four is a failed workflow, not a code-review
catch. See the architecture.md "verify hooks"
tail for the same chain in the higher-level inventory and
db_schema.md for the migration-chain side of the
contract.
Markers and gating
Defined in pyproject.toml and re-asserted in tests/conftest.py:
| Marker | Meaning |
|---|---|
smoke |
Layer-1 + scenarios on the per-commit CI gate (<1 min) |
solver |
Touches a real solver (HiGHS), 5–60 s each |
slow |
>~30 s — nightly only |
decomposition |
Tier 8 objective-decomposition parity (tests/test_scenarios.py) |
perturbation |
Tier 6 single-multiplier perturbation tests |
emission |
Tier 7 MPS row-count emission tests |
csv_reload |
CSV reload contract tests in tests/csv_reload/ — opt-in |
gui |
Opens a real Tk window — opt-in with --run-gui (see "GUI tests" below) |
The smoke marker is attached automatically to scenarios in
scenarios.yaml that set smoke: true.
csv_reload is attached by tests/csv_reload/conftest.py to every test
in that folder: tests that load a CSV work folder (a provider snapshot, a
dump_csvs tree, hand-written CSVs) with the unsupported
flextool.engine._csv_reload.load_from_dump (a provider seeded from the
folder's CSVs), which production never does: load_flextool needs the
sub-solve's provider and a DB reader, and never reads the work folder. The default addopts (-m 'not csv_reload')
deselects them, and because a command-line -m replaces that one, the
conftest also skips them unless the -m expression names csv_reload.
Run them with pytest -m csv_reload tests/csv_reload/.
.github/workflows/ci.yml defines the three-tier CI gate:
- smoke — every push and PR; runs
pytest -m "smoke or (not slow and not solver)"with a 1-minute timeout. - full — push to
masterand nightly cron; full Layer-1 plus Layer-2 MPS parity (30-minute timeout). - slow — nightly only;
-m slow. - gui-tests — every push and PR, a required check: the Tk tests under xvfb (see "GUI tests" below).
GUI tests
The tests that open a real Tk window are the modules listed in
_GUI_DISPLAY_MODULES (tests/conftest.py) and the tests marked gui
(pytestmark = pytest.mark.gui, e.g. every module of tests/gui/assistant/).
They are skipped unless --run-gui is given; --gui-only deselects
everything else. Never run them on your own display — they move windows
and the keyboard focus. Run them under xvfb, with openbox as the window
manager as the CI job does (mapping, focus, grabs and the work area then
behave as on a desktop):
xvfb-run -a sh -c 'openbox & sleep 1; python -m pytest tests/gui --run-gui --gui-only -n 0'
The gui-tests job of ci.yml runs exactly this on Linux (Python 3.14,
--timeout 300). It runs serially: the focus is one per display, so tests
in parallel processes on one display take it from each other. A test that
checks where the focus went asks the window (focus_lastfor()), not the
display (focus_get()). A dialog is transient to its parent, and a window
manager never maps a transient of a withdrawn window: open dialogs over a
mapped window. The Tk-free modules of tests/gui/ run in the sharded
Tests workflow. Windows and macOS Tk are checked by hand.
Test-suite environment variables
Opt-in switches read by the test suite only (registered with audience test in ENV_KNOBS,
flextool/run_options.py). The runtime developer switches the suite also uses, such as
FLEXTOOL_AUTOSCALE_STRICT and FLEXTOOL_SKIP_SOLVER_PROBE, are in
Environment variables (developer).
| Variable | Default | Set to | What it does |
|---|---|---|---|
FLEXTOOL_NATIVE_PARITY_SWEEP |
unset (skipped) | 1 |
Run the slow parity sweep of tests/engine/test_orchestration.py over every fixture (about 5 min per fixture). |
FLEXTOOL_WRITE_SOLVER_HANDOFF_GOLDEN |
unset | 1 |
Rewrite tests/engine/data/solver_handoff_golden.json from the current code (tests/engine/test_solver_handoff_golden.py; the test then skips). Only when a change to what the solvers are handed is intended. |
FLEXTOOL_TEST_RSS_BUDGET_MB |
1500 |
MB | RSS budget of the input-derivation memory test tests/spinedb_backend/test_memory_budget.py, for machines with unrelated memory pressure. |
FLEXTOOL_TEST_KEYRING_LOG |
set by the suite | file | Set by the root conftest.py in the first process of a session and inherited by the xdist workers: every process appends a call that reached a system keyring backend, or a store or removal on the default keyring, to this file, and the session fails when it is not empty (tests/user_dirs_guard.py). Not for use by hand. |
FLEXTOOL_TEST_SANDBOX |
set by the suite | folder | Set by the root conftest.py in the first process of a session and inherited by the xdist workers and the processes the tests start: HOME (USERPROFILE on Windows), XDG_CONFIG_HOME, XDG_CACHE_HOME, XDG_DATA_HOME, XDG_STATE_HOME, APPDATA and LOCALAPPDATA point into this temp folder, so a fallback to the platform's user folder stays inside it (tests/user_dirs_guard.py). Not for use by hand. |
FLEXTOOL_TEST_REAL_HOME, FLEXTOOL_TEST_REAL_USER_DIRS |
set by the suite | folders | The real home folder and the real FlexTool user folders, as the first process of the session found them before it redirected HOME and XDG_*. In every pytest process a flextool.user_dirs call that would give a folder under one of these, or under the real home's .config, .cache, .local, Library or AppData, is recorded in FLEXTOOL_TEST_KEYRING_LOG (the session fails) and raises. Not for use by hand. |
FLEXTOOL_SHIM_RECORD |
set by the tests | file | Set by tests/engine/commercial_shim/test_item230_commercial_shims.py (opt-in, -m commercial_shim): each commercial-CLI shim in tests/engine/commercial_shim/shims/ appends one JSON line per call to this file (arguments, stdin script, the parameters as the solver package reports them after the solve, status, MIP gap); the tests assert on it. Not for use by hand. |
Running the tests
pytest -m smoke # the CI smoke gate, fast
pytest -m "not solver" # everything that doesn't touch a solver
pytest tests/engine/ # broader gate — engine layer
pytest tests/ # activation truth (slow)
Regenerate a single scenario's golden CSVs in place:
pytest tests/test_scenarios.py --regenerate coal
This runs only the matching scenario parametrize-case, writes fresh
CSVs into tests/expected/coal/, and lets you commit the diff.
Determinism guards
engine emitters keep cross-solve determinism through explicit
polars sort keys on every set / parameter frame before it enters the
polar_high.Problem. The downstream LP row / column IDs are then
assigned in canonical order regardless of Python hash randomisation.
The autouse fixture _reset_flextool_module_caches in
tests/conftest.py clears the LP-scaling module cache around every
test so scenarios that share solve names cannot cross-contaminate.
Adding a new test
- Engine unit test. Drop a
test_*.pyfile intotests/engine/(or the matchingloaders//constraints//objective/subdirectory) — pytest auto-discovers. - New scenario golden. Append a scenario entry to
tests/scenarios.yaml, thenpytest tests/test_scenarios.py --regenerate <name>and commit the resultingtests/expected/<name>/directory. - New fixture content (append-only). Use
flextool.update_flextool.extend_tests_fixturewith a YAML delta rather than editing the JSON by hand. - Marker discipline. Mark fast tests with
smokeonly if they belong in the per-commit gate; mark anything that builds and solves an LP withsolver; mark anything over ~30 s withslow.
Recorded provider replies
The AI assistant's backend tests run on synthetic payloads (tests/assistant/llm/payloads/). The real answers of
each AI service are kept as recordings under tests/assistant/llm/recorded/<preset>/<case>.json, made by hand with
tools/record_llm_replies.py and checked in CI by tests/assistant/llm/test_recorded_replies.py (skipped while
there are none). A recording is made only against the synthetic test project that the tool builds itself
(tests/fixtures/tests.json with the runs of the assistant tests), never against a user's project.
To record one service:
- Make its key findable as the chat finds it: the preset's environment variable (
GEMINI_API_KEY, …) orflextool-assistant --set-key --provider <preset>. The key is never given on the command line. For a local server, start it with the model loaded the way users run it (for Ollama,OLLAMA_CONTEXT_LENGTH=16384). - Run, from the repository root:
The tool asks two fixed questions (one about input data, one about a result) through the real session, so the service is billed for a few requests. It writes the file only when both questions were answered, the model made at least two rounds of tool calls, the scrubbed recording passes the scrub check and it replays to the same answers; otherwise it says why and writes nothing (exit 1).
python tools/record_llm_replies.py --preset gemini --model <a model from flextool-assistant --list-models> python tools/record_llm_replies.py --preset ollama --model <a pulled model that supports tools> python tools/record_llm_replies.py --preset openai_compatible --base-url http://localhost:8000/v1 --model <name> - Look through the file before committing it, then run
pytest tests/assistant/llm/test_recorded_replies.py.
What the scrub does before writing: no request header is recorded; message, call, response and request ids
(chatcmpl-…, msg_…, toolu_…, gen-…, call_…, req_…) become placeholders of the same format, the same
everywhere in the file; organisation and project ids, run ids, e-mail addresses and the key are replaced; the
FlexTool redaction removes the project folder, the home folder, user and host names and other absolute paths; the
address of a custom endpoint becomes https://endpoint.example.test/v1. Any other address than the preset's own
or one on this computer keeps its scheme, port and route kind with a placeholder host (192.0.2.1 or
[2001:db8::1] on the local network, endpoint.example.test elsewhere) and the preset's path; a remote_host
outside the known services becomes forwarded.example.test. In every text, the top-level fields included, an IP
address outside loopback and the documentation ranges becomes a numbered documentation address (192.0.2.N,
2001:db8::N, never the endpoint's), and a host name of the local network (*.lan, *.local, *.home,
*.internal, *.intranet, *.corp, *.localdomain, *.home.arpa, or a one-label host after ://) becomes
host-N.example.test; a one-label endpoint host is replaced only as a host (://gpu, gpu:11434), never inside a
word. Public host names in texts stay. Of the model server's own answers only the fields the probe reads are
recorded (Ollama /api/show: capabilities, remote_host, parameter size, architecture and context length;
/api/ps: the recorded model's name, context, size and graphics memory, no digest; LM Studio: the recorded model's
context, load state and capabilities), and a model list keeps only the recorded model. Signatures and thinking text stay, because the replay needs them. Response headers are kept only from an
allow-list (content-type, retry-after, retry-after-ms, x-ratelimit-*, anthropic-ratelimit-*).
What the scrub check (in the tool and in CI) refuses: a key-like text (sk-, sk-ant-, sk-or-, AIza…), a run
of 32 or more letters and digits outside a signature field, an absolute path, an e-mail address, a request header,
x-stainless-*, openai-organization or openai-project anywhere, a project name other than the fixture's, an
IP address outside loopback and the documentation ranges, a host name of the local network or a one-label host
after ://, a base_url or remote_host outside the known services, and (in the tool) the endpoint's own host
left anywhere — a one-label one as a whole word. The check's patterns are its own and looser than the scrub's, and
it also reads each streamed answer joined, so an address the scrub missed, or one split over two pieces, is
refused rather than written.
When a recording settles a point the design marked unverified (for example the shape of Ollama's remote_host
or of LM Studio's model list), correct the code and its tests in the same change.
Related pages
architecture.md— pipeline end-to-end, Repository layout, verify-hook inventoryengine.md— the core under Layer 1db_schema.md— schema migration chain thatsync_master_json_template --verifyenforcesdecomposition.md— Benders parity contextCHANGELOG.md3.47.0 entry — the JSON-fixture architecture rolloutCHANGELOG.md3.33.0 entry — the original test-pyramid rollout