Testing tips#
tractor’s test suite is a different kind of beast than your
average single-proc pytest run: nearly every test spawns a real
process tree, hammers on cancellation under structured
concurrency (SC), and tears the whole thing down again — hundreds
of times per session. This page collects the tips, knobs and
one-liners that make hacking on (and with) the suite pleasant.
Running the suite#
This is a uv-managed project, so after cloning it’s just:
uv sync --dev
uv run pytest tests/
Expect a lot of process churn; the suite is effectively a rolling chaos exercise for the runtime.
The classic fix-iterate loop when something breaks:
# stop at the first failure
uv run pytest tests/ -x
# then iterate on just the failures til green
uv run pytest --lf -x
--lf (last-failed) re-runs only what failed previously, so
combined with -x you get a tight one-test-at-a-time repair
loop.
Suite-specific flags#
The repo auto-loads the bundled tractor._testing.pytest plugin
(via addopts in pyproject.toml) which adds a few extra
flags:
--spawn-backend <key>: pick the process spawn backend for the session (default'trio'); same keys as thestart_methodruntime argument,--tpt-proto <key> [...]: which IPC transport(s) opting-in suites should run against, eg.--tpt-proto uds,--tpdb/--debug-mode: flip on thedebug_modefixture so debugger-aware tests boot their trees with the crash-REPL enabled,--enable-stackscope: install theSIGUSR1task-tree dump handler in pytest and every spawned subactor — much lighter than a full debug-mode run when you only need stack visibility during a hang hunt,--ll <level>/--tl <level-or-spec>: console loglevels;--tltargets thetractor-as-runtime logger and accepts a per-subsystem spec like'devx:runtime,trionics:cancel'.
Watch the tree grow#
The single most useful trick while the suite (or any tractor
app) runs: keep a live pstree view going in a side terminal:
watch -n 0.1 "pstree -a $(pgrep -f pytest)"
You’ll see actor processes pop in and out of existence as each
test builds and reaps its tree. Launch it after pytest is up
(the pid is substituted once, at watch startup).
Every subactor also sets its OS process title (via
setproctitle) to _subactor[<name>@<uuid-prefix>] so the
tree view shows which actor is which at a glance — and targeted
greps stay easy:
pgrep -af '_subactor\['
For a single example script, the repo’s signature incantation spawns the watcher alongside your program and cleans it up after:
$TERM -e watch -n 0.1 "pstree -a $$" \
& python examples/parallelism/single_func.py \
&& kill $!
Env-var knobs#
Two env-vars override their corresponding runtime arguments globally — no application (or test) code changes required:
TRACTOR_SPAWN_METHODWins over any caller-passed
start_methodso you can drive the whole suite (or any app) under a different spawn backend:TRACTOR_SPAWN_METHOD=mp_spawn uv run pytest tests/ -x
TRACTOR_LOGLEVELWins over any caller-passed
loglevel; crank (or silence) runtime console verbosity wholesale:TRACTOR_LOGLEVEL=cancel uv run pytest tests/ -x -s
TRACTOR_ENABLE_STACKSCOPEForce-install the
SIGUSR1task-tree dump handler in every actor, debug-mode or not; thenpkill --signal SIGUSR1 -f <part-of-cmd>dumps every actor’s livetriotask tree.
Debug mode vs. pytest capture#
The tree-wide crash-to-REPL experience (debug_mode=True plus
await tractor.pause()) requires a real tty, and pytest’s
default output capturing swallows exactly that. When you want to
interact with the REPL from inside a test run, disable capture:
uv run pytest tests/test_foo.py -x -s
(-s is shorthand for --capture=no.)
Tests should request the debug_mode fixture (driven by the
--tpdb flag) rather than hard-coding it, so that normal CI
runs stay non-interactive.
For automated REPL interaction — asserting on prompt output,
sending debugger commands — you can’t just turn capture off;
instead do what tests/devx/ does: drive a child Python program
through pexpect on a real pseudo-tty and pattern-match the
(Pdb+) prompts. See tests/devx/test_debugger.py for many
worked patterns.
Examples are tests#
Every script under examples/ is run as a subprocess by
tests/test_docs_examples.py; since these docs
literalinclude those same scripts, the code you read here is
CI-verified on every push and can never silently rot B)
Conventions when adding a new example:
make it a standalone runnable script with the usual guard:
if __name__ == '__main__': trio.run(main)
it must exit cleanly (returncode
0) within the per-example timeout (~16s locally, with headroom auto-added in CI and under cpu-freq scaling) — keep sleeps short,any stderr line containing
Errorfails the test, so silence or assert-around expected error output,don’t crank
tractorlogging inside an example: subprocess pipe backpressure can deadlock the run (ask us how we know..),filenames starting with
_are skipped (the WIP convention), as are the special subdirs (debugging/,integration/,advanced_faults/,trio/) which are driven by their own dedicated suites instead.
Drop your script in, run the example suite, profit:
uv run pytest tests/test_docs_examples.py -x
Zombie cleanup#
First, the contract: tractor always reaps its children —
if you can create a zombie process (without resorting to
untrappable signals) it is a bug, please report it!
That said, while hacking on the runtime itself you can
definitely wedge things — a SIGKILL-ed pytest, a half-broken
spawn backend — and strand subactor procs plus their shm segments
and UDS socket files. The repo ships a dedicated cleanup tool:
uv run scripts/tractor-reap --shm --uds
It’s SC-polite even as a reaper: matched processes get SIGINT
first with a bounded grace window — so actor runtimes can run
their trio teardown paths — escalating to SIGKILL only as
a last resort. The --shm sweep unlinks /dev/shm/ segments
that no live process has open (it leans on psutil, already in
your dev venv, to check live mappings and fds) and --uds
clears socket files whose binder pid is dead.
Testing your own tractor app#
The same plugin the suite uses ships in the package, so your project can load it too:
[tool.pytest.ini_options]
addopts = ['-p tractor._testing.pytest']
That buys you the CLI flags above plus a set of fixtures —
loglevel, debug_mode, reg_addr (a session-unique
registrar address so concurrent runs and other live tractor
apps on the host can’t cross-talk) — and the @tractor_test
decorator:
import tractor
from tractor._testing import tractor_test
@tractor_test
async def test_my_service(
reg_addr: tuple,
loglevel: str,
):
# already inside a root actor's trio task!
async with tractor.open_nursery() as an:
...
The decorator boots a root actor around your (async) test fn,
wires any of the special fixtures you declare (reg_addr,
loglevel, start_method, debug_mode) into
open_root_actor(), and runs the body as the root-most task
under a wall-clock trio.fail_after() guard.
General advice that has served this suite well:
bound waits with
trio.fail_after()inside tests; global pytest timeout plugins interact badly with multi-processtrioteardown,use the
reg_addrfixture (or otherwise randomize your registry addrs) so leftover registrars from prior runs can’t contaminate lookups,assert on structured outcomes — eg.
RemoteActorError.boxed_typeorContextCancelled.canceller— not on log text.
Note
tractor._testing is still an underscore-internal namespace:
shipped and handy, but its API may shift between alpha
releases.
(This page exists thanks to the ask in #126.)
See also
Actor discovery — how registrar wiring (the thing
reg_addrrandomizes) works in the runtime proper.Hot tips for tractor hackers — contributor-oriented extras: releases, log-system tracing, tree-monitoring recipes.