diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index f4ca2eac455..2ef737afc2d 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -1841,7 +1841,12 @@ def _getautousenames(self, node: nodes.Node) -> Iterator[str]: def _getusefixturesnames(self, node: nodes.Item) -> Iterator[str]: """Return the names of usefixtures fixtures applicable to node.""" - for marker_node, mark in node.iter_markers_with_node(name="usefixtures"): + # Reverse order (farthest to closest) is more natural for usefixtures, + # e.g. want a module-level usefixture to be requested before a class one, + # a parent class' before a child's, etc. + for marker_node, mark in reversed( + list(node.iter_markers_with_node(name="usefixtures")) + ): if not mark.args: marker_node.warn( PytestWarning( diff --git a/src/_pytest/nodes.py b/src/_pytest/nodes.py index f0629c2daf7..acbf847565e 100644 --- a/src/_pytest/nodes.py +++ b/src/_pytest/nodes.py @@ -330,6 +330,8 @@ def add_marker(self, marker: str | MarkDecorator, append: bool = True) -> None: def iter_markers(self, name: str | None = None) -> Iterator[Mark]: """Iterate over all markers of the node. + The markers are returned from closest to farthest. + :param name: If given, filter the results by the name attribute. :returns: An iterator of the markers of the node. """ @@ -340,14 +342,25 @@ def iter_markers_with_node( ) -> Iterator[tuple[Node, Mark]]: """Iterate over all markers of the node. + The markers are returned from closest to farthest. + :param name: If given, filter the results by the name attribute. :returns: An iterator of (node, mark) tuples. """ for node in self.iter_parents(): - for mark in node.own_markers: + for mark in node._iter_own_markers_closest_first(): if name is None or getattr(mark, "name", None) == name: yield node, mark + def _iter_own_markers_closest_first(self) -> Iterable[Mark]: + """Yield own markers in closest-first order. + + For most nodes this is just own_markers in order. + Overridden by nodes whose own_markers contain markers from + multiple levels (e.g. Class nodes with MRO-inherited markers). + """ + return self.own_markers + @overload def get_closest_marker(self, name: str) -> Mark | None: ... diff --git a/src/_pytest/python.py b/src/_pytest/python.py index 45ff185184b..4dc0a223ce3 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -751,6 +751,28 @@ def from_parent(cls, parent, *, name, obj=None, **kw) -> Self: # type: ignore[o """The public constructor.""" return super().from_parent(name=name, parent=parent, **kw) + def _iter_own_markers_closest_first(self) -> Iterator[Mark]: + """own_markers stores MRO markers in base-first order + (construction order). For closest-first iteration, reverse at the + MRO class-group level while preserving decorator order within + each class.""" + from _pytest.mark.structures import normalize_mark_list + + # Walk MRO in natural order (closest first: Child, Parent, ...) + # yielding each class's marks in their decorator-stacking order. + mro_mark_ids: set[int] = set() + for cls in self.obj.__mro__: + cls_marks = cls.__dict__.get("pytestmark", []) + if not isinstance(cls_marks, list): + cls_marks = [cls_marks] + for mark in normalize_mark_list(cls_marks): + mro_mark_ids.add(id(mark)) + yield mark + # Yield any dynamically added markers (via add_marker) not from MRO. + for mark in self.own_markers: + if id(mark) not in mro_mark_ids: + yield mark + def newinstance(self): return self.obj() diff --git a/testing/test_mark.py b/testing/test_mark.py index 253cda94503..e66f01de8ba 100644 --- a/testing/test_mark.py +++ b/testing/test_mark.py @@ -656,6 +656,40 @@ def test_has_inherited(self): assert has_inherited_marker.kwargs == {"location": "class"} assert has_own.get_closest_marker("missing") is None + def test_mark_closest_mro(self, pytester: Pytester) -> None: + """Marks should be collected from MRO from nearest to furthest (#14329).""" + pytester.makepyfile( + """ + import pytest + + + @pytest.mark.foo(0) + class TestParent: + def test_only_class(self, request): + assert request.node.get_closest_marker("foo").args[0] == 0 + assert [mark.args[0] for mark in request.node.iter_markers("foo")] == [0] + + @pytest.mark.foo(1) + def test_function_and_class(self, request): + assert request.node.get_closest_marker("foo").args[0] == 1 + assert [mark.args[0] for mark in request.node.iter_markers("foo")] == [1, 0] + + + @pytest.mark.foo(2) + class TestChild(TestParent): + def test_only_class(self, request): + assert request.node.get_closest_marker("foo").args[0] == 2 + assert [mark.args[0] for mark in request.node.iter_markers("foo")] == [2, 0] + + @pytest.mark.foo(3) + def test_function_and_class(self, request): + assert request.node.get_closest_marker("foo").args[0] == 3 + assert [mark.args[0] for mark in request.node.iter_markers("foo")] == [3, 2, 0] + """ + ) + result = pytester.runpytest() + result.assert_outcomes(passed=4) + def test_mark_with_wrong_marker(self, pytester: Pytester) -> None: reprec = pytester.inline_runsource( """ @@ -1133,6 +1167,7 @@ class TestBarClass(BaseTests): def test_addmarker_order(pytester) -> None: session = mock.Mock() session.own_markers = [] + session._iter_own_markers_closest_first.return_value = session.own_markers session.parent = None session.nodeid = "" session.path = pytester.path