Step 9: Production Ready #
Goal: Build a complete production test class with fixtures, sidecar configuration, and full traceability.
What You'll Build #
A production-ready test class with:
- Pin-to-instrument mapping (fixtures)
- Ordered test execution (pytest class methods, in the order you write them)
- Per-test limits, mocks, sweeps, and retries (sidecar YAML)
- Full signal traceability
Complete Project Structure #
my_project/
├── parts/ # WHAT you're testing
│ └── power_board.yaml
├── stations/ # WHERE you test
│ └── bench_1.yaml
├── fixtures/ # HOW pins connect to instruments
│ └── power_board_fixture.yaml
├── tests/ # Test code + sidecar
│ ├── conftest.py
│ ├── test_power_board.py # Test class — execution order = method order
│ └── test_power_board.yaml # Sidecar — limits, sweeps, mocks per method
└── data/ # Run output (gitignored)The Fixture: Pin-to-Instrument Mapping #
A fixture maps UUT pins to station instruments:
# fixtures/power_board_fixture.yaml
id: power_board_fixture
name: "Power Board Test Fixture"
part_id: power_board
connections:
vin_supply:
name: vin_supply # Required (by convention, the dict key)
uut_pin: VIN # From part spec
instrument: psu # From station config
instrument_channel: "1"
vout_measure:
name: vout_measure
uut_pin: VOUT
instrument: dmm
gnd_supply:
name: gnd_supply
uut_pin: GND
instrument: psu
instrument_channel: "GND"The pins Fixture #
With a fixture config, you can access instruments via pin names. The pins fixture is a dict keyed by part-pin name returning the instrument routed to that pin by the active fixture YAML — distinct from the pins: block in the part YAML, which declares the pin set itself (concepts/parts):
def test_output_voltage(pins, measure):
"""Access instruments by UUT pin name."""
pins["VIN"].set_voltage(5.0)
pins["VIN"].enable_output()
voltage = pins["VOUT"].measure_dc_voltage()
measure("output_voltage", voltage)Run with fixture config:
pytest tests/ \
--station=bench_1 \
--fixture=power_board_fixture \
--uut-serial=SN001Why Use pins Instead of instruments? #
instruments["dmm"] | pins["VOUT"] |
|---|---|
| Station-centric | UUT-centric |
| "Use the DMM" | "Measure VOUT" |
| Changes if station changes | Stable across stations |
| No traceability | Full traceability |
The pins approach provides:
- Instrument-independent — Test code doesn't name an instrument; it names the UUT pin, and the fixture routes it
- Portability — Same test works on stations with different instruments
- Traceability — Measurements linked to UUT pins
The Production Test Class #
A test class groups related test methods that run in definition order. Each method gets its own row in the run, with its own limits, sweeps, mocks, and retries from the sidecar.
# tests/test_power_board.py
class TestPowerBoardProduction:
"""Production test for power_board — runs in method order."""
def test_input_voltage(self, pins, verify):
pins["VIN"].set_voltage(5.0)
pins["VIN"].enable_output()
verify("input_voltage", pins["VIN"].measure_voltage())
def test_output_voltage(self, pins, verify):
verify("output_voltage", pins["VOUT"].measure_dc_voltage())
def test_load_sweep(self, pins, verify, load_percent):
# load_percent is parametrized via the sidecar's sweeps:
verify("output_voltage", pins["VOUT"].measure_dc_voltage())# tests/test_power_board.yaml — sidecar
limits:
input_voltage:
low: 4.5
high: 5.5
nominal: 5.0
unit: V
output_voltage:
low: 3.135
high: 3.465
unit: V
mocks:
- target: psu.measure_voltage
return_value: 5.0
- target: dmm.measure_dc_voltage
return_value: 3.31
tests:
TestPowerBoardProduction:
tests:
test_load_sweep:
sweeps:
- load_percent: [0, 50, 100]
retry:
max_retries: 2The sidecar keys follow the same path::Class::method naming pytest gives each test. Top-level keys (limits, mocks) apply to the whole file; nest under tests: → class name → method name to override limits, sweeps, mocks, or retries for one class or one method.
Sidecar Features #
retry: Per-Test Retry on Failure #
tests:
TestPowerBoardProduction:
tests:
test_margin:
retry:
max_retries: 2
delay: 0.5
on: [AssertionError] # only retry on this exception nameprompts: Operator Prompts #
prompts:
visual_inspection:
message: "Verify LED is GREEN"
prompt_type: confirm
timeout_seconds: 30Reference the prompt from a test method via the prompt() fixture (Litmus's operator-prompt helper for paused interactions).
Ordering across files #
A test class runs its methods in definition order. To order tests across multiple files, name the files so pytest collects them in the desired order (test_01_power.py, test_02_thermal.py) or filter via a profile (see Profiles).
Complete Example #
parts/power_board.yaml:
id: power_board
name: "5V to 3.3V Converter"
pins:
VIN: {name: "J1.1", role: power}
VOUT: {name: "J1.3", role: signal}
GND: {name: "J1.2", role: ground}
characteristics:
output_voltage:
direction: output
function: dc_voltage
unit: V
pins: [VOUT]
bands:
- value: 3.3
accuracy: {pct_reading: 5}stations/bench_1.yaml:
id: bench_1
name: "Production Bench 1"
instruments:
psu:
type: psu
driver: pymeasure.instruments.keysight.KeysightE36312A
resource: "GPIB0::5::INSTR"
mock_config: {measure_voltage: 5.0}
dmm:
type: dmm
driver: pymeasure.instruments.keysight.Keysight34461A
resource: "TCPIP::192.168.1.100::INSTR"
mock_config: {measure_dc_voltage: 3.31}fixtures/power_board_fixture.yaml:
id: power_board_fixture
part_id: power_board
connections:
vin_supply:
name: vin_supply
uut_pin: VIN
instrument: psu
vout_measure:
name: vout_measure
uut_pin: VOUT
instrument: dmmtests/test_power_board.py:
class TestPowerBoardProduction:
def test_input_voltage(self, pins, verify):
pins["VIN"].set_voltage(5.0)
pins["VIN"].enable_output()
verify("input_voltage", pins["VIN"].measure_voltage())
def test_output_voltage(self, pins, verify):
verify("output_voltage", pins["VOUT"].measure_dc_voltage())Running Production Tests #
pytest tests/ \
--station=bench_1 \
--fixture=power_board_fixture \
--uut-serial=SN12345 \
--operator="Jane Doe" \
-vWith simulation:
pytest tests/ \
--station=bench_1 \
--fixture=power_board_fixture \
--mock-instruments \
--uut-serial=SIM001 \
-vViewing Results #
CLI #
litmus runs # List recent runs
litmus show <run_id> # Show run detailsOperator UI #
litmus serve
# Open http://localhost:8000Programmatic #
import duckdb
# Each run stores its measurements as a list per row; expand them into one row each.
rows = duckdb.sql("""
SELECT m.name, m.value, m.unit
FROM read_parquet('data/runs/**/*.parquet', union_by_name=true),
UNNEST(measurements) AS t(m)
WHERE record_type = 'vector'
""").fetchall()
for name, value, unit in rows:
print(f"{name}: {value} {unit}")Full Traceability #
Every measurement now traces back through the chain:
Measurement: output_voltage = 3.31V PASS
↓
UUT Pin: VOUT (from fixture)
↓
Fixture connection: vout_measure
↓
Instrument: dmm
↓
Station: bench_1
↓
Limit: 3.135-3.465V
↓
Spec: output_voltage @ tolerance=5%What You've Built #
| Component | File | Purpose |
|---|---|---|
| Part spec | parts/power_board.yaml | What to test |
| Station | stations/bench_1.yaml | Where to test |
| Fixture | fixtures/power_board_fixture.yaml | Pin-to-instrument mapping |
| Test class | tests/test_power_board.py | Test code, methods run in definition order |
| Sidecar | tests/test_power_board.yaml | Limits, sweeps, mocks, retries per method |
What You Learned #
- Fixture configuration for pin-to-instrument mapping
- The
pinsfixture for UUT-centric testing - Pytest classes as the unit of ordered execution
- Sidecar YAML for per-test limits, sweeps, mocks, and retries
- Full traceability from spec to measurement
Sharing data across projects: litmus data promote #
litmus init --starter ships your project with a data_dir: data override in litmus.yaml. Runs land in the project-local data/ folder so your tutorial and mock-instrument runs stay out of the shared global store other projects use.
When you're ready to share data across projects and benches — typically once you have real hardware wired up and you want operator-UI access from any directory — run:
litmus data promoteThis:
- Walks your project-local
data/runs/runs/*/*.parquet - Skips runs that match starter sentinels (
part_id: example_part,uut_serial: STARTER001, etc.) — the throwaway scaffolding you ran while learning - Copies the rest into the global store (
~/.local/share/litmus/data/on Linux; platformdirs equivalents on Mac/Windows) - Removes the
data_dir:override from yourlitmus.yamlso future runs go straight to the global store
Add --dry-run to preview without writing. Add --include-starter to bring the scaffolding runs along too if you happened to capture something worth keeping.
The local data/ directory stays in place after promote (the sandbox is still readable if you ever need it). When you're certain, rm -rf data to clean up.
Congratulations! #
You've completed the tutorial. You now have a foundation for production hardware testing with Litmus.
← Step 8: Capability Matching | Step 10: Live Monitoring →
Next Steps #
- API Reference — MCP tools and HTTP endpoints
- Configuration Reference — All YAML options
- Litmus fixtures — every fixture the pytest plugin provides
- Litmus markers — the
litmus_*markers and their sidecar equivalents - pytest-native Reference — how Litmus tests use pytest's own collection / fixtures / markers
- Test Harness Integration — Advanced patterns
Tutorial · Step 10 of 13