Testing Workflow Logic

Every example in afwf/examples/ has a corresponding test file in tests/examples/. Because the Python layer is cleanly separated from Alfred, all tests run with plain pytest — no Alfred installation required.

This document covers the four patterns used throughout the test suite.

Pattern 1 — run_cov_test: Standalone File Execution with Coverage

Every test file ends with an if __name__ == "__main__": block that runs the file directly and generates a coverage report for exactly one module:

if __name__ == "__main__":
    from afwf.tests import run_cov_test

    run_cov_test(
        __file__,
        "afwf.examples.search_bookmarks",
        preview=False,
    )

Running the file as a script (python tests/examples/test_examples_search_bookmarks.py) invokes pytest as a subprocess, measures coverage for the target module, and writes htmlcov/. Setting preview=True opens the HTML report in a browser immediately after.

This pattern enables quick iteration: fix a bug in one module, run its test file directly, see the coverage diff — without running the full suite.

The full suite is run via mise run cov, which collects all test files and aggregates coverage.

Pattern 2 — Monkeypatching Module-Level State

Several modules keep mutable state at module level — a file path, a settings object, a cache instance. Tests replace these with tmp_path variants using monkeypatch.setattr:

import afwf.examples.write_file as mod

def test_creates_file(self, tmp_path, monkeypatch):
    p = tmp_path / "file.txt"
    monkeypatch.setattr(mod, "path_file", p)   # redirect writes to tmp

    mod.write_request("hello")
    assert p.read_text() == "hello"

The key is to patch the attribute on the module object (mod.path_file), not on the imported Path class. monkeypatch.setattr restores the original value automatically after each test.

Settings storetest_examples_set_settings.py patches both the settings_mod.settings and mod.settings attributes because the settings singleton is imported into set_settings.py at import time:

def _patch_settings(tmp_path, monkeypatch):
    import afwf.examples.settings as settings_mod

    patched = _JsonSettings(tmp_path / "settings.json")
    monkeypatch.setattr(settings_mod, "settings", patched)
    monkeypatch.setattr(mod, "settings", patched)
    return patched

Cache instancetest_examples_memoize.py replaces both the cache object and the memoized function (because the decorator was already applied at import time with the real cache):

def test_same_key_returns_cached_value(self, tmp_path):
    import afwf.examples.memoize as mod
    from afwf.opt.cache.api import TypedCache

    mod.cache = TypedCache(tmp_path / ".cache")
    mod._get_value = mod.cache.typed_memoize(tag="memoize", expire=5)(
        lambda key: __import__("random").randint(1, 1000)
    )

    v1 = mod._get_value("same_key")
    v2 = mod._get_value("same_key")
    assert v1 == v2

Pattern 3 — Testing log_error via __wrapped__

log_error() uses @functools.wraps, which stores the original function on __wrapped__. Tests access this to re-decorate with a temporary log path, so they can assert that the log file is written without touching ~/.alfred-afwf/:

def test_error_query_raises_and_logs(self, tmp_path):
    import afwf.examples.search_bookmarks as mod
    from afwf.decorator import log_error

    log_file = tmp_path / "test_error.log"
    patched_main = log_error(log_file=log_file)(mod.main.__wrapped__)

    with pytest.raises(ValueError, match="simulated Python error"):
        patched_main(query="error")

    assert log_file.exists()
    content = log_file.read_text(encoding="utf-8")
    assert "ValueError" in content
    assert "simulated Python error" in content

The three assertions check: the log file was created, it contains the exception type, and it contains the message. This is sufficient to verify that the decorator wrote the traceback correctly.

Pattern 4 — Asserting Variables, Not JSON

Tests assert on Python object state — item.variables, item.arg, item.icon — rather than on the serialised JSON string. This is more readable and decoupled from serialisation details:

# Good — assert on the model
item = sf.items[0]
assert item.variables["run_script"] == "y"
assert "write-file-request" in item.arg

# Avoid — brittle, couples tests to JSON format
raw = json.dumps(sf.to_script_filter())
assert '"run_script": "y"' in raw

The serialisation rules are tested separately in tests/test_script_filter_object.py; example tests do not need to re-test them.

What Not to Test

  • Alfred widget routing — Conditional branches, Open URL config, Run Script field values — are workflow configuration, not Python code. They are verified by running the workflow in Alfred, not by unit tests.

  • ``to_script_filter()`` output format — already covered by tests/test_script_filter_object.py. Example tests should assert on the model, not on the raw dict.

  • ``uvx`` or Alfred invocation — integration with Alfred is an end-to-end concern. Unit tests only cover the Python logic layer.