Adopting Litmus from an existing pytest project #

You already have a pytest test suite. This page is the route to wiring Litmus in without rewriting it.

The short version: install Litmus, point a station YAML at your bench, and the bundled pytest plugin auto-loads. Existing tests keep running. New tests that take Litmus fixtures (verify, measure, context, your per-role instrument fixtures) get measurement logging, limit checking, parquet results, and the operator UI for free.

The longer version is the rest of this page: install, what auto-loads, what fixtures appear, how to keep an old test alongside a new one, and four entry points for mixing in Litmus features at different depths.

Install #

pip install litmus-test

That's it. Litmus's pytest plugin registers via its entry point in pyproject.toml — pytest discovers and loads it automatically. You do not need to add pytest_plugins = ["litmus"] to your conftest.

The plugin registers these CLI flags out of the box:

  • --uut-serial, --uut-serials, --uut-part-number, --uut-revision, --uut-lot-number
  • --station, --slot, --fixture, --part
  • --mock-instruments / --no-mock-instruments, --test-phase, --test-profile / --no-test-profile, --operator
  • --data-dir, --guardband, --strict-traceability

The full table with defaults and descriptions is in reference/pytest-native.md. Dynamic flags for profile facets and required_inputs: keys are also registered — see that page.

Don't re-register these in your own pytest_addoption — pytest fails at collection if a flag is registered twice; the plugin already owns them.

Verify it loaded #

pytest --co -q

The plugin name appears in the loaded-plugins list at the top of the output. If your fixtures collection includes names like context, verify, measure, pins, instruments, mock_instruments, the plugin is live.

What fixtures appear #

The plugin provides a set of plugin fixtures (most-used: verify, measure, context, pins, instruments). It also creates one fixture per instrument in the active station YAML — a station with instruments: { dmm: ..., psu: ..., scope: ... } exposes dmm, psu, scope as fixtures automatically. No wrapper code needed.

# tests/test_voltage.py — a new pytest test that uses Litmus
def test_output_voltage(dmm, verify):
    verify("output_voltage", dmm.measure_dc_voltage())

The dmm fixture resolves to a connected DMM driver from your station YAML. The verify fixture resolves the limit (sidecar / marker / part spec / inline limit=), records the measurement to parquet, and raises LimitFailure if it's out of range.

Your existing tests keep running unmodified — pytest treats them as ordinary tests with no fixture dependencies on the Litmus surface.

# tests/test_existing.py — untouched
def test_calculate_something():
    assert calculate() == 42

Both run together:

pytest tests/ --station=bench_1 --uut-serial=SN001

Configuration files #

A complete Litmus-aware project has up to four YAML files. None of them are required for plain pytest tests; each unlocks more of the platform.

FileWhat it doesRequired when
litmus.yamlProject-wide defaults (data dir, default station, etc.)Always recommended — pin a data_dir: so results land somewhere predictable
stations/<id>.yamlDeclares instruments and their roles for one benchAny test that takes an instrument fixture (dmm, psu, etc.)
fixtures/<id>.yamlMaps UUT pins to instrument channelsTests that use the pins fixture or need pin-level traceability
parts/<id>.yamlDeclares pins + characteristics + spec bandsTests that use verify against a part spec

For the full schemas, see configuration reference.

A minimal litmus.yaml:

# Project root
name: my-existing-project
data_dir: results              # writes to ./results/ instead of the global pool
default_station: bench_1

A minimal sidecar (per-test YAML, optional):

# tests/test_voltage.yaml — colocated with the test module
limits:                        # applied to every test in the file
  voltage:
    low: 3.0
    high: 3.6
    unit: V
 
tests:
  test_power_rails:            # per-test overrides nested under tests:
    limits:
      vcc:
        low: 3.2
        high: 3.4
        unit: V

Top-level keys must be SidecarConfig fields (limits, sweeps, mocks, prompts, retry, connections, characteristics, tests, runner). A test name at the YAML root fails validation because the model rejects unknown keys.

Four ways to mix Litmus in #

These are independent entry points, not a staircase. Pick the one that matches the project state. You can combine them.

Path A — Litmus fixtures from new tests (the canonical default) #

The headline path. Add --station= and write new tests that take Litmus fixtures.

def test_output_voltage(dmm, verify):
    verify("output_voltage", dmm.measure_dc_voltage())
  • Pros: smallest possible surface; standard pytest; uses everything the plugin offers.
  • Trade-off: requires a station YAML to define dmm.

Use this for any test you're writing fresh. See writing tests for end-to-end patterns and Litmus fixtures for the full fixture surface.

Path B — LitmusClient for result tracking from any existing test #

For tests where rewriting the assertion to use verify isn't worth it but you still want measurements landing in parquet:

from litmus import LitmusClient
 
client = LitmusClient()
run = client.start_run(uut_serial="SN001", station_id="bench_1", test_phase="production")
 
with run.step("voltage_check") as step:
    voltage = your_existing_measure_function()
    step.measure("voltage", voltage, unit="V", low=3.0, high=3.6)
    assert 3.0 <= voltage <= 3.6   # your existing assertion stays
 
run.finish()

LitmusClient is a chained builder — run.step() and step.vector() are context managers; run.finish() finalizes and saves. Full API on reference/client.md.

  • Pros: zero plugin dependency; works from any Python code (LabVIEW Python Node, TestStand Python adapter, standalone scripts).
  • Trade-off: don't mix Path B with Path A in the same pytest session — the plugin and a manual LitmusClient would each open a run, producing duplicate rows.

Use this when you've got an existing pytest suite you don't want to touch, or when you're driving Litmus from non-pytest code. See also submitting results from non-pytest sources.

Path C — non-pytest runners #

Driving Litmus from a non-pytest runner (Robot Framework, unittest, ad-hoc scripts)? Use TestHarness — see test harness. In a pytest project the plugin already does this for you; you don't need it here.

Path D — VisaInstrument to replace ad-hoc driver code #

Independent of how you track results, the VisaInstrument base class wraps PyVISA with pyvisa-sim simulation built in:

# Before — raw pyvisa, no simulation, no Litmus
def measure_voltage():
    import pyvisa
    rm = pyvisa.ResourceManager()
    dmm = rm.open_resource("TCPIP::192.168.1.100::INSTR")
    voltage = float(dmm.query("MEAS:VOLT?"))
    dmm.close()
    return voltage
 
# After — your driver class subclasses VisaInstrument
from litmus.instruments.visa import VisaInstrument
 
class MyDMM(VisaInstrument):
    def measure_voltage(self) -> float:
        return float(self.query("MEAS:VOLT?"))
 
def measure_voltage(simulate=False):
    with MyDMM("TCPIP::192.168.1.100::INSTR", simulate=simulate) as dmm:
        return dmm.measure_voltage()

You can use this in isolation (no plugin, no station YAML) for the simulation contract alone, or wire it into a station YAML so the plugin handles instantiation. See custom drivers.

Coexistence patterns #

Marking Litmus vs non-Litmus tests #

Register a custom marker in your pytest.ini / pyproject.toml so --strict-markers doesn't warn:

# pyproject.toml
[tool.pytest.ini_options]
markers = [
    "litmus: requires Litmus station + fixtures",
    "unit: pure-Python unit test, no Litmus",
]

Then:

@pytest.mark.litmus
def test_with_litmus(dmm, verify):
    verify("voltage", dmm.measure_dc_voltage())
 
@pytest.mark.unit
def test_without_litmus():
    assert calculate() == 42

Run subsets:

pytest -m litmus              # only Litmus-flavored tests
pytest -m "not litmus"        # only unit tests

Separate directories #

tests/
├── unit/           # plain pytest, no Litmus surface
│   └── test_*.py
├── integration/    # uses Litmus fixtures
│   └── test_*.py
└── conftest.py     # shared

Loading station YAML from non-pytest code #

For migration tooling or scripts that read your station YAML directly:

from litmus.store import get_station
 
# By id — looks up stations/<id>.yaml under the project root
station = get_station("bench_1")
if station is None:
    raise RuntimeError("bench_1 not found")
 
for role, cfg in station.instruments.items():
    print(role, cfg.driver, cfg.resource, cfg.mock)

get_station(id) looks up stations/<id>.yaml. load_station(path) (also exported from litmus.store) takes an explicit Path for files outside the project's stations/ directory.

Running the tests #

Local development #

pytest tests/                                  # auto-resolves default_station from litmus.yaml
pytest tests/ --mock-instruments               # hardware-free run via mock instruments
pytest tests/ --station=bench_1 --uut-serial=SN001

CI #

- name: Run tests
  run: |
    pytest tests/ \
      --mock-instruments \
      --uut-serial=CI \
      --station=ci_station \
      --test-phase=development

For CI, the simplest setup is a stations/ci_station.yaml whose every instrument has mock: true. With --mock-instruments, the platform substitutes a stand-in for each instrument that returns the values listed in mock_config:; your driver class is never instantiated, connect() is never called. See mock mode for the details.

Production #

pytest tests/ \
  --station=bench_1 \
  --uut-serial=$SERIAL \
  --operator=$OPERATOR \
  --test-phase=production

--test-phase=production requires a clean git tree; uncommitted changes silently demote the stamped phase to development (see cli.md).

How do I know it worked? #

After the first test run with Litmus active, verify the results landed:

litmus runs                  # list of recent runs
litmus show <run_id>         # detailed report for one run
litmus serve                 # operator UI at http://localhost:8000

If litmus runs is empty, the session likely didn't finish cleanly — a killed pytest process leaves the run stamped aborted. See outcomes.

What this gets you vs what it costs #

You getYou spend
Every measurement persisted with full traceability (UUT serial, station, operator, timestamps, limits, outcomes)Writing a stations/<id>.yaml for each bench
Mock-mode CI without changing test bodiesPer-test mock_config setpoints for the simulated bench
Operator UI, MCP tools, HTTP API on the same dataNothing — they read the same parquet
Spec-driven limits (limits move from test code to part YAML)Authoring parts/<id>.yaml
Capability matching (which station can run this part)A catalog/<vendor>/<model>.yaml per instrument model

Pick what you need. The plugin doesn't force any of it — without YAMLs, you still get plain pytest with no platform features active.

See also #