Repository: pytest-dev/pytest-xdist
Pull request #1201 (2025-05-06 18:13:45)

Join multiple xdist_group
Adressing #1200 

ygorpontelo: Appreciate the feedback @nicoddemus! Anything else we should update?
ygorpontelo: @nicoddemus just need to merge now. 

Patch:
diff --git a/changelog/1200.feature b/changelog/1200.feature
new file mode 100644
index 0000000..7629758
--- /dev/null
+++ b/changelog/1200.feature
@@ -0,0 +1,3 @@
+Now multiple ``xdist_group`` markers are considered when assigning tests to groups (order does not matter).
+
+Previously, only the last marker would assign a test to a group, but now if a test has multiple ``xdist_group`` marks applied (for example via parametrization or via fixtures), they are merged to make a new group.
diff --git a/docs/distribution.rst b/docs/distribution.rst
index b62f787..5c62970 100644
--- a/docs/distribution.rst
+++ b/docs/distribution.rst
@@ -68,7 +68,10 @@ The test distribution algorithm is configured with the ``--dist`` command-line o
 
 * ``--dist loadgroup``: Tests are grouped by the ``xdist_group`` mark. Groups are
   distributed to available workers as whole units. This guarantees that all
-  tests with same ``xdist_group`` name run in the same worker.
+  tests with same ``xdist_group`` name run in the same worker. If a test has
+  multiple groups, they will be joined together into a new group,
+  the order of the marks doesn't matter. This works along with marks from fixtures
+  and from the pytestmark global variable.
 
   .. code-block:: python
 
@@ -83,6 +86,36 @@ The test distribution algorithm is configured with the ``--dist`` command-line o
               pass
 
   This will make sure ``test1`` and ``TestA::test2`` will run in the same worker.
+
+  .. code-block:: python
+
+      @pytest.fixture(
+          scope="session",
+          params=[
+              pytest.param(
+                  "chrome",
+                  marks=pytest.mark.xdist_group("chrome"),
+              ),
+              pytest.param(
+                  "firefox",
+                  marks=pytest.mark.xdist_group("firefox"),
+              ),
+              pytest.param(
+                  "edge",
+                  marks=pytest.mark.xdist_group("edge"),
+              ),
+          ],
+      )
+      def setup_container():
+          pass
+
+
+      @pytest.mark.xdist_group(name="data-store")
+      def test_data_store(setup_container):
+          ...
+
+  This will generate 3 new groups: ``chrome_data-store``, ``data-store_firefox`` and ``data-store_edge`` (the markers are lexically sorted before being merged together).
+
   Tests without the ``xdist_group`` mark are distributed normally as in the ``--dist=load`` mode.
 
 * ``--dist worksteal``: Initially, tests are distributed evenly among all
diff --git a/src/xdist/remote.py b/src/xdist/remote.py
index 0a30306..6649a0e 100644
--- a/src/xdist/remote.py
+++ b/src/xdist/remote.py
@@ -241,15 +241,17 @@ class WorkerInteractor:
         # add the group name to nodeid as suffix if --dist=loadgroup
         if config.getvalue("loadgroup"):
             for item in items:
-                mark = item.get_closest_marker("xdist_group")
-                if not mark:
+                gnames: set[str] = set()
+                for mark in item.iter_markers("xdist_group"):
+                    name = (
+                        mark.args[0]
+                        if len(mark.args) > 0
+                        else mark.kwargs.get("name", "default")
+                    )
+                    gnames.add(name)
+                if not gnames:
                     continue
-                gname = (
-                    mark.args[0]
-                    if len(mark.args) > 0
-                    else mark.kwargs.get("name", "default")
-                )
-                item._nodeid = f"{item.nodeid}@{gname}"
+                item._nodeid = f"{item.nodeid}@{'_'.join(sorted(gnames))}"
 
     @pytest.hookimpl
     def pytest_collection_finish(self, session: pytest.Session) -> None:


Test patch:
diff --git a/testing/acceptance_test.py b/testing/acceptance_test.py
index 3ef10cc..ea3d416 100644
--- a/testing/acceptance_test.py
+++ b/testing/acceptance_test.py
@@ -1481,6 +1481,46 @@ class TestGroupScope:
 
         assert a_1.keys() == b_1.keys() and a_2.keys() == b_2.keys()
 
+    def test_multiple_group_marks(self, pytester: pytest.Pytester) -> None:
+        test_file = """
+            import pytest
+            @pytest.mark.xdist_group(name="group1")
+            @pytest.mark.xdist_group(name="group2")
+            def test_1():
+                pass
+        """
+        pytester.makepyfile(test_a=test_file, test_b=test_file)
+        result = pytester.runpytest("-n2", "--dist=loadgroup", "-v")
+        res = parse_tests_and_workers_from_output(result.outlines)
+        assert len(res) == 2
+        # get test names
+        a_1 = next(t[2] for t in res if "test_a.py::test_1" in t[2])
+        b_1 = next(t[2] for t in res if "test_b.py::test_1" in t[2])
+        # check groups
+        assert a_1.split("@")[1] == b_1.split("@")[1] == "group1_group2"
+
+    def test_multiple_group_order(self, pytester: pytest.Pytester) -> None:
+        test_file = """
+            import pytest
+            @pytest.mark.xdist_group(name="b")
+            @pytest.mark.xdist_group(name="d")
+            @pytest.mark.xdist_group(name="c")
+            @pytest.mark.xdist_group(name="c2")
+            @pytest.mark.xdist_group(name="a")
+            @pytest.mark.xdist_group(name="aa")
+            def test_1():
+                pass
+        """
+        pytester.makepyfile(test_a=test_file, test_b=test_file)
+        result = pytester.runpytest("-n2", "--dist=loadgroup", "-v")
+        res = parse_tests_and_workers_from_output(result.outlines)
+        assert len(res) == 2
+        # get test names
+        a_1 = next(t[2] for t in res if "test_a.py::test_1" in t[2])
+        b_1 = next(t[2] for t in res if "test_b.py::test_1" in t[2])
+        # check groups, order should be sorted
+        assert a_1.split("@")[1] == b_1.split("@")[1] == "a_aa_b_c_c2_d"
+
 
 class TestLocking:
     _test_content = """