Repository: pytest-dev/pytest-xdist

Issue:
Combine multiple xdist_group into one
I made a modification in the hook pytest_collection_modifyitems to allow for multiple loadgroups to be combined into one. This is very useful when used with pytest.param and passing marks.

My use case was making ui tests with selenium automatically expand to include 3 different browsers, while maintaining the other mark of the specific test cases, allowing all of them to run in parallel.

The fixture was something like this:

```
@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(...):
    ...
```

I have a PR to address this, should it be accepted. Tests seem to be passing.

Hints from issue comments:
@nicoddemus: Hi @ygorpontelo,

Thanks for writing.

To be clear, using the example above, this test:

```python
@pytest.mark.xdist_group("group1")
def test1(setup_container):
    ...
```

will generate 3 tests due to `setup_container` parametrization, with 3 new groups `group1-chrome`, `group1-firefox`, and `group1-edge`?@ygorpontelo: That's correct.

Solution patch:
diff --git a/changelog/1200.feature b/changelog/1200.feature
new file mode 100644
index 00000000..76297589
--- /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 b62f787a..5c629700 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 0a303066..6649a0e3 100644
--- a/src/xdist/remote.py
+++ b/src/xdist/remote.py
@@ -241,15 +241,17 @@ def pytest_collection_modifyitems(
         # 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 3ef10cc9..ea3d4163 100644
--- a/testing/acceptance_test.py
+++ b/testing/acceptance_test.py
@@ -1481,6 +1481,46 @@ def test_2():
 
         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 = """