Skip to content

Commit 707b80e

Browse files
committed
feat: Add group class for better control of multi dut tests
1 parent 167480f commit 707b80e

6 files changed

Lines changed: 597 additions & 2 deletions

File tree

docs/apis/pytest-embedded.rst

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@
1212
:undoc-members:
1313
:show-inheritance:
1414

15+
.. automodule:: pytest_embedded.group
16+
:members:
17+
:undoc-members:
18+
:show-inheritance:
19+
1520
.. automodule:: pytest_embedded.dut_factory
1621
:members:
1722
:undoc-members:

docs/usages/expecting.rst

Lines changed: 138 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
Expecting Functions
33
#####################
44

5-
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`.
5+
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`.
66

77
All of these functions accept the following keyword arguments:
88

@@ -186,6 +186,143 @@ As with the :func:`~pytest_embedded.dut.Dut.expect` function, the ``pattern`` ar
186186
for _ in range(2):
187187
dut.expect_exact(pattern_list)
188188
189+
***************************
190+
Multi-DUT Synchronization
191+
***************************
192+
193+
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:
194+
195+
- 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.
196+
- The **slowest** device should not delay matching on others more than your chosen global timeout.
197+
198+
:class:`~pytest_embedded.group.DutGroup`
199+
========================================
200+
201+
``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.
202+
203+
.. code:: python
204+
205+
from pytest_embedded import DutGroup
206+
207+
def test_two_boards(dut):
208+
group = DutGroup(dut[0], dut[1])
209+
# or from a list:
210+
group = DutGroup(*dut)
211+
212+
It is also available as ``Dut.DutGroup`` for discoverability.
213+
214+
expect / expect_exact
215+
---------------------
216+
217+
``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:
218+
219+
.. code:: python
220+
221+
# Broadcast -- same pattern to every DUT
222+
group.expect_exact("[READY]", timeout=120)
223+
224+
# Per-DUT patterns -- one per DUT, in constructor order
225+
group.expect_exact("[AP] ready", "[CLIENT] ready", timeout=120)
226+
227+
# Regex -- also supports broadcast and per-DUT forms
228+
results = group.expect(r"IP=(\S+)", timeout=10)
229+
ip0 = results[0].group(1).decode()
230+
ip1 = results[1].group(1).decode()
231+
232+
# Same as :class:`~pytest_embedded.dut.Dut`: a single pattern may use the keyword form
233+
group.expect_exact(pattern="[READY]", timeout=120)
234+
235+
Other methods
236+
-------------
237+
238+
Any other :class:`~pytest_embedded.dut.Dut` method called on the group is forwarded with the **same arguments** to every DUT in parallel:
239+
240+
.. code:: python
241+
242+
group.write(ssid)
243+
244+
For per-DUT arguments on non-expect methods, index into the group:
245+
246+
.. code:: python
247+
248+
group[0].write(ap_config)
249+
group[1].write(client_config)
250+
251+
Container protocol
252+
------------------
253+
254+
``DutGroup`` supports indexing, iteration, and length:
255+
256+
.. code:: python
257+
258+
group[0] # first DUT
259+
group[-1] # last DUT
260+
len(group) # number of DUTs
261+
list(group) # iterate over DUTs
262+
group.duts # underlying tuple (read-only)
263+
264+
Non-callable attributes are returned as a list:
265+
266+
.. code:: python
267+
268+
procs = group.pexpect_proc # [proc_0, proc_1, ...]
269+
270+
Names and clearer errors
271+
------------------------
272+
273+
Pass optional **member** labels and an optional **group** label so logs and failures are easy to read:
274+
275+
.. code:: python
276+
277+
group = DutGroup(*dut, names=("ap", "client"), group_name="wifi_ap")
278+
# group.names -> ("ap", "client"); group.group_name -> "wifi_ap"
279+
280+
If you omit ``names``, members default to ``dut-0``, ``dut-1``, … (aligned with per-DUT log file names when using ``--count``).
281+
282+
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).
283+
284+
Full example
285+
------------
286+
287+
.. code:: python
288+
289+
from pytest_embedded import DutGroup
290+
291+
def test_wifi_ap(dut):
292+
group = DutGroup(*dut)
293+
294+
# Phase 1: wait for both devices to be ready
295+
group.expect_exact("[READY]", timeout=120)
296+
297+
# Phase 2: exchange SSID
298+
group.expect_exact("Send SSID:", timeout=10)
299+
group.write(ap_ssid)
300+
301+
# Phase 3: exchange password
302+
group.expect_exact("Send Password:", timeout=10)
303+
group.write(ap_password)
304+
305+
# Phase 4: verify connection
306+
results = group.expect(r"IP=(\S+)", timeout=30)
307+
for r in results:
308+
assert r.group(1) != b""
309+
310+
Phase synchronization
311+
=====================
312+
313+
``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.
314+
315+
.. code:: python
316+
317+
group = DutGroup(*dut)
318+
group.expect_exact("Init OK", timeout=30)
319+
group.expect_exact("Server started", timeout=10)
320+
group.expect_exact("Connected", timeout=60)
321+
322+
.. note::
323+
324+
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__`.
325+
189326
***********************************************************
190327
:func:`~pytest_embedded.dut.Dut.expect_unity_test_output`
191328
***********************************************************

pytest-embedded/pytest_embedded/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33
from .app import App
44
from .dut import Dut
55
from .dut_factory import DutFactory
6+
from .group import DutGroup, DutGroupMemberError
67

7-
__all__ = ['App', 'Dut', 'DutFactory']
8+
__all__ = ['App', 'Dut', 'DutFactory', 'DutGroup', 'DutGroupMemberError']
89

910
__version__ = '2.7.0'

pytest-embedded/pytest_embedded/dut.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import pexpect
1010

1111
from .app import App
12+
from .group import DutGroup
1213
from .log import MessageQueue, PexpectProcess
1314
from .unity import UNITY_SUMMARY_LINE_REGEX, TestSuite
1415
from .utils import Meta, _InjectMixinCls, remove_asci_color_code, to_bytes, to_list
@@ -49,6 +50,8 @@ def __init__(
4950
# junit related
5051
self.testsuite = TestSuite(self.test_case_name)
5152

53+
54+
5255
@property
5356
def logdir(self):
5457
return self._meta.logdir
@@ -232,3 +235,7 @@ def run_all_single_board_cases(
232235
requires enable service ``idf``
233236
"""
234237
pass
238+
239+
240+
#: Alias for :class:`~pytest_embedded.group.DutGroup` for discoverability.
241+
Dut.DutGroup = DutGroup

0 commit comments

Comments
 (0)