Writing Tests
This guide is for contributors writing tests for the QHAna plugin runner core or for individual plugins. It covers the pytest configuration, the test file locations, the shared fixtures and helpers, the recommended pattern for testing Celery tasks, how to run the suite locally, and how it is executed in CI.
The testing strategy is anchored in two architecture decision records:
Celery Task Testing Strategy. Celery tasks are tested with an in-process worker on an in-memory broker.
Co-locate plugin tests with plugin code. Plugin tests are co-located with the plugin code.
For background on why plugins use Celery in the first place, see Use Celery Task Queue.
Pytest setup
The pytest configuration is defined in pyproject.toml:
[tool.pytest.ini_options]
testpaths = ["tests", "plugins", "stable_plugins"]
pythonpath = ["tests"]
addopts = "--import-mode=importlib"
python_files = ["test_*.py"]
testpathscollects tests from the runner-coretests/directory and from both plugin trees.pythonpath = ["tests"]putstests/onsys.pathso shared helpers can be imported asfrom utils import ….addopts = "--import-mode=importlib"switches pytest from the defaultprependimport mode toimportlib. This is required because plugin test modules can collide on names (for example several plugins each shipping their owntest_routes.py). See Co-locate plugin tests with plugin code for the rationale.python_files = ["test_*.py"]restricts collection to files starting withtest_.
The unit tests depend on pytest and hypothesis, both pulled in by the dev dependency group.
Test File Locations
Test files reside in three valid locations:
tests/for runner-core tests. Examples:tests/test_db.py,tests/test_entity_marshalling.py,tests/test_plugin_imports.py.plugins/<name>/for plugin tests, in either a nested or a flat layout (see below).stable_plugins/<theme>/<plugin>/for stable-plugin tests, following the same nested-or-flat convention.
A plugin chooses one of two layouts for its tests:
Nested layout. Test files reside in a dedicated
tests/subdirectory within the plugin package:plugins/foo/ ├── __init__.py ├── routes.py ├── tasks.py └── tests/ ├── __init__.py └── test_routes.pyFlat layout. Test files reside directly next to the source files, prefixed with
test_:plugins/bar/ ├── __init__.py ├── routes.py ├── tasks.py └── test_routes.py
Pytest discovers both layouts. The nested layout suits plugins with many test files or shared fixtures specific to the plugin. The flat layout suits small plugins where one or two test modules sit comfortably alongside the source. Module-name collisions across plugins (for example two plugins each having test_routes.py) are handled by --import-mode=importlib.
See also
The ADR Co-locate plugin tests with plugin code records the reasoning for this layout and the trade-offs against alternatives (mirroring under tests/, per-plugin pyproject.toml).
Writing tests for the plugin runner
Tests for runner-core code go in tests/ and follow standard pytest patterns. The existing modules are good templates:
tests/test_db.py: minimal fixture usage with thetask_datafixture.tests/test_entity_marshalling.py: exercises CSV/JSON entity round-trips withassert_sequence_equals.tests/test_plugin_imports.py: validates the plugin import contract enforced on plugin source files.
Use task_data whenever a test needs a Flask app context or DB access.
Writing tests for plugins
To test a plugin:
Place test files at
plugins/<name>/tests/test_*.pyorplugins/<name>/test_*.py.Reuse the shared
task_datafixture and the helpers fromtests/utils.py, with no boilerplate and no duplication.Plugin source files must use relative imports (this is enforced by
tests/test_plugin_imports.pyso plugins remain relocatable, see Writing Plugins). Test files are excluded from this check, so plugin tests can use absolute imports.
Test module names can collide across plugins (multiple plugins each having a test_routes.py is fine). --import-mode=importlib handles the disambiguation.
Examples
The stable_plugins/data_synthesis/data_creator/tests/ directory demonstrates the test types described in this guide. It uses the nested layout and relies on the client fixture from the repo-root conftest.py. Each file covers one aspect of the plugin:
stable_plugins/data_synthesis/data_creator/tests/test_datasets.pyPure unit tests and hypothesis property tests for the numpy-based dataset generators in
stable_plugins/data_synthesis/data_creator/backend/datasets.py. Demonstratespytest.mark.parametrize()for shape checks across everyDataTypeEnummember, and@givenstrategies for invariants (output length, finite values, integer label dtype, label range bounded bycenters). No fixtures are required because the generators have no Flask, DB, or Celery dependencies.stable_plugins/data_synthesis/data_creator/tests/test_schemas.pyMarshmallow schema tests for
InputParametersSchema. Covers the round-trip from JSON payload toInputParametersdataclass (including thecamelCaserewriting performed byMaBaseSchema), the per-type required-field rules inREQUIRED_FIELDS_BY_TYPE, range validators onnum_train_points/noise/turns/centers, and rejection of unknowndataset_typevalues. Schemas are pure Python, so these tests also run without a Flask app.stable_plugins/data_synthesis/data_creator/tests/test_routes.pyHTTP-level tests using the shared
clientfixture. Covers the metadata endpoint (GET /plugins/<id>/), the micro frontend form rendering and default values, and the form’s behavior on invalid input (the route usesvalidate_errors_as_result=Trueand re-renders rather than returning a 400). The/process/endpoint enqueues a Celery task and is therefore covered by Celery-aware tests instead, see Celery Task Testing Strategy.stable_plugins/data_synthesis/data_creator/tests/test_tasks.pyEnd-to-end Celery tests for the
calculation_taskenqueued by/process/. Persists aProcessingTaskthe wayroutes.pydoes, callscalculation_task.apply_asyncagainst the in-memory broker, and asserts on the four output files written by the worker (file names,file_type,mimetype, and JSON payload shape). Also covers thecentersparameter forDataTypeEnum.blobsand theKeyErrorraised when thedb_iddoes not resolve to a row. Uses thebroker_appandcelery_workerfixtures from the repo-rootconftest.py, following the pattern described in Testing Celery tasks.
Testing Celery tasks
Plugins use Celery for long-running work (see Use Celery Task Queue). The recommended testing strategy, set out in Celery Task Testing Strategy, is to run a real Celery worker thread inside the test process against an in-memory broker. This exercises the full apply_async → broker → worker round-trip (including task registration, argument serialization, result serialization, and worker-side error handling) without requiring Redis or Docker.
The fixtures and test config required for this pattern can be found in the repo-root conftest.py and are auto-discovered by pytest.
Plugin authors do not need to copy them.
Import the plugin’s tasks at module level in the test file so the CELERY singleton picks up the registration when broker_app builds the app.
Configuration
The Flask + Celery test config in conftest.py combines an in-memory SQLite database (with a thread-safe pool) with an in-memory Celery broker:
"SECRET_KEY": "test",
"DEBUG": False,
"TESTING": True,
"JSON_SORT_KEYS": True,
"JSONIFY_PRETTYPRINT_REGULAR": False,
"DEFAULT_LOG_SEVERITY": INFO,
"DEFAULT_LOG_FORMAT_STYLE": "{",
"DEFAULT_LOG_FORMAT": "{asctime} [{levelname:^7}] [{module:<30}] {message} <{funcName}, {lineno}; {pathname}>",
"DEFAULT_FILE_STORE": "local_filesystem",
"FILE_STORE_ROOT_PATH": "files",
"OPENAPI_VERSION": "3.0.2",
"OPENAPI_JSON_PATH": "api-spec.json",
"OPENAPI_URL_PREFIX": "",
# ``SERVER_NAME`` lets ``flask.url_for`` build URLs without a request
# context, which the route-level tests in plugin test suites rely on.
"SERVER_NAME": "localhost.localdomain",
# StaticPool keeps a single connection alive across threads so the
# in-memory SQLite database is visible from both the test thread and
# the worker thread.
"SQLALCHEMY_ENGINE_OPTIONS": {
"connect_args": {"check_same_thread": False},
"poolclass": StaticPool,
},
"CELERY": {
"task_default_queue": "qhana_plugin_runner",
"broker_url": "memory://",
"result_backend": "cache+memory://",
"task_always_eager": False,
"broker_connection_retry_on_startup": True,
},
}
Two parts are critical:
SQLALCHEMY_ENGINE_OPTIONSusesStaticPoolandcheck_same_thread=Falseso the in-memory SQLite database is visible from both the test thread and the worker thread.The
CELERYblock usesbroker_url = "memory://"andresult_backend = "cache+memory://"and keepstask_always_eager = Falseso calls actually go through the broker.
Fixtures
Two module-scoped fixtures in conftest.py set up the app and the worker thread:
def broker_app():
"""App configured with a real Celery broker (in-memory)."""
test_config = dict(DEFAULT_TEST_CONFIG)
test_config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///:memory:"
app = create_app(test_config, silent_log=True)
with app.app_context():
create_db_function(app)
yield app
@pytest.fixture(scope="module")
def celery_worker(broker_app):
"""Start an in-process Celery worker thread for the test module.
``broker_app`` is required so the CELERY singleton is reconfigured
against the memory broker before the worker boots. The fixture
is module-scoped because spinning the worker up and down per test
is slow.
"""
from celery.contrib.testing.worker import start_worker
with start_worker( # pyright: ignore[reportGeneralTypeIssues]
CELERY,
pool="solo",
perform_ping_check=False,
shutdown_timeout=10,
) as worker:
broker_appbuilds the Flask app with the test config and creates the database schema.celery_workerstarts a real in-process Celery worker viacelery.contrib.testing.worker.start_worker.pool="solo"keeps the worker single-threaded for simpler debugging. The fixture is module-scoped because spinning the worker up and down per test is slow.
Use both fixtures in every Celery test, either by naming them as parameters or via @pytest.mark.usefixtures("broker_app", "celery_worker") when the test body does not reference them directly.
Example tests
stable_plugins/data_synthesis/data_creator/tests/test_tasks.py demonstrates this pattern for a plugin.
@pytest.mark.usefixtures("broker_app", "celery_worker")
def test_calculation_task_persists_four_files():
db_id = _enqueue_processing_task(...)
result = calculation_task.apply_async(kwargs={"db_id": db_id}).get(timeout=30)
assert result == "Result stored in file"
Errors propagate through the result backend and can be asserted with pytest.raises:
@pytest.mark.usefixtures("broker_app", "celery_worker")
def test_calculation_task_missing_db_id_raises():
async_result = calculation_task.apply_async(kwargs={"db_id": 99999})
with pytest.raises(KeyError, match="Could not load task data"):
async_result.get(timeout=30)
A test that exercises a DB-mutating task must expire the test session before re-reading the row, otherwise SQLAlchemy returns the cached identity-mapped instance from before the worker committed:
@pytest.mark.usefixtures("broker_app", "celery_worker")
def test_reads_worker_mutation():
db_id = _enqueue_processing_task(...)
calculation_task.apply_async(kwargs={"db_id": db_id}).get(timeout=30)
DB.session.expire_all()
task = ProcessingTask.get_by_id(db_id)
assert task.outputs # written by the worker thread
Gotchas
Warning
Use
StaticPoolwithcheck_same_thread=Falsefor any in-memory SQLite database that the worker thread will touch. Without this, the test thread and the worker thread see different databases.Call
DB.session.expire_all()before re-reading rows that the worker mutated. The test session caches identity-mapped instances and will otherwise return stale state.Do not use
task_always_eager = True. It bypasses the broker, the worker, and the serialization layer, so the code path under test does not match production. Celery Task Testing Strategy explicitly rejects this option.The in-memory broker does not model Redis-specific behavior (visibility timeouts, persistence, priorities). Tests that depend on those features need a real broker.
See also
Plugin example:
stable_plugins/data_synthesis/data_creator/tests/test_tasks.pyFixtures and test config:
conftest.py
Property-based testing with hypothesis
Hypothesis is a property-based testing library. Instead of writing example-driven assertions, you describe the property a function should satisfy and hypothesis generates many inputs to try and falsify it. When it finds a counter-example, it shrinks the input to a minimal failing case before reporting it. This is well-suited for code with structured input domains (entity marshalling, attribute parsers, serialization round-trips), where hand-picking examples tends to miss edge cases.
A round-trip property looks like:
from hypothesis import given, strategies as st
@given(st.dictionaries(st.text(), st.integers()))
def test_roundtrip(data):
assert deserialize(serialize(data)) == data
Hypothesis is pulled in by the dev dependency group, so no extra setup is needed. poetry run pytest --hypothesis-explain prints the example-shrinking trail when a property fails, which helps when the minimized counter-example is not self-explanatory.
See the hypothesis quickstart and the strategies reference for the full API.
Running tests
The full set of pytest commands is documented in the A Runner for QHAna Plugins. The most-used invocations:
# Run the whole suite
poetry run pytest
# Run a single test
poetry run pytest path/to/test_x.py::test_name
# Re-run only failures from the last run
poetry run pytest --last-failed
# Coverage with a terminal summary and an HTML report under htmlcov/
poetry run pytest -p pytest_cov --cov=qhana_plugin_runner --cov-report=html --cov-report=term
Continuous integration
Unit tests run on every push to main and on every pull request via .github/workflows/pytest.yml. The job sets up Python 3.10, installs dependencies with poetry install --no-interaction --with dev, runs poetry run pytest --cov=qhana_plugin_runner --cov-report=html --cov-report=term, and uploads the HTML coverage report as a build artifact. No external services are started. The in-memory SQLite database and the in-memory Celery broker keep the suite self-contained.
A separate workflow at .github/workflows/integration-tests.yml runs the full QHAna integration suite (UST-QuAntiL/qhana-integration-tests) on a weekly schedule and on manual dispatch. That workflow exercises the runner against a real broker, registry, backend, and UI. It is out of scope for this guide.
See also
stable_plugins/data_synthesis/data_creator/tests/, an example covering unit, property-based, schema, and route tests for a stable plugin.Writing Plugins, the plugin authoring guide.