Experiment tests do not need Gate d1 (that applies to device modules only). Instead, mock the device clients to test measurement logic without connecting to any instruments.

Patching build_client

The standard approach is to patch build_client at the point where it is called:

# tests/test_wavelength_sweep.py
from unittest.mock import MagicMock, patch
from plesty.scan_exp.measurements import wavelength_sweep
from plesty.lib.device.composite_device import CompositeDevice


def make_mock_device(query_value: str = "1.23e-3") -> MagicMock:
    """Create a mock device that returns a fixed value for any query."""
    mock = MagicMock()
    mock.query.return_value = query_value
    mock.write.return_value = True
    mock.identity.return_value = "Mock Device v1.0"
    mock.is_operatable = True
    return mock


def test_wavelength_sweep_basic():
    laser = make_mock_device()
    powermeter = make_mock_device(query_value="0.005")

    instruments = CompositeDevice({"laser": laser, "powermeter": powermeter})

    result = wavelength_sweep(instruments, start_nm=1020, stop_nm=1025, step_nm=1)

    assert result.wavelengths == [1020, 1021, 1022, 1023, 1024, 1025]
    assert len(result.powers) == 6
    assert all(p == 0.005 for p in result.powers)


def test_wavelength_sweep_calls_write_in_order():
    laser = make_mock_device()
    powermeter = make_mock_device()
    instruments = CompositeDevice({"laser": laser, "powermeter": powermeter})

    wavelength_sweep(instruments, start_nm=1060, stop_nm=1062, step_nm=1, settle_ms=0)

    write_calls = [call[0][1] for call in laser.write.call_args_list]
    assert write_calls == [1060, 1061, 1062]


def test_wavelength_sweep_respects_step():
    laser = make_mock_device()
    powermeter = make_mock_device()
    instruments = CompositeDevice({"laser": laser, "powermeter": powermeter})

    result = wavelength_sweep(instruments, start_nm=1000, stop_nm=1010, step_nm=5)

    assert result.wavelengths == [1000, 1005, 1010]

Why Gate d1 does not apply to experiments

Gate d1 checks that the device's DevicePipeline mock tests are present and passing. Experiments are not devices — they do not inherit BaseDeviceSyncModel and do not have param_schema.json. The compliance standard for experiments is the same quantum standard, but Gate d1 is skipped because module_type != "device".

Coverage requirement (Gate 7)

Gate 7 requires ≥ 80% test coverage. For experiment modules, this means covering:

  1. Measurement functions with mocked instruments
  2. Edge cases (empty ranges, step > range, minimum values)
  3. Error paths (device raises an exception mid-sweep)
def test_wavelength_sweep_handles_device_error():
    laser = make_mock_device()
    powermeter = MagicMock()
    powermeter.query.side_effect = RuntimeError("Connection lost")

    instruments = CompositeDevice({"laser": laser, "powermeter": powermeter})

    import pytest
    with pytest.raises(RuntimeError, match="Connection lost"):
        wavelength_sweep(instruments, start_nm=1060, stop_nm=1062)

Running tests

uv run pytest tests/ -v
uv run pytest tests/ --cov=plesty/scan_exp --cov-report=term-missing

The coverage report shows which lines are not covered. Focus on branches and error paths rather than chasing 100%.