Skip to content

Testing in CI

Ralf Steinert edited this page Nov 1, 2026 · 1 revision

Two ways to get simulated devices into automated tests: the pytest plugin (modbus-sim installed in the test environment) or modbus-sim serve as a background process (any language, any test runner).

pytest plugin

pip install modbus-sim registers two fixtures. They start a fresh bench on free ports for every test and stop it afterwards, so tests cannot leak device state into each other and can run in parallel.

from pymodbus.client import ModbusTcpClient


def test_reads_the_meter(modbus_bench):
    c = ModbusTcpClient("127.0.0.1", port=modbus_bench.port)
    c.connect()
    r = c.read_input_registers(0, count=2, device_id=1)
    assert 220 < c.convert_from_registers(r.registers, c.DATATYPE.FLOAT32) < 240
    c.close()
pytest --modbus-bench examples/real-devices.yaml
  • Which bench modbus_bench starts: --modbus-bench FILE, or the ini key modbus_bench = FILE in pytest.ini / [tool.pytest.ini_options] (relative to the rootdir). Without either: one vfd at unit 1.
  • modbus_bench.port is the TCP port, modbus_bench.client() a connected modbus_sim.client.ModbusClient.
  • modbus_bench.inject(device, kind, **params) and .clear(device) switch faults; device is the name from the bench file or the unit ID.
  • modbus_bench.value(device, point) reads a point's current value without a Modbus request — handy for asserting what your code wrote.
  • modbus_bench_factory(source, **options) starts more benches in one test: a bench file, a dict, or a bundled profile name; framing="rtu" for RTU over TCP.

A bench per test module, defined in code

import pytest

BENCH = {"devices": [
    {"profile": "eastron-sdm630", "unit": 1, "name": "meter"},
    {"profile": "omron-e5cc", "unit": 2, "name": "controller"},
]}


@pytest.fixture
def plant(modbus_bench_factory):
    return modbus_bench_factory(BENCH)


def test_controller_set_point(plant):
    from mylab.oven import Oven                  # your code
    Oven("127.0.0.1", plant.port, unit=2).set_point(150)
    assert plant.value("controller", "set_point") == 150

Time

Device models run on the real clock. Tests that need a process to move (a controller heating, an inverter going through a day, energy counting) should speed the model up instead of sleeping long:

{"profile": "omron-e5cc", "unit": 2, "model_params": {"time_scale": 100}}

GitHub Actions

With the plugin there is nothing to set up beyond installing the package:

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.13"
      - run: pip install modbus-sim pymodbus pytest -e .
      - run: pytest --modbus-bench tests/bench.yaml

For code that is not tested with pytest — a Node-RED flow, a Go or C# service, an integration test that starts your gateway — run the simulator as a background process and wait until it answers:

      - run: pip install modbus-sim
      - name: Start simulated devices
        run: |
          modbus-sim serve --bench tests/bench.yaml --port 5020 > sim.log 2>&1 &
          for i in $(seq 50); do modbus-sim list >/dev/null 2>&1 && break; sleep 0.2; done
      - run: ./run-integration-tests.sh          # your tests, against 127.0.0.1:5020
      - if: always()
        run: cat sim.log

modbus-sim list asks the control port and fails until the bench is up. In the tests, switch faults with modbus-sim inject <device> <fault> (the control port listens on 127.0.0.1:5021).

Serial / RTU in CI

On Linux runners, --pty gives a virtual serial port without hardware or extra packages:

def test_rtu_master(modbus_bench_factory):
    bench = modbus_bench_factory("eastron-sdm630", pty=True)
    port = bench.path                   # e.g. /dev/pts/3
    ...                                 # open it with your RTU master

Baud rate and parity are not simulated over a pty. On Windows runners use a virtual null-modem pair (com0com) and --serial COMx.

Clone this wiki locally