Testing
Running Tests
Run the full suite:
python -m pytest tests/
The Python suite collects 3315 tests across 260 test files. 53 of them are marked e2e and deselected by default (addopts = "-m 'not e2e'"); see E2E Tests.
Frontend tests (vitest)
The React app has its own unit suite — 175 tests across 27 files — plus a type check:
cd ui
npm ci
npx tsc --noEmit # type errors are failures too
npx vitest run
Both run in CI (the frontend job in ci.yml, Node 20). They were not wired into CI until 2026-08 — a broken reactflow mock had been failing on master unnoticed for that reason. If you add a frontend test, check it runs here before pushing.
Mock reactflow partially, never exhaustively
reactflow needs browser APIs jsdom lacks, so its components have to be stubbed — but an exhaustive hand-written mock breaks the moment a component imports one more export. Use vi.mock('reactflow', async (importOriginal) => ({ ...await importOriginal(), /* stubs */ })) and override only the components that touch the DOM or the zustand store (ReactFlow, ReactFlowProvider, Background, Controls, MiniMap). Enums like MarkerType and BackgroundVariant then stay real. See ui/src/components/editor/EditorCanvas.test.tsx.
Run a specific test file:
python -m pytest tests/unit/qa/test_qa_phase4_core.py
Run tests matching a keyword:
python -m pytest tests/ -k "test_hello"
Run with verbose output:
python -m pytest tests/ -v
Test Organization
tests/
├── conftest.py # Shared fixtures (sample workflows)
├── unit/ # Unit tests (~75 files, ~860 tests)
│ ├── test_models_*.py # Pydantic model validation
│ ├── test_cli_*.py # CLI command tests
│ ├── test_dag.py # DAG construction and traversal
│ ├── test_scheduler.py # Scheduler logic (dependency resolution)
│ ├── test_dispatcher.py # Dispatcher and adapter routing
│ ├── test_qa_*.py # QA regression tests (see below)
│ └── ...
└── integration/
└── test_orchestrator.py # End-to-end orchestrator tests
QA Test Files
QA tests follow a structured plan and are organized by phase:
| File | Focus | Tests |
|---|---|---|
test_qa_models.py |
Pydantic models, validation | ~20 |
test_qa_stores.py |
Sqlite and filesystem stores | ~20 |
test_qa_dag_scheduler.py |
DAG and scheduler | ~15 |
test_qa_adapters_runtime.py |
Adapters, dispatcher | ~15 |
test_qa_cli_workflow.py |
CLI + workflow loading | ~15 |
test_qa_trace_registry.py |
Trace, registry | ~15 |
test_qa_replay.py |
Replay command | ~10 |
test_qa_phase2.py |
Agents, settings | 22 |
test_qa_phase3_cli.py |
CLI DX commands | 22 |
test_qa_phase4_core.py |
Runtime, stores, adapters | 27 |
test_qa_phase5a_security.py |
Security, E2E | 22 |
test_qa_phase5b_remaining.py |
Registry, trace, workflow, models | 21 |
E2E Tests (Playwright)
Browser tests live in tests/e2e_playwright/ and are marked e2e.
Prerequisite: a running UI server
E2E tests expect the web UI on http://localhost:8420:
./scripts/build-ui.sh # build the frontend (once, or after frontend changes)
binex ui --port 8420 --no-browser # start the server
Running
pytest -m e2e # all e2e tests
pytest -m e2e -n auto # in parallel (pytest-xdist)
Plain pytest tests/ does not run them: pyproject.toml sets addopts = "-m 'not e2e'", so the marker is excluded by default. Passing -m e2e on the command line overrides it.
Debugging a failure
Collect artifacts on failure (screenshots, video, trace go to test-results/):
pytest -m e2e --screenshot=only-on-failure --video=retain-on-failure --tracing=retain-on-failure
playwright show-trace test-results/<test-dir>/trace.zip
In CI the same artifacts are uploaded as playwright-artifacts on failure — download the zip and open the trace locally with show-trace.
Other useful modes:
pytest -m e2e -k test_name --headed # watch the browser
PWDEBUG=1 pytest -m e2e -k test_name # Playwright Inspector, step-by-step
Page objects (tests/e2e_playwright/pages/)
sidebar.py—Sidebar+SidebarLink/SidebarGroupenumsexport_page.py—ExportPage+ExportFormatenum
Fixtures sidebar and export_page are provided by tests/e2e_playwright/conftest.py. Three POM rules:
- Selectors (
get_by_test_id, roles) live only in page objects — no raw selectors in tests. - Page objects expose actions and
Locators, never assertions — checks belong in tests. - Use enums instead of strings for finite sets (sidebar links, export formats).
conftest.py also injects TOUR_DISMISSED_STATE as storage_state so the onboarding tour never overlays elements under test.
Arrange-Act-Assert
Tests follow the Arrange-Act-Assert structure: set up state, perform the
action under test, verify the outcome. The three phases are separated by
a blank line — no comments needed; the whitespace is the annotation.
test_export.py is the reference example.
Async Test Configuration
All async tests are auto-detected. The pyproject.toml sets:
[tool.pytest.ini_options]
testpaths = ["tests"]
asyncio_mode = "auto"
This means you do not need the @pytest.mark.asyncio decorator. Simply define your test as async def and pytest-asyncio handles the rest:
async def test_orchestrator_runs_two_nodes():
orch = Orchestrator(
artifact_store=InMemoryArtifactStore(),
execution_store=InMemoryExecutionStore(),
)
# ... register adapters, run workflow
summary = await orch.run_workflow(spec)
assert summary.completed_nodes == 2
Shared Fixtures
Defined in tests/conftest.py:
sample_workflow_dict()— Minimal 2-node workflow (producer -> consumer) with local echo agentssample_research_workflow_dict()— 5-node research pipeline (planner -> 2 researchers -> validator -> summarizer)
Usage:
def test_workflow_parsing(sample_workflow_dict):
spec = WorkflowSpec(**sample_workflow_dict)
assert len(spec.nodes) == 2
assert "producer" in spec.nodes
Mocking Patterns
CLI Store Patching
CLI commands use a _get_stores() helper that returns real sqlite + filesystem stores by default. Always patch this in tests to avoid hitting disk:
from click.testing import CliRunner
from unittest.mock import patch
from binex.cli.hello import hello_cmd
from binex.stores.backends.memory import InMemoryExecutionStore, InMemoryArtifactStore
def test_hello_command():
stores = InMemoryExecutionStore(), InMemoryArtifactStore()
with patch("binex.cli.hello._get_stores", return_value=stores):
runner = CliRunner()
result = runner.invoke(hello_cmd, [])
assert result.exit_code == 0
assert "Hello from Binex!" in result.output
The patch target follows the pattern binex.cli.<module>._get_stores, where <module> matches the command file (e.g., run, debug, hello, trace, replay).
In-Memory Stores for Unit Tests
For non-CLI tests, use the in-memory store implementations directly:
from binex.stores.backends.memory import InMemoryExecutionStore, InMemoryArtifactStore
async def test_store_roundtrip():
store = InMemoryExecutionStore()
await store.record(execution_record)
result = await store.get_run(run_id)
assert result is not None
LiteLLM Mocking
Mock litellm.acompletion when testing LLM-backed nodes:
from unittest.mock import AsyncMock, patch
async def test_llm_adapter():
mock_response = AsyncMock()
mock_response.choices = [AsyncMock(message=AsyncMock(content="result"))]
with patch("litellm.acompletion", new_callable=AsyncMock, return_value=mock_response):
result = await adapter.execute(task, inputs, trace_id)
assert result[0].content == "result"
Custom Test Adapters
For orchestrator tests, create simple adapter classes instead of mocking:
class EchoAdapter:
"""Returns an artifact containing the node_id as content."""
def __init__(self, content: str | None = None, *, fail: bool = False):
self._content = content
self._fail = fail
self.call_count = 0
async def execute(self, task, input_artifacts, trace_id):
self.call_count += 1
if self._fail:
raise RuntimeError(f"Node {task.node_id} failed")
content = self._content or f"result_from_{task.node_id}"
return [
Artifact(
id=f"art_{task.run_id}_{task.node_id}",
run_id=task.run_id,
type="result",
content=content,
lineage=Lineage(
produced_by=task.node_id,
derived_from=[a.id for a in input_artifacts],
),
)
]
async def cancel(self, task_id: str) -> None:
pass
async def health(self):
return AgentHealth.ALIVE
This pattern is used extensively in test_qa_phase4_core.py for testing orchestrator flows, retry logic, and DAG execution order.
Writing YAML Workflow Files in Tests
Use tmp_path and textwrap.dedent to create temporary workflow files:
import textwrap
from pathlib import Path
def _write_yaml(tmp_path: Path, content: str) -> Path:
wf = tmp_path / "wf.yaml"
wf.write_text(textwrap.dedent(content))
return wf
def test_run_command(tmp_path):
wf = _write_yaml(tmp_path, """\
name: test
nodes:
node1:
agent: "local://echo"
system_prompt: do_stuff
outputs: [result]
""")
# ... invoke CLI with str(wf)
Linting
Run ruff to check for style and import issues:
ruff check src/
The project uses these ruff rules: E (pycodestyle errors), F (pyflakes), I (isort), N (naming), W (warnings), UP (pyupgrade). Line length is 99 characters.
Code Coverage
To run tests with coverage:
python -m pytest tests/ --cov=src/binex --cov-report=term-missing
The unit + integration suite currently covers ~82% of src/binex (measured
2026-08, --cov=binex over tests/unit tests/integration). Not every
uncovered line is a gap — see the next section.
Conscious Testing Boundaries
Some zeros and low percentages in the coverage report are decisions, not neglect. The guiding rule: the boundary runs through the terminal, not through the file — pure logic inside a boundary module is extracted into a function and unit-tested; only the genuinely interactive or environment-bound layer stays untested. If you touch one of these modules and find separable logic, pull it out and test it rather than widening the boundary.
Interactive TUI screens — covered by manual QA scenarios plus smoke runs,
not unit tests. A unit test over a mocked click.getchar() loop asserts the
mock, not the terminal, and gives false confidence:
cli/explore_ui.py,cli/explore_actions.py,cli/explore_replay.py, and the interactive loops ofcli/explore.py(thebinex exploredashboard)cli/start_ui.pyand the prompt chains incli/start_constructor.py/cli/start_config.py(thebinex startwizard) — their computable steps are already covered via scaffold/template tests- the live-render wrapper in
cli/run_progress.py(richLivetable) - the
_render_*functions oftrace/diff_rich.py(its pure helpers — delta formatting, error-change detection, row building — are unit-tested) - rich panel styling in
cli/trace.py(the event-grouping logic is tested)
Launchers and entry points — wrappers whose only behavior is starting something else; a test would assert "the wrapper calls the library":
cli/collect.py(uvicorn launcher for the OTel collector)mcp_server/server.py::run_server()(stdio transport is FastMCP's code, exercised only by a real MCP client; tool registration and delegation ARE unit-tested)binex/__main__.py(two-line delegation tocli.main)scheduler/engine.py::start()(signal handlers + sleep loop;_tick, rescan/skip logic and_run_workfloware unit-tested)
Transport-only branches — reachable only through a real network client:
- the
CancelledErrorbranch of the SSE generator inui/api/events.py(client disconnect)
Environment-gated paths — dead in the default test environment because an optional dependency is absent; not dead in the matrix:
telemetry.pyOTel emission paths (require thetelemetryextra)- protobuf decode paths in
importers/collector.py(requireopentelemetry-proto; the 415 gate is tested) observe_crewai.pyattribution paths — covered by the dedicatedobserve-crewaiCI job that installscrewai; only a local-environment boundary
Anything not listed here is expected to be tested; a new zero in the coverage report is either a bug in the test setup or a candidate for this list — with its one-sentence justification written down, not silently accepted.