Test Discovery
tryke discovers tests without running your code. It uses a Rust-powered Python parser (Ruff) to read your source files at startup, build an import graph, and identify every test function — all before a Python interpreter is ever involved.
This is why tryke is fast: it reasons about your code the way a compiler does, at parse time rather than runtime.
What static analysis can see
| Pattern | Tracked |
|---|---|
import foo |
✅ |
from foo.bar import baz |
✅ |
from . import utils (relative) |
✅ |
from __future__ import annotations |
✅ |
TYPE_CHECKING imports |
✅ |
importlib.import_module("foo") |
❌ |
__import__("foo") |
❌ |
| Tests defined in dynamically-loaded modules | ❌ |
What happens when dynamic imports are detected
tryke detects importlib.import_module() and __import__() calls during the parse
phase. When a file contains either, tryke marks it as always re-run.
Effect on --changed and --changed-first mode
The file is included in every --changed invocation, regardless of whether anything it
statically imports changed. A single widely-imported helper with one dynamic import can
pull many tests back into every run — silently undermining the precision of
impact-based filtering.
tryke will emit a warning when this happens:
warning: tests/helpers/loader.py — dynamic imports found; this file will always re-run with --changed
replace importlib.import_module() or __import__() with static imports to restore selective re-runs
Effect on watch and server mode
tryke tracks which Python modules to reload between test cycles by following static import edges. Dynamic import edges are invisible to this graph. This means:
- If
helper.pyis only imported dynamically byloader.py, andhelper.pychanges, the watch-mode worker may not reloadhelperduring that cycle. loader.pyitself will always be reloaded (it is always-dirty), but when its code re-executesimportlib.import_module("helper"), it may receive the stale cached version fromsys.modules.- This can cause tests to pass or fail based on code that hasn't been reflected yet, persisting until the watch session restarts.
Recommendations
Prefer static imports
The most direct fix is to replace dynamic calls with static imports wherever the module name is known at write time:
# before
mod = importlib.import_module("myapp.plugins.csv_plugin")
# after
from myapp.plugins import csv_plugin
Isolate dynamic loading from test code
If you genuinely need runtime module selection (plugin systems, feature flags), keep the dynamic logic in non-test production code and test through a static interface:
# helpers/loader.py — dynamic loading lives here
def load_plugin(name: str):
return importlib.import_module(f"myapp.plugins.{name}")
# tests/test_loader.py — only static imports here
from helpers.loader import load_plugin
@test
def loads_csv_plugin():
plugin = load_plugin("csv_plugin")
expect(plugin).not_.to_be_none()
This way only helpers/loader.py is marked always-dirty. Tests that don't import it
are unaffected.
Use TYPE_CHECKING for type-only imports
tryke already handles TYPE_CHECKING blocks correctly — they are treated as static
imports and fully tracked:
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from myapp import HeavyType # tracked, not flagged
Exclude files that don't contain tests
If a file uses dynamic imports for non-test purposes (code generation, benchmarks, scripts) and contains no tests, tell tryke to ignore it:
Accept the tradeoff when it's intentional
If a file intentionally tests dynamic loading behavior — for example, a test that verifies your plugin loader works — that's fine. Just be aware that:
- Those tests will always run with
--changed - In watch/server mode, editing the dynamically-loaded target may require restarting the watch session to pick up changes reliably