From 045bb9ea88f9ff80e32daa096c4b9b0cc7132269 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Randy=20D=C3=B6ring?= <30527984+radoering@users.noreply.github.com> Date: Mon, 16 Feb 2026 15:52:46 +0100 Subject: [PATCH] subtests: fix inconcistent handling of non-string messages (#14196) Fixes #14195 --------- Co-authored-by: Bruno Oliveira (cherry picked from commit 8950ca3bc0988647dfc35b02f854347a1435bd36) --- AUTHORS | 1 + changelog/14195.bugfix.rst | 1 + src/_pytest/unittest.py | 6 +++- testing/test_subtests.py | 57 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 64 insertions(+), 1 deletion(-) create mode 100644 changelog/14195.bugfix.rst diff --git a/AUTHORS b/AUTHORS index fa6bc58c52f..e8140292aa4 100644 --- a/AUTHORS +++ b/AUTHORS @@ -382,6 +382,7 @@ Ralf Schmitt Ralph Giles Ram Rachum Ran Benita +Randy Döring Raphael Castaneda Raphael Pierzina Rafal Semik diff --git a/changelog/14195.bugfix.rst b/changelog/14195.bugfix.rst new file mode 100644 index 00000000000..29ae149dd98 --- /dev/null +++ b/changelog/14195.bugfix.rst @@ -0,0 +1 @@ +Fixed an issue where non-string messages passed to `unittest.TestCase.subTest()` were not printed. diff --git a/src/_pytest/unittest.py b/src/_pytest/unittest.py index 23b92724f5d..31be8847821 100644 --- a/src/_pytest/unittest.py +++ b/src/_pytest/unittest.py @@ -409,6 +409,10 @@ def addSubTest( | tuple[type[BaseException], BaseException, TracebackType] | None, ) -> None: + # Importing this private symbol locally in case this symbol is renamed/removed in the future; importing + # it globally would break pytest entirely, importing it locally only will break unittests using `addSubTest`. + from unittest.case import _subtest_msg_sentinel # type: ignore[attr-defined] + exception_info: ExceptionInfo[BaseException] | None match exc_info: case tuple(): @@ -427,7 +431,7 @@ def addSubTest( when="call", _ispytest=True, ) - msg = test._message if isinstance(test._message, str) else None # type: ignore[attr-defined] + msg = None if test._message is _subtest_msg_sentinel else str(test._message) # type: ignore[attr-defined] report = self.ihook.pytest_runtest_makereport(item=self, call=call_info) sub_report = SubtestReport._new( report, diff --git a/testing/test_subtests.py b/testing/test_subtests.py index 6849df53622..06de9f009d8 100644 --- a/testing/test_subtests.py +++ b/testing/test_subtests.py @@ -370,6 +370,36 @@ def test_foo(subtests): ) +def test_msg_not_a_string( + pytester: pytest.Pytester, monkeypatch: pytest.MonkeyPatch +) -> None: + """ + Using a non-string in subtests.test() should still show it in the terminal (#14195). + + Note: this was not a problem originally with the subtests fixture, only with TestCase.subTest; this test + was added for symmetry. + """ + monkeypatch.setenv("COLUMNS", "120") + pytester.makepyfile( + """ + def test_int_msg(subtests): + with subtests.test(42): + assert False, "subtest failure" + + def test_no_msg(subtests): + with subtests.test(): + assert False, "subtest failure" + """ + ) + result = pytester.runpytest() + result.stdout.fnmatch_lines( + [ + "SUBFAILED[[]42[]] test_msg_not_a_string.py::test_int_msg - AssertionError: subtest failure", + "SUBFAILED() test_msg_not_a_string.py::test_no_msg - AssertionError: subtest failure", + ] + ) + + @pytest.mark.parametrize("flag", ["--last-failed", "--stepwise"]) def test_subtests_last_failed_step_wise(pytester: pytest.Pytester, flag: str) -> None: """Check that --last-failed and --step-wise correctly rerun tests with failed subtests.""" @@ -619,6 +649,33 @@ def test_foo(self): "SUBSKIPPED[[]subtest 1[]] [[]1[]] *.py:*: skip subtest 1" ) + def test_msg_not_a_string( + self, pytester: pytest.Pytester, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Using a non-string in TestCase.subTest should still show it in the terminal (#14195).""" + monkeypatch.setenv("COLUMNS", "120") + pytester.makepyfile( + """ + from unittest import TestCase + + class T(TestCase): + def test_int_msg(self): + with self.subTest(42): + assert False, "subtest failure" + + def test_no_msg(self): + with self.subTest(): + assert False, "subtest failure" + """ + ) + result = pytester.runpytest() + result.stdout.fnmatch_lines( + [ + "SUBFAILED[[]42[]] test_msg_not_a_string.py::T::test_int_msg - AssertionError: subtest failure", + "SUBFAILED() test_msg_not_a_string.py::T::test_no_msg - AssertionError: subtest failure", + ] + ) + class TestCapture: def create_file(self, pytester: pytest.Pytester) -> None: