Step 3: pytest-native tests #

Goal: Adopt Litmus's per-test fixtures so measurements get recorded with full traceability.

In step 2, your tests called driver methods and used assert for pass/fail. Litmus's measure and verify fixtures slot in alongside that, recording each measurement to the run record (see data stores) without changing how your test reads.

You don't need any new YAML for this step. Keep the conftest.py from step 2 — the psu / dmm fixtures still work.

The fixtures you add #

All three are available on every test run — no station, no sidecar, no sweep required. measure and verify record measurements; context exposes what's active for this test — the run, UUT, station, and any sweep values.

FixtureWhat it gives the testVerbs
measureRecords a measurement row — no pass/fail checkmeasure(name, value, limit=None, characteristic=None)
verifyRecords the row, resolves a limit, raises on FAILverify(name, value, limit=..., characteristic=...) (characteristic = a named measurable property on the part spec — covered in step 6 / concepts/capabilities)
contextWhat's active — the run, UUT, station, and (if parametrized) sweep valuesget_param, changed, last, observe, .part, .station, .run

These are the three you'll reach for most. The Litmus plugin for pytest provides more — hardware accessors (pins, instruments, uut), config accessors (part, station_config), and special-purpose fixtures (vectors, sync) — see the Litmus fixtures reference for the full set.

From assert to measure #

Take the test from step 2:

def test_output_voltage(psu, dmm):
    psu.set_voltage(5.0)
    psu.enable_output()
    v = dmm.measure_dc_voltage()
    assert 3.2 <= v <= 3.4

Add measure and record the measurement explicitly:

def test_output_voltage(psu, dmm, measure):
    psu.set_voltage(5.0)
    psu.enable_output()
    v = dmm.measure_dc_voltage()
    measure("output_voltage", v, limit={"low": 3.2, "high": 3.4, "unit": "V"})
    assert 3.2 <= v <= 3.4

Same control flow, but now there's a measurement recorded in the run record — value, units, limits, and outcome — visible to litmus runs, the operator UI, and any downstream analysis.

Skip the assert with verify #

verify is measure + assert in one call. Pass / fail is decided by the limit; an out-of-range value raises AssertionError:

def test_output_voltage(psu, dmm, verify):
    psu.set_voltage(5.0)
    psu.enable_output()
    verify("output_voltage", dmm.measure_dc_voltage(),
           limit={"low": 3.2, "high": 3.4, "unit": "V"})

For one-off tests, passing limit= inline is fine. The cleaner home for limits is the part spec or the sidecar YAML — both arrive in later steps.

Grouping related tests in a plain pytest class is the standard way to structure a Litmus test:

class TestPowerUp:
    def test_input_voltage(self, psu, verify):
        psu.set_voltage(5.0)
        psu.enable_output()
        verify("input_voltage", psu.measure_voltage(),
               limit={"low": 4.5, "high": 5.5, "unit": "V"})
 
    def test_output_voltage(self, dmm, verify):
        verify("output_voltage", dmm.measure_dc_voltage(),
               limit={"low": 3.2, "high": 3.4, "unit": "V"})

Methods run in source order. Each emits its own step events; the class container's outcome rolls up from the worst child outcome.

If a downstream test should skip when an upstream one fails, use @pytest.mark.dependency(depends=["test_input_voltage"]) from the pytest-dependency plugin — pytest's ecosystem, not a Litmus addition.

Parametrize is first-class #

@pytest.mark.parametrize works the way it always does. Add the context fixture if you want those parametrize values recorded with the measurement:

import pytest
@pytest.mark.parametrize("vin", [4.5, 5.0, 5.5])
def test_output_voltage(vin, psu, dmm, verify):
    psu.set_voltage(vin)
    psu.enable_output()
    verify("output_voltage", dmm.measure_dc_voltage(),
           limit={"low": 3.2, "high": 3.4, "unit": "V"})

Each parametrized vin value is recorded as an input named vin on the measurement (its role is input — see traceability), so you can later query "how did output_voltage track vin?" — inputs are addressable by name and role — without adding extra code to the test. Sweeping from YAML instead of inline arrives in step 5.

Litmus also adds a native sweep marker, @pytest.mark.litmus_sweeps, that records the same inputs and supports range expanders (linspace, arange, logspace):

import pytest
 
@pytest.mark.litmus_sweeps([{"vin": [4.5, 5.0, 5.5]}])
def test_output_voltage(vin, psu, dmm, verify):
    ...

Use @pytest.mark.parametrize when you want pytest's per-row pytest.param(..., id="...") metadata; use @pytest.mark.litmus_sweeps when you want range expanders (linspace / arange / logspace) or want the sweep to match how you'll define it in YAML (step 5). See litmus_sweeps and the Litmus markers reference for all seven litmus_* markers.

Multiple measurements per test #

Each verify or measure call records one measurement. Call them as many times as you need:

def test_power_analysis(psu, dmm, verify):
    verify("input_voltage",  psu.measure_voltage(),
           limit={"low": 4.5, "high": 5.5, "unit": "V"})
    verify("input_current",  psu.measure_current(),
           limit={"high": 0.5, "unit": "A"})
    verify("output_voltage", dmm.measure_dc_voltage(),
           limit={"low": 3.2, "high": 3.4, "unit": "V"})

Recording many samples #

measure records one row per name within a step. For a stream of samples under a single name — a stability capture or a scope trace — use a channel (stream), covered in Step 10 and Step 11.

Running the tests #

Nothing new on the command line — same pytest invocation from step 2:

pytest tests/ --mock-instruments -v

If you want to see the recorded measurements, list runs from the CLI:

litmus runs
litmus show <run_id>

What a measurement records #

Read a run back and each measurement gives you:

FieldDescription
measurement_namename passed to verify / measure
measurement_valuethe measured value
measurement_unitunit (from limit.unit)
measurement_outcomepassed / failed / done / skipped / errored
limit_low, limit_high, limit_nominal, limit_comparatorthe active limit
measurement_timestampwhen it was recorded
vector_indexwhich sweep variant (NULL for non-parametrized tests)

Read these with litmus runs / the operator UI, or the Query API.

What you learned #

  • measure(name, value, limit={"low": ..., "high": ..., "unit": "V"}) records a measurement explicitly
  • verify(name, value, limit=...) does the same plus pass/fail + raise on FAIL
  • Pytest classes group related tests; methods run in source order
  • Parametrize works as it always does; values are recorded as inputs (role input)

Continue #

So far you've been passing limit= inline on every verify call. Step 4 separates the limit shape from the test code.

Step 2: Mock Instruments | Step 4: Add Limits →

Tutorial · Step 4 of 13