Testing¶
Mnemosynce uses pytest. All tests live in tests/ and run without a real backup server, real SSH connections, or real SMTP.
Running the tests¶
# Fast — no real scripts or network
uv run pytest -v -m "not functional"
# With coverage
uv run pytest -v -m "not functional" --cov=src --cov-report=term-missing
# Functional tests — requires the project to be fully set up on a real machine
uv run pytest -v -m functional
The CI pipeline (.github/workflows/test.yml) runs pytest -m "not functional".
Test files¶
| File | Covers |
|---|---|
test_backup_task.py |
BackupTask — excludes file, subprocess runner, step status, error handling |
test_config_file.py |
ConfigFile — valid config, missing keys, optional fields |
test_database.py |
LogDB — table creation, inserting runs, last-success query |
test_email_report.py |
enrich_task_status, EmailReport._compose_mail, SMTP send, attachments |
test_main.py |
main() end-to-end with fakes, log cleanup, password reader |
test_setup_wizard.py |
setup_state, setup_guard, wizard routes |
test_functional.py |
Shell scripts (marked functional, excluded from CI) |
Fixtures — conftest.py¶
minimal_config¶
Writes a valid backup_config.yml to tmp_path and returns its Path. Used by backup engine tests that need a real file on disk.
@pytest.fixture()
def minimal_config(tmp_path):
cfg = {"dir_backup_local": ..., "tasks": [...], ...}
p = tmp_path / "config.yml"
p.write_text(yaml.dump(cfg))
return p
fake_runner¶
Returns a factory for constructing subprocess.CompletedProcess objects with configurable returncode, stdout, and stderr. Injected into BackupTask via the runner parameter.
@pytest.fixture()
def fake_runner():
def make(returncode=0, stdout="", stderr=""):
def runner(*args, **kwargs):
return subprocess.CompletedProcess(args, returncode, stdout, stderr)
return runner
return make
app and client (wizard tests)¶
The wizard test fixtures create a TestConfig with all paths pointing to tmp_path, construct the Flask app, and return both the app and a test_client(). This keeps every test fully isolated — no shared filesystem state.
@pytest.fixture()
def app(tmp_path):
cfg = TestConfig()
cfg.DATA_ROOT = tmp_path
cfg.CONFIG_PATH = tmp_path / "backup_config.yml"
cfg.DB_PATH = tmp_path / "log.db"
cfg.SSH_KEY_DIR = tmp_path / "ssh"
cfg.SSH_KEY_DIR.mkdir()
yield create_app(cfg)
Testing patterns¶
Injecting a fake subprocess runner¶
BackupTask accepts a runner parameter so tests never shell out:
def test_start_full_success(fake_runner, tmp_path):
task = BackupTask(
task={"name": "T", "dir_source": "/src"},
dir_local=str(tmp_path),
dir_remote="user@host:/remote",
work_dir=tmp_path,
runner=fake_runner(returncode=0),
)
status = task.start()
assert status["success"] is True
Testing Flask routes with test_client¶
The wizard tests use client.get() and client.post() against a fully wired Flask app in TestConfig mode. Because APP_ENV is not "development" in TestConfig, login_required would normally enforce authentication — but TestConfig sets TESTING = True, which the login_required decorator does not check. Instead, the wizard tests rely on APP_ENV=test falling through the development bypass, so routes are accessible without a session.
Note
Check web/auth.py if this behaviour changes — the bypass condition is APP_ENV == "development". Tests that need authenticated routes should set session["logged_in"] = True via client.session_transaction().
Testing session-backed state¶
Use app.test_request_context() to get a real Flask request context with an active session:
def test_mark_connection_tested(app):
with app.test_request_context():
from flask import session
from web.setup_state import mark_connection_tested
mark_connection_tested()
assert session.get("setup_connection_tested") is True
Asserting redirect destinations¶
The wizard tests check response.headers["Location"] rather than following redirects, which keeps assertions fast and explicit:
def test_redirects_to_setup_when_incomplete(client):
response = client.get("/dashboard/", follow_redirects=False)
assert response.status_code == 302
assert "/setup/" in response.headers["Location"]
Coverage¶
Run with:
uv run pytest -m "not functional" \
--cov=src \
--cov-report=term-missing \
--cov-report=html:htmlcov
Open htmlcov/index.html for a line-level breakdown. The two pre-existing failures in test_main.py (test_main_runs_without_real_secrets, test_password_reader_called_with_correct_env_var) are caused by a missing pythonjsonlogger install in the bare test environment — they pass when the full dependency set is available via uv sync --extra dev.