suite.suite¶
- class otto.suite.suite.OttoSuite¶
Bases:
Generic[TOptions]Base class for otto test suites.
Subclass this and decorate with
@register_suite()to register your suite as anotto test <ClassName>subcommand. OttoSuite is a plain class (notunittest.TestCase), so all standard pytest features work natively — fixtures,@pytest.mark.parametrize, markers, conftest.py, and yield-based setup/teardown.Defining suite options¶
Define a
@dataclassbefore the suite class, annotate each field withAnnotated[T, typer.Option(help="...")]so the help text appears inotto test <ClassName> --help, then pass it as the generic argument:from dataclasses import dataclass from typing import Annotated import typer from otto.suite import OttoSuite, register_suite @dataclass class _Opts: device_type: Annotated[str, typer.Option( help="Kind of device under test ('router', 'switch').", )] = "router" @register_suite() class TestMyDevice(OttoSuite[_Opts]): """Validate device configuration.""" Options = _OptsAccessing options in tests¶
Request the
suite_optionsfixture as a parameter. It provides theOptionsinstance constructed from CLI arguments:async def test_device_reachable(self, suite_options: _Opts) -> None: self.logger.info(f"Testing {suite_options.device_type}")Parametrized tests¶
Use
@pytest.mark.parametrizeon test methods:@pytest.mark.parametrize("interface", ["eth0", "eth1", "mgmt0"]) async def test_interface_up(self, interface: str) -> None: self.logger.info(f"Checking {interface}") assert TrueEach parameter combination gets its own
testDirwith a sanitized name.Using fixtures¶
Test methods can request any pytest fixture as a parameter. Define shared fixtures in a
conftest.pyalongside your suite:# conftest.py @pytest.fixture async def primary_host(): from otto.configmodule import get_host host = get_host("primary") yield host await host.close() # test_device.py async def test_with_host(self, primary_host) -> None: result = await primary_host.oneshot("echo hello") assert result.status == Status.SuccessInheriting repo-wide options¶
Create a shared base dataclass in your pylib and inherit from it to share common options across multiple suites:
# pylib/my_suites/options.py @dataclass class RepoOptions: lab_env: Annotated[str, typer.Option( help="Lab environment to target.", )] = "staging" # tests/test_device.py @dataclass class _Opts(RepoOptions): # inherits --lab-env firmware: Annotated[str, typer.Option( help="Firmware version to validate.", )] = "latest" @register_suite() class TestDevice(OttoSuite[_Opts]): Options = _OptsBoth
--lab-envand--firmwareappear inotto test TestDevice --help.The same
RepoOptionsdataclass may also be passed to@instruction(options=...)so thatotto runsubcommands expose the same repo-wide flags asotto test— seeotto.cli.run.instruction().Built-in autouse fixtures¶
OttoSuite provides three autouse fixtures that run for every test:
_otto_log_test_start— logs a banner marking the start of each test_otto_test_dir— createsself.testDirwith sanitized node name_otto_monitor_events— records monitor start/end events
Per-test timeouts are enforced by the shared
_otto_timeoutfixture inotto.suite.timeout, registered automatically byOttoPlugin. Apply@pytest.mark.timeout(seconds)to individual tests, classes, or set atimeoutclass attribute on the suite.- classmethod setup_class()¶
- classmethod teardown_class()¶
-
expect(condition, msg=
None)¶ Record a non-fatal expectation.
Unlike
assert, a failingexpect()does not stop the test. All failures are collected and reported together when the test finishes — the test is marked failed only at that point.Use
assertfor preconditions that must hold before the test can continue (fatal). Useexpect()for checks where you want to see all failures at once (non-fatal).- Parameters:¶
condition – Any truthy/falsy expression to evaluate.
msg – Optional human-friendly message printed alongside the auto-captured source line and locals — not a replacement.
Examples
Fatal vs non-fatal:
# Fatal — test stops here if command itself failed assert result.status == Status.Success # Non-fatal — records failure, test continues self.expect("hostname" in result.output) self.expect("interface" in result.output) self.expect(result.retcode == 0, "unexpected retcode")The failure report always includes the source location and caller locals. When msg is provided it appears in addition to the auto-captured source context, never replacing it:
>>> from unittest.mock import MagicMock >>> from otto.suite.suite import OttoSuite >>> suite = OttoSuite() >>> suite._expect_failures = [] >>> suite.logger = MagicMock() >>> x = 42 >>> suite.expect(x == 99, "math is broken") >>> report = suite._expect_failures[0] >>> "Message: math is broken" in report True >>> "x = 42" in report TrueNote
The auto-captured source line and locals are best-effort. Provide msg when the expression alone isn’t self-explanatory.
- Return type:¶
None
-
async startMonitor(hosts=
None, interval=datetime.timedelta(seconds=5), parsers=None, port=0, bind='127.0.0.1', db_path=None, targets=None)¶ Start metric collection from all hosts and launch the web dashboard.
Must be called with
await:url = await self.startMonitor(hosts=[host])All hosts are polled simultaneously on each tick via asyncio.gather(). Series keys in results are
"hostname/metric_label".- Parameters:¶
hosts – The RemoteHosts to monitor. Ignored when targets is provided.
interval – How often to poll the hosts. timedelta or float (seconds).
parsers – Custom metric parsers applied to all hosts. Ignored when targets is provided.
port – TCP port for the dashboard web server (0 = auto-assign).
bind – Address to bind to. Use ‘0.0.0.0’ for access from other machines.
db_path – Path for SQLite persistence. If None, data is in-memory only.
targets – Per-host MonitorTarget objects. When provided, hosts and parsers are ignored. Use this to assign different parsers to different hosts.
- Return type:¶
str- Returns:¶
Dashboard URL, e.g. ‘http – //127.0.0.1:8080’.
- async stopMonitor()¶
Stop metric collection and shut down the dashboard server.
Must be called with
await:await self.stopMonitor()- Return type:¶
None
-
async addMonitorEvent(label, color=
'#888888', dash='dash')¶ Record a labeled event on the live dashboard at the current time.
Has no effect if monitoring is not active. Honors a per-suite collector created by
startMonitor()first, then falls back to the session-wide collector started byotto test --monitor.- Return type:¶
None
- getMonitorResults()¶
Return collected metric series after stopMonitor(). Empty dict if never started.
- Return type:¶
dict[str,list[tuple[datetime,float]]]
- getMonitorEvents()¶
Return all recorded events after stopMonitor(). Empty list if never started.
- Return type:¶
list[MonitorEvent]