Skip to content

Commit f08f4ca

Browse files
committed
Fixed color inconsistency in verbose mode where test status showed green instead of yellow for passed tests with warnings.
1 parent c97a401 commit f08f4ca

File tree

7 files changed

+304
-5
lines changed

7 files changed

+304
-5
lines changed

AUTHORS

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -463,6 +463,7 @@ Tomer Keren
463463
Tony Narlock
464464
Tor Colvin
465465
Trevor Bekolay
466+
Trey Shaffer
466467
Tushar Sadhwani
467468
Tyler Goodlet
468469
Tyler Smart

changelog/13201.bugfix.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Fixed color inconsistency in verbose mode where test status showed green instead of yellow for passed tests with warnings.

codecov.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,7 @@ coverage:
99
patch:
1010
default:
1111
target: 100% # require patches to be 100%
12+
paths:
13+
- "src/" # only check source files, not test files
1214
project: false
1315
comment: false

src/_pytest/terminal.py

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -635,10 +635,19 @@ def pytest_runtest_logreport(self, report: TestReport) -> None:
635635
return
636636
if markup is None:
637637
was_xfail = hasattr(report, "wasxfail")
638-
if rep.passed and not was_xfail:
639-
markup = {"green": True}
640-
elif rep.passed and was_xfail:
641-
markup = {"yellow": True}
638+
# Check if report has warnings via user_properties
639+
from _pytest.warnings import HAS_WARNINGS_KEY
640+
641+
has_warnings = any(
642+
name == HAS_WARNINGS_KEY and value is True
643+
for name, value in getattr(report, "user_properties", [])
644+
)
645+
646+
if rep.passed:
647+
if was_xfail or has_warnings:
648+
markup = {"yellow": True}
649+
else:
650+
markup = {"green": True}
642651
elif rep.failed:
643652
markup = {"red": True}
644653
elif rep.skipped:

src/_pytest/warnings.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,31 @@
66
from contextlib import ExitStack
77
import sys
88
from typing import Literal
9+
from typing import TYPE_CHECKING
910
import warnings
1011

1112
from _pytest.config import apply_warning_filters
1213
from _pytest.config import Config
1314
from _pytest.config import parse_warning_filter
1415
from _pytest.main import Session
1516
from _pytest.nodes import Item
17+
from _pytest.stash import StashKey
1618
from _pytest.terminal import TerminalReporter
1719
from _pytest.tracemalloc import tracemalloc_message
1820
import pytest
1921

2022

23+
if TYPE_CHECKING:
24+
from _pytest.reports import TestReport
25+
from _pytest.runner import CallInfo
26+
27+
# StashKey for storing warning log on items
28+
warning_captured_log_key = StashKey[list[warnings.WarningMessage]]()
29+
30+
# Key name for storing warning flag in report.user_properties
31+
HAS_WARNINGS_KEY = "has_warnings"
32+
33+
2134
@contextmanager
2235
def catch_warnings_for_item(
2336
config: Config,
@@ -51,6 +64,9 @@ def catch_warnings_for_item(
5164
for mark in item.iter_markers(name="filterwarnings"):
5265
for arg in mark.args:
5366
warnings.filterwarnings(*parse_warning_filter(arg, escape=False))
67+
# Store the warning log on the item so it can be accessed during reporting
68+
if record and log is not None:
69+
item.stash[warning_captured_log_key] = log
5470

5571
try:
5672
yield
@@ -89,6 +105,21 @@ def pytest_runtest_protocol(item: Item) -> Generator[None, object, object]:
89105
return (yield)
90106

91107

108+
@pytest.hookimpl(hookwrapper=True)
109+
def pytest_runtest_makereport(
110+
item: Item, call: CallInfo[None]
111+
) -> Generator[None, TestReport, None]:
112+
"""Attach warning information to test reports for terminal coloring."""
113+
outcome = yield
114+
report: TestReport = outcome.get_result()
115+
116+
# Only mark warnings during the call phase, not setup/teardown
117+
if report.passed and report.when == "call":
118+
warning_log = item.stash.get(warning_captured_log_key, None)
119+
if warning_log is not None and len(warning_log) > 0:
120+
report.user_properties.append((HAS_WARNINGS_KEY, True))
121+
122+
92123
@pytest.hookimpl(wrapper=True, tryfirst=True)
93124
def pytest_collection(session: Session) -> Generator[None, object, object]:
94125
config = session.config

testing/test_terminal.py

Lines changed: 174 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2191,7 +2191,7 @@ def test_foobar(i): raise ValueError()
21912191
[
21922192
r"test_axfail.py {yellow}x{reset}{green} \s+ \[ 4%\]{reset}",
21932193
r"test_bar.py ({green}\.{reset}){{10}}{green} \s+ \[ 52%\]{reset}",
2194-
r"test_foo.py ({green}\.{reset}){{5}}{yellow} \s+ \[ 76%\]{reset}",
2194+
r"test_foo.py ({yellow}\.{reset}){{5}}{yellow} \s+ \[ 76%\]{reset}",
21952195
r"test_foobar.py ({red}F{reset}){{5}}{red} \s+ \[100%\]{reset}",
21962196
]
21972197
)
@@ -2208,6 +2208,179 @@ def test_foobar(i): raise ValueError()
22082208
)
22092209
)
22102210

2211+
def test_verbose_colored_warnings(
2212+
self, pytester: Pytester, monkeypatch, color_mapping
2213+
) -> None:
2214+
"""Test that verbose mode shows yellow PASSED for tests with warnings."""
2215+
monkeypatch.setenv("PY_COLORS", "1")
2216+
pytester.makepyfile(
2217+
test_warning="""
2218+
import warnings
2219+
def test_with_warning():
2220+
warnings.warn("test warning", DeprecationWarning)
2221+
2222+
def test_without_warning():
2223+
pass
2224+
"""
2225+
)
2226+
result = pytester.runpytest("-v")
2227+
result.stdout.re_match_lines(
2228+
color_mapping.format_for_rematch(
2229+
[
2230+
r"test_warning.py::test_with_warning {yellow}PASSED{reset}{green} \s+ \[ 50%\]{reset}",
2231+
r"test_warning.py::test_without_warning {green}PASSED{reset}{yellow} \s+ \[100%\]{reset}",
2232+
]
2233+
)
2234+
)
2235+
2236+
def test_verbose_colored_warnings_xdist(
2237+
self, pytester: Pytester, monkeypatch, color_mapping
2238+
) -> None:
2239+
"""Test that warning coloring works correctly with pytest-xdist parallel execution."""
2240+
pytest.importorskip("xdist")
2241+
monkeypatch.delenv("PYTEST_DISABLE_PLUGIN_AUTOLOAD", raising=False)
2242+
monkeypatch.setenv("PY_COLORS", "1")
2243+
pytester.makepyfile(
2244+
test_warning_xdist="""
2245+
import warnings
2246+
def test_with_warning_1():
2247+
warnings.warn("warning in test 1", DeprecationWarning)
2248+
pass
2249+
2250+
def test_with_warning_2():
2251+
warnings.warn("warning in test 2", DeprecationWarning)
2252+
pass
2253+
2254+
def test_without_warning():
2255+
pass
2256+
"""
2257+
)
2258+
2259+
output = pytester.runpytest("-v", "-n2")
2260+
# xdist outputs in random order, and uses format:
2261+
# [gw#][cyan] [%] [reset][color]STATUS[reset] test_name
2262+
# Note: \x1b[36m is cyan, which isn't in color_mapping
2263+
output.stdout.re_match_lines_random(
2264+
color_mapping.format_for_rematch(
2265+
[
2266+
r"\[gw\d\]\x1b\[36m \[\s*\d+%\] {reset}{yellow}PASSED{reset} "
2267+
r"test_warning_xdist.py::test_with_warning_1",
2268+
r"\[gw\d\]\x1b\[36m \[\s*\d+%\] {reset}{yellow}PASSED{reset} "
2269+
r"test_warning_xdist.py::test_with_warning_2",
2270+
r"\[gw\d\]\x1b\[36m \[\s*\d+%\] {reset}{green}PASSED{reset} "
2271+
r"test_warning_xdist.py::test_without_warning",
2272+
]
2273+
)
2274+
)
2275+
2276+
def test_failed_test_with_warnings_shows_red(
2277+
self, pytester: Pytester, monkeypatch, color_mapping
2278+
) -> None:
2279+
"""Test that failed tests with warnings show RED, not yellow."""
2280+
monkeypatch.setenv("PY_COLORS", "1")
2281+
pytester.makepyfile(
2282+
test_failed_warning="""
2283+
import warnings
2284+
def test_fails_with_warning():
2285+
warnings.warn("This will fail", DeprecationWarning)
2286+
assert False, "Expected failure"
2287+
2288+
def test_passes_with_warning():
2289+
warnings.warn("This passes", DeprecationWarning)
2290+
assert True
2291+
"""
2292+
)
2293+
result = pytester.runpytest("-v")
2294+
# Failed test should be RED even though it has warnings
2295+
result.stdout.re_match_lines(
2296+
color_mapping.format_for_rematch(
2297+
[
2298+
r"test_failed_warning.py::test_fails_with_warning {red}FAILED{reset}",
2299+
r"test_failed_warning.py::test_passes_with_warning {yellow}PASSED{reset}",
2300+
]
2301+
)
2302+
)
2303+
2304+
def test_non_verbose_mode_with_warnings(
2305+
self, pytester: Pytester, monkeypatch, color_mapping
2306+
) -> None:
2307+
"""Test that non-verbose mode (dot output) works correctly with warnings."""
2308+
monkeypatch.setenv("PY_COLORS", "1")
2309+
pytester.makepyfile(
2310+
test_dots="""
2311+
import warnings
2312+
def test_with_warning():
2313+
warnings.warn("warning", DeprecationWarning)
2314+
pass
2315+
2316+
def test_without_warning():
2317+
pass
2318+
"""
2319+
)
2320+
result = pytester.runpytest() # No -v flag
2321+
# Should show dots, yellow for warning, green for clean pass
2322+
result.stdout.re_match_lines(
2323+
color_mapping.format_for_rematch(
2324+
[
2325+
r"test_dots.py {yellow}\.{reset}{green}\.{reset}",
2326+
]
2327+
)
2328+
)
2329+
2330+
def test_multiple_warnings_single_test(
2331+
self, pytester: Pytester, monkeypatch, color_mapping
2332+
) -> None:
2333+
"""Test that tests with multiple warnings still show yellow."""
2334+
monkeypatch.setenv("PY_COLORS", "1")
2335+
pytester.makepyfile(
2336+
test_multi="""
2337+
import warnings
2338+
def test_multiple_warnings():
2339+
warnings.warn("warning 1", DeprecationWarning)
2340+
warnings.warn("warning 2", DeprecationWarning)
2341+
warnings.warn("warning 3", DeprecationWarning)
2342+
pass
2343+
"""
2344+
)
2345+
result = pytester.runpytest("-v")
2346+
result.stdout.re_match_lines(
2347+
color_mapping.format_for_rematch(
2348+
[
2349+
r"test_multi.py::test_multiple_warnings {yellow}PASSED{reset}",
2350+
]
2351+
)
2352+
)
2353+
2354+
def test_warning_with_filterwarnings_mark(
2355+
self, pytester: Pytester, monkeypatch, color_mapping
2356+
) -> None:
2357+
"""Test that warnings with filterwarnings mark still show yellow."""
2358+
monkeypatch.setenv("PY_COLORS", "1")
2359+
pytester.makepyfile(
2360+
test_marked="""
2361+
import warnings
2362+
import pytest
2363+
2364+
@pytest.mark.filterwarnings("ignore::DeprecationWarning")
2365+
def test_with_ignored_warning():
2366+
warnings.warn("ignored warning", DeprecationWarning)
2367+
pass
2368+
2369+
def test_with_visible_warning():
2370+
warnings.warn("visible warning", DeprecationWarning)
2371+
pass
2372+
"""
2373+
)
2374+
result = pytester.runpytest("-v")
2375+
result.stdout.re_match_lines(
2376+
color_mapping.format_for_rematch(
2377+
[
2378+
r"test_marked.py::test_with_ignored_warning {green}PASSED{reset}",
2379+
r"test_marked.py::test_with_visible_warning {yellow}PASSED{reset}",
2380+
]
2381+
)
2382+
)
2383+
22112384
def test_count(self, many_tests_files, pytester: Pytester) -> None:
22122385
pytester.makeini(
22132386
"""

testing/test_warnings.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -888,3 +888,85 @@ def test_resource_warning(tmp_path):
888888
else []
889889
)
890890
result.stdout.fnmatch_lines([*expected_extra, "*1 passed*"])
891+
892+
893+
def test_warning_captured_in_user_properties(pytester: Pytester) -> None:
894+
"""Test that warnings are captured in report.user_properties for terminal coloring."""
895+
pytester.makepyfile(
896+
"""
897+
import warnings
898+
def test_with_warning():
899+
warnings.warn("test warning", DeprecationWarning)
900+
assert True
901+
902+
def test_without_warning():
903+
assert True
904+
"""
905+
)
906+
# Use inline_run to get access to reports
907+
reprec = pytester.inline_run()
908+
reports = reprec.getreports("pytest_runtest_logreport")
909+
910+
# Find the call phase reports
911+
call_reports = [r for r in reports if r.when == "call"]
912+
assert len(call_reports) == 2
913+
914+
# First test should have warnings in user_properties
915+
test_with_warning_report = call_reports[0]
916+
assert test_with_warning_report.nodeid.endswith("test_with_warning")
917+
assert test_with_warning_report.passed
918+
919+
# Check that HAS_WARNINGS_KEY is in user_properties
920+
from _pytest.warnings import HAS_WARNINGS_KEY
921+
922+
has_warnings = any(
923+
name == HAS_WARNINGS_KEY and value is True
924+
for name, value in test_with_warning_report.user_properties
925+
)
926+
assert has_warnings, (
927+
"Expected HAS_WARNINGS_KEY in user_properties for test with warning"
928+
)
929+
930+
# Second test should NOT have warnings in user_properties
931+
test_without_warning_report = call_reports[1]
932+
assert test_without_warning_report.nodeid.endswith("test_without_warning")
933+
assert test_without_warning_report.passed
934+
935+
has_warnings = any(
936+
name == HAS_WARNINGS_KEY and value is True
937+
for name, value in test_without_warning_report.user_properties
938+
)
939+
assert not has_warnings, "Did not expect HAS_WARNINGS_KEY for test without warning"
940+
941+
942+
def test_warning_stash_storage(pytester: Pytester) -> None:
943+
"""Test that warning log is stored in item.stash during test execution."""
944+
pytester.makepyfile(
945+
"""
946+
import warnings
947+
948+
def test_with_warning():
949+
warnings.warn("test warning", DeprecationWarning)
950+
pass
951+
"""
952+
)
953+
954+
# Use a plugin to capture the item and check the stash
955+
captured_item = []
956+
957+
class StashChecker:
958+
def pytest_runtest_call(self, item):
959+
captured_item.append(item)
960+
961+
pytester.inline_run(plugins=[StashChecker()])
962+
963+
assert len(captured_item) == 1
964+
item = captured_item[0]
965+
966+
# Check that the warning log was stored in the stash
967+
from _pytest.warnings import warning_captured_log_key
968+
969+
warning_log = item.stash.get(warning_captured_log_key, None)
970+
assert warning_log is not None, "Expected warning log to be stored in item.stash"
971+
assert len(warning_log) > 0, "Expected at least one warning in the log"
972+
assert "test warning" in str(warning_log[0].message)

0 commit comments

Comments
 (0)