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=SN001

Why Use pins Instead of instruments? #

instruments["dmm"]pins["VOUT"]
Station-centricUUT-centric
"Use the DMM""Measure VOUT"
Changes if station changesStable across stations
No traceabilityFull 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: 2

The 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 name

prompts: Operator Prompts #

prompts:
  visual_inspection:
    message: "Verify LED is GREEN"
    prompt_type: confirm
    timeout_seconds: 30

Reference 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: dmm

tests/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" \
  -v

With simulation:

pytest tests/ \
  --station=bench_1 \
  --fixture=power_board_fixture \
  --mock-instruments \
  --uut-serial=SIM001 \
  -v

Viewing Results #

CLI #

litmus runs                    # List recent runs
litmus show <run_id>           # Show run details

Operator UI #

litmus serve
# Open http://localhost:8000

Programmatic #

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 #

ComponentFilePurpose
Part specparts/power_board.yamlWhat to test
Stationstations/bench_1.yamlWhere to test
Fixturefixtures/power_board_fixture.yamlPin-to-instrument mapping
Test classtests/test_power_board.pyTest code, methods run in definition order
Sidecartests/test_power_board.yamlLimits, sweeps, mocks, retries per method

What You Learned #

  • Fixture configuration for pin-to-instrument mapping
  • The pins fixture 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 promote

This:

  • 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 your litmus.yaml so 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 #

Tutorial · Step 10 of 13