Skip to content

Commit 1584ced

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 1584ced

File tree

7 files changed

+574
-5
lines changed

7 files changed

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

0 commit comments

Comments
 (0)