Testing¶
This page is for anyone writing or reviewing a test in Eden: where a test goes, what it may build and what it may fake, what it should assert, how to keep the cost of Genesis scene builds down, and how to run the suite. Eden's suite is written Detroit style (classicist): a test drives real Eden objects and asserts on what they produce. It does not replace Eden's own collaborators with stand-ins, and it does not check which calls were made.
Layout¶
tests/ mirrors eden/. The tests of eden/managers/terms/rewards/locomotion.py live in
tests/managers/terms/rewards/test_locomotion.py; a task's end-to-end tests live under tests/tasks/<task>/. Every
directory is a package (an __init__.py with a one-line docstring), so two test_base.py files in different
directories do not collide, and tests itself is a regular package so that a dependency shipping its own top-level
tests package cannot shadow it. tests/test_suite_layout.py enforces both: a test file directly under tests/
must mirror a top-level eden module (or be one of the few root files it lists).
| Where | What goes there |
|---|---|
tests/<mirror of the eden path>/test_<module>.py |
tests of that module's public behaviour |
tests/<dir>/conftest.py |
fixtures and helpers shared by that directory, e.g. its module-scoped scenes |
tests/conftest.py |
suite-wide fixtures (below) and the marker declarations |
tests/support/ |
helpers several directories share: asserts (tensor comparisons), envs.build_env, ddp.ddp_spawn, motion (synthetic motion clips and mimic envs), deploy (LoopbackDeployment and an in-memory robot wire), policy_servers (local fake policy servers), hand_object_releases (synthetic TACO / OakInk2 / HOT3D releases), soma_retargeter (a stand-in eden-soma-retargeter + py-soma-x install that shadows a real one), and the CUDA probes described under Cost |
tests/data/ |
small golden files a test compares against: .npz arrays, mochi/ solver goldens, and the server-backed policies' wire records in policy_client_goldens/*.json |
Name a file after the module it tests, never after an issue or a pull request. Name a test after the behaviour it
pins (test_fell_over_fires_past_limit_angle). If a regression test needs to say which bug it guards, put that in
one docstring line; the history belongs in git.
Never import from another test module (from tests.managers.test_x import _helper). A helper two files share goes
in their common conftest.py, or in tests/support/ if it serves the whole suite. Fixtures in a conftest.py reach
the tests below it automatically. Import plain functions and classes from it explicitly
(from tests.extensions.deployment.conftest import robot_env): every test directory is a package, so this resolves
to the same module pytest loaded.
Suite-wide fixtures¶
tests/conftest.py calls eden.init(debug=True) once per session, which initializes Genesis on the CPU backend, and
provides fixtures for the things a test would otherwise get subtly wrong by hand:
| Fixture | Use it for |
|---|---|
eden_caplog |
asserting on a log line from en.logger. Plain caplog sees nothing from it: the eden logger does not propagate to the root logger, so a plain-caplog assertion passes vacuously or fails while the message is in the captured output. |
user_assets_dir |
a test that downloads through a default local_dir: it unsets EDEN_ASSETS_DIR and points XDG_CACHE_HOME into tmp_path. |
task_registry_alias |
registering an extra name on TASK_REGISTRY for one test and removing it afterwards. |
task_package |
writing an importable package of task files for a registry-discovery test. |
isolated_task_registry |
a test that changes what the task registry's scan sees, restored afterwards. |
count_cuda_syncs |
pinning a hot path's GPU→CPU sync budget (skips without CUDA). |
Real collaborators¶
Build the real thing: options, managers, terms, entities, and envs from built Genesis scenes. Adapted from
tests/managers/terms/conftest.py and tests/managers/terms/rewards/test_locomotion.py:
import pytest
import torch
from eden.managers.terms.rewards.locomotion import base_height_l2
from eden.options import BoxOptions, EnvOptions, SceneOptions
from eden.utils.configs import EdenConfig
from tests.support.envs import build_env
@pytest.fixture(scope="module")
def env():
return build_env(
EdenConfig(
env_options=EnvOptions(num_envs=2),
scene_options=SceneOptions(box=BoxOptions(size=(0.1, 0.1, 0.1), default_root_pos=(3.0, 3.0, 2.0))),
)
)
def test_base_height_l2_squares_the_error_of_the_world_height(env):
torch.testing.assert_close(
base_height_l2(env, entity_name="box", target_height=0.5),
torch.full((2,), (2.0 - 0.5) ** 2),
)
build_env(config, reset=False, eval_mode=False) picks the env class the config pairs with, builds it with the
viewer off, and optionally resets it (an RL env needs reset=True before step()).
These are off limits for Eden's own types:
- a
SimpleNamespaceor hand-written class standing in for an env, entity, manager, term or config; MagicMock/Mock/patchon anything undereden.;monkeypatch.setattron an Eden module to steer control flow.
A stand-in only has to agree with the real object on what one test reads, and nothing keeps that agreement when the real object changes. Tests written that way kept passing after the behaviour they claimed to pin had broken.
Test doubles are allowed only at a real boundary, for things a test cannot or should not run for real:
| Boundary | What to use |
|---|---|
robot hardware and its SDK (unitree_sdk2py, LimX, Dynamixel, glove and XR devices) |
a hand-written fake of the SDK's interface that holds state (the last command sent, a scripted sensor stream); tests/support/deploy.py drives a deployment through an in-memory wire |
| the network (the Hugging Face Hub, HTTP, a remote policy server) | a local fake server (tests/support/policy_servers.py) or a pre-seeded cache in tmp_path |
| wall-clock time and sleeps | an injected clock, or monkeypatch on time.monotonic / time.sleep |
| model weights you don't have (ONNX graphs, checkpoints) | a tiny real artifact built in the test (a two-node ONNX graph, a saved state_dict) rather than a mocked session |
environment variables, sys.argv |
monkeypatch.setenv / monkeypatch.setattr(sys, "argv", ...) |
| the filesystem | real files under tmp_path, never a mocked open |
torch.distributed |
real subprocess ranks through tests.support.ddp.ddp_spawn |
Even at a boundary, assert on outcomes: what the fake received, the file written, the value returned. Don't
assert on assert_called_once_with.
One exception: GPU-sync budgets. The suite runs Genesis on the CPU, so no built env lives on CUDA. A test that
pins a hot path's sync count (the count_cuda_syncs fixture) may build the unit under test against a minimal
CUDA-resident env stand-in, kept in the directory's conftest.py (tests/managers/conftest.py,
tests/managers/terms/commands/conftest.py). It must count syncs and nothing else. What the unit computes on the
same inputs belongs to a real-env test next to it. To pin the sync budget of a whole built env, use the CUDA probe
tests.support.step_syncs instead (see Cost).
What a test asserts¶
Assert on public outputs: returned tensors, the state of a built entity, a raised error and its message
(pytest.raises(..., match=...)), a file on disk. Reach into a private attribute only when it is the only
observable form of the contract, and say why in a comment. When a test pins an error, match the parts of the message
the error-message rules require: the offending value, and the fix.
Drop checks that cannot fail, or that fail only when something else's tests already fail:
- self-referential oracles. The test recomputes the production formula and compares against its own copy. Pin known values instead (hand-computed, or a boundary case), or compare against an independent implementation.
- tautologies and restatements. A Pydantic default read back unchanged,
isinstanceafter a constructor, a shape assert followed by a value assert that implies it, a class attribute re-read. - tests of Python, Pydantic, torch or Genesis rather than of Eden.
- source-text tests (
inspect.getsourceplus a regex) when a behavioural test can see the same thing. - import-only smoke tests that a behavioural test in the same area already covers.
One behaviour gets one test. Families that differ only in data become one @pytest.mark.parametrize.
Cost¶
A Genesis scene build takes seconds, so:
- build once per module (
@pytest.fixture(scope="module")) and share it. Several tests that need slightly different scenes usually fit in one scene with a few more entities. - keep
num_envssmall (1–4) unless the test is about batching. - if a test mutates a shared env, it restores what it changed or calls
env.reset(). - mark genuinely slow tests (training iterations, long rollouts)
@pytest.mark.slow.
Behaviour that exists only on CUDA (a sync count, a CUDA graph, the batch renderer, a timing) cannot be tested
in the suite's own process: Genesis is initialized there on the CPU and cannot be re-initialized in-process. Such a
test runs a probe in a subprocess. The probes are modules under tests/support/ (step_syncs, graded_graph,
reset_bench, batch_renderer_ink), each run as python -m tests.support.<probe> ... from the repository root,
which prints one line, RESULT {...}, that the test parses and asserts on.
Running¶
Run the files your change touches, one file at a time, rather than the whole directory in one process:
uv run pytest tests/managers/terms/rewards/test_locomotion.py
for f in $(find tests -name 'test_*.py' | sort); do uv run pytest "$f" || break; done
Whole-suite runs in one process used to crash partway through, because every scene.build() used up kernel-tree
slots that a process has a fixed number of. That leak is fixed in the Genesis version Eden pins, but a single-process
run of the whole suite has not been re-verified since, so running per file remains the supported way.
The markers are declared in tests/conftest.py: slow, requires_genesis, and precision(bits) (the Genesis
precision a MochiSolver test runs at, in tests/engine/solvers/mochi). pyproject.toml passes --strict-markers,
so an undeclared marker is a collection error rather than a decorator that silently does nothing. Nothing is marked
automatically; apply a marker per test or per module (pytestmark = pytest.mark.requires_genesis).
See also¶
- Error Messages
- Contributing
- Terms guide — the term contract the tests pin