Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/apis/pytest-embedded.rst
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@
:undoc-members:
:show-inheritance:

.. automodule:: pytest_embedded.group
:members:
:undoc-members:
:show-inheritance:

.. automodule:: pytest_embedded.dut_factory
:members:
:undoc-members:
Expand Down
11 changes: 8 additions & 3 deletions docs/concepts/key-concepts.rst
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,9 @@ Here are a few examples of how to enable this feature. For detailed information,
Enable multi DUTs by specifying ``--count``
===========================================

When multi-DUT mode is enabled, all fixtures become a tuple of instances. Each instance in the tuple is independent. For parametrization, each configuration uses ``|`` as a separator for each instance's values.
When multi-DUT mode is enabled, most fixtures become a tuple of instances. Each instance in the tuple is independent. For parametrization, each configuration uses ``|`` as a separator for each instance's values.

The ``dut`` fixture is special: instead of a plain tuple, it becomes a :class:`~pytest_embedded.group.DutGroup` — a transparent proxy that supports indexing, iteration, and **parallel method calls** across all DUTs (see :ref:`multi-dut-synchronization` for details).

For example, running shell command:

Expand All @@ -99,9 +101,9 @@ For example, running shell command:
--count 2 \
--app-path <master_bin>|<slave_bin>

enables two DUTs with the ``serial`` service. The ``app`` fixture becomes a tuple of two ``App`` instances, and ``dut`` becomes a tuple of two ``Dut`` instances.
enables two DUTs with the ``serial`` service. The ``app`` fixture becomes a tuple of two ``App`` instances, and ``dut`` becomes a :class:`~pytest_embedded.group.DutGroup` containing two ``Dut`` instances.

You can test with:
You can access individual DUTs by index, or call methods directly on the group to run them in parallel:

.. code:: python

Expand All @@ -112,6 +114,9 @@ You can test with:
master.expect("sent")
slave.expect("received")

# Or use the group directly for parallel operations:
dut.expect_exact("[DONE]", timeout=10)

Specify once when applying to all DUTs
======================================

Expand Down
151 changes: 150 additions & 1 deletion docs/usages/expecting.rst
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
Expecting Functions
#####################

In testing, most of the work involves expecting a certain string or pattern and then making assertions. This is supported by the functions :func:`~pytest_embedded.dut.Dut.expect`, :func:`~pytest_embedded.dut.Dut.expect_exact`, and :func:`~pytest_embedded.dut.Dut.expect_unity_test_output`.
In testing, most of the work involves expecting a certain string or pattern and then making assertions. This is supported by the functions :func:`~pytest_embedded.dut.Dut.expect`, :func:`~pytest_embedded.dut.Dut.expect_exact`, :class:`~pytest_embedded.group.DutGroup` (multi-DUT synchronization), and :func:`~pytest_embedded.dut.Dut.expect_unity_test_output`.

All of these functions accept the following keyword arguments:

Expand Down Expand Up @@ -186,6 +186,155 @@ As with the :func:`~pytest_embedded.dut.Dut.expect` function, the ``pattern`` ar
for _ in range(2):
dut.expect_exact(pattern_list)

.. _multi-dut-synchronization:

***************************
Multi-DUT Synchronization
***************************

When you use ``--count N`` (or equivalent), each board has its own serial stream and its own :class:`~pytest_embedded.dut.Dut` instance. Waiting for readiness on each device with separate ``expect`` calls works, but:

- Sequential calls use **per-call** timeouts, so two ``expect_exact(..., timeout=120)`` lines can behave like a much larger wall-clock budget than a single 120s deadline.
- The **slowest** device should not delay matching on others more than your chosen global timeout.

:class:`~pytest_embedded.group.DutGroup`
========================================

``DutGroup`` is a transparent proxy: **every method** available on a single :class:`~pytest_embedded.dut.Dut` can be called on the group. The call runs on all members **in parallel** and returns a list of per-DUT results.

When you use ``--count N`` with N > 1, the ``dut`` fixture is **already a** ``DutGroup`` — you can call group methods on it directly:

.. code:: python

def test_two_boards(dut):
# dut is a DutGroup -- use it directly
dut.expect_exact("[READY]", timeout=120)

# Individual DUTs are accessible by index
dut[0].write("hello from master")
dut[1].write("hello from slave")

You can also construct a ``DutGroup`` manually from individual DUTs if you need a subset or custom naming:

.. code:: python

from pytest_embedded import DutGroup

def test_two_boards(dut):
group = DutGroup(dut[0], dut[1], names=("ap", "client"))

It is also available as ``Dut.DutGroup`` for discoverability.

expect / expect_exact
---------------------

``expect`` and ``expect_exact`` support both **broadcast** (one pattern for all DUTs) and **per-DUT** patterns (N patterns for N DUTs), all running in parallel:

.. code:: python

# Broadcast -- same pattern to every DUT
group.expect_exact("[READY]", timeout=120)

# Per-DUT patterns -- one per DUT, in constructor order
group.expect_exact("[AP] ready", "[CLIENT] ready", timeout=120)

# Regex -- also supports broadcast and per-DUT forms
results = group.expect(r"IP=(\S+)", timeout=10)
ip0 = results[0].group(1).decode()
ip1 = results[1].group(1).decode()

# Same as :class:`~pytest_embedded.dut.Dut`: a single pattern may use the keyword form
group.expect_exact(pattern="[READY]", timeout=120)

Other methods
-------------

Any other :class:`~pytest_embedded.dut.Dut` method called on the group is forwarded with the **same arguments** to every DUT in parallel:

.. code:: python

group.write(ssid)

For per-DUT arguments on non-expect methods, index into the group:

.. code:: python

group[0].write(ap_config)
group[1].write(client_config)

Container protocol
------------------

``DutGroup`` supports indexing, iteration, and length:

.. code:: python

group[0] # first DUT
group[-1] # last DUT
len(group) # number of DUTs
list(group) # iterate over DUTs
group.duts # underlying tuple (read-only)

Non-callable attributes are returned as a list:

.. code:: python

procs = group.pexpect_proc # [proc_0, proc_1, ...]

Names and clearer errors
------------------------

Pass optional **member** labels and an optional **group** label so logs and failures are easy to read:

.. code:: python

group = DutGroup(*dut, names=("ap", "client"), group_name="wifi_ap")
# group.names -> ("ap", "client"); group.group_name -> "wifi_ap"

If you omit ``names``, members default to ``dut-0``, ``dut-1``, … (aligned with per-DUT log file names when using ``--count``).

When any parallel call fails on one DUT, pytest-embedded raises :exc:`pytest_embedded.group.DutGroupMemberError`. Its message and attributes identify the member (``member_name``, ``member_index``) and group (``group_name``), and the original error (for example :exc:`pexpect.TIMEOUT`) is chained as :attr:`__cause__`. A structured line is also written to the Python logger at ERROR (including the underlying exception context).

Full example
------------

.. code:: python

def test_wifi_ap(dut):
# dut is already a DutGroup in multi-DUT mode

# Phase 1: wait for both devices to be ready
dut.expect_exact("[READY]", timeout=120)

# Phase 2: exchange SSID
dut.expect_exact("Send SSID:", timeout=10)
dut.write(ap_ssid)

# Phase 3: exchange password
dut.expect_exact("Send Password:", timeout=10)
dut.write(ap_password)

# Phase 4: verify connection
results = dut.expect(r"IP=(\S+)", timeout=30)
for r in results:
assert r.group(1) != b""

Phase synchronization
=====================

``DutGroup`` methods can be called **multiple times** in one test to synchronize phases. Each call blocks until every DUT has matched before continuing. After a successful match, those substrings are consumed from each DUT's buffer; emit new output for the next phase.

.. code:: python

group = DutGroup(*dut)
group.expect_exact("Init OK", timeout=30)
group.expect_exact("Server started", timeout=10)
group.expect_exact("Connected", timeout=60)

.. note::

If one DUT fails, pending work is cancelled where possible; expects that have already started may still run until they match or time out, because pexpect cannot always be interrupted from another thread. The failure is reported as :exc:`~pytest_embedded.group.DutGroupMemberError` with the underlying error as :attr:`~BaseException.__cause__`.

***********************************************************
:func:`~pytest_embedded.dut.Dut.expect_unity_test_output`
***********************************************************
Expand Down
3 changes: 2 additions & 1 deletion pytest-embedded/pytest_embedded/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
from .app import App
from .dut import Dut
from .dut_factory import DutFactory
from .group import DutGroup, DutGroupMemberError

__all__ = ['App', 'Dut', 'DutFactory']
__all__ = ['App', 'Dut', 'DutFactory', 'DutGroup', 'DutGroupMemberError']

__version__ = '2.7.0'
5 changes: 5 additions & 0 deletions pytest-embedded/pytest_embedded/dut.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import pexpect

from .app import App
from .group import DutGroup
from .log import MessageQueue, PexpectProcess
from .unity import UNITY_SUMMARY_LINE_REGEX, TestSuite
from .utils import Meta, _InjectMixinCls, remove_asci_color_code, to_bytes, to_list
Expand Down Expand Up @@ -232,3 +233,7 @@ def run_all_single_board_cases(
requires enable service ``idf``
"""
pass


#: Alias for :class:`~pytest_embedded.group.DutGroup` for discoverability.
Dut.DutGroup = DutGroup
2 changes: 1 addition & 1 deletion pytest-embedded/pytest_embedded/dut_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -551,7 +551,7 @@ def dut_gn(
serial: t.Union['Serial', 'LinuxSerial'] | None,
qemu: t.Optional['Qemu'],
wokwi: t.Optional['Wokwi'],
) -> Dut | list[Dut]:
) -> Dut:
global DUT_GLOBAL_INDEX
DUT_GLOBAL_INDEX += 1

Expand Down
Loading
Loading