Skip to content

Testing

An in-memory mock backend ships as a pytest plugin. It’s auto-registered via an entry point — no conftest wiring — and it implements the same ModbusConnection / ModbusUnit Protocols, so code typed against ModbusUnit runs against it unchanged. This is how you test a device library with no hardware and no Home Assistant in the loop.

  • mock_modbus_connection — a MockModbusConnection.
  • mock_modbus_unit — its unit 1.

MockModbusConnection / MockModbusUnit are also importable from modbus_connection.mock for direct construction.

Set values on the per-space stores (holding, input, coils, discrete_inputs). A single value fills one register; a list fills consecutive registers; a callable is evaluated on every read:

async def test_reads_setpoint(mock_modbus_unit):
mock_modbus_unit.holding[40] = 1234 # single value
mock_modbus_unit.holding[2] = [0x0001, 0x86A0] # list -> consecutive registers
mock_modbus_unit.holding[9] = lambda: 7 # callable -> evaluated per read
assert await mock_modbus_unit.read_holding_registers(40, 1) == [1234]
assert await mock_modbus_unit.read_holding_registers(2, 2) == [0x0001, 0x86A0]

Reads resolve against these stores; writes mutate them and fire on_write callbacks.

Because the mock is a real ModbusUnit, you test a Component exactly as production code uses it:

from modbus_connection.model import Component, gauge
class Meter(Component):
voltage = gauge(0, 0.1, unit="V")
async def test_meter(mock_modbus_unit):
mock_modbus_unit.holding[0] = 2301 # raw
meter = Meter(mock_modbus_unit)
await meter.async_update()
assert meter.voltage == 230.1 # raw * 0.1

Register an on_write callback to simulate a device that changes state in response to a command — e.g. flips a “ready” flag when a command register is written:

def test_command_sets_ready(mock_modbus_unit):
def respond(event):
if event.address == 0: # a command was written
mock_modbus_unit.holding[100] = 1 # device flips its "ready" flag
mock_modbus_unit.on_write(respond)

Arm fail_write and the next write covering that address raises the given error before the store is touched, so the value is left unchanged and on_write callbacks don’t fire. register_type defaults to "holding" (use "coil" for coil writes — the tables are independent); pass None to clear:

async def test_write_rejected(mock_modbus_unit):
mock_modbus_unit.holding[40] = 7
mock_modbus_unit.fail_write(40, ModbusExceptionError(3)) # illegal data value
with pytest.raises(ModbusExceptionError):
await mock_modbus_unit.write_register(40, 99)
assert await mock_modbus_unit.read_holding_registers(40, 1) == [7] # unchanged
mock_modbus_unit.fail_write(40, None) # clear it
await mock_modbus_unit.write_register(40, 99) # now succeeds

Give a register a callable that raises — it’s evaluated on every read:

def boom():
raise ModbusExceptionError(2) # illegal data address
mock_modbus_unit.holding[9] = boom

The mock lets a device library’s tests cover the hard part — the register map, the scaling, the write sequencing, the pooled read plan — with plain pytest and no device. Keep that library separate from any Home Assistant integration and this is where nearly all your coverage lives; see Integration structure.