Skip to content

Commit 9046569

Browse files
authored
Add --max-warnings option to fail test runs (#14372)
Allow users to set a maximum number of allowed warnings via --max-warnings CLI option or max_warnings config option. When the warning count exceeds the threshold and all tests pass, pytest exits with a new WARNINGS_ERROR exit code (6). This supports gradually ratcheting down warnings in a codebase without converting them all to errors. Closes #14371 Signed-off-by: Mike Fiedler <miketheman@gmail.com>
1 parent 2f15694 commit 9046569

File tree

9 files changed

+273
-1
lines changed

9 files changed

+273
-1
lines changed

AUTHORS

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,7 @@ Michał Zięba
325325
Mickey Pashov
326326
Mihai Capotă
327327
Mihail Milushev
328+
Mike Fiedler (miketheman)
328329
Mike Hoyle (hoylemd)
329330
Mike Lundy
330331
Milan Lesnek

changelog/14371.feature.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Added :option:`--max-warnings` command-line option and :confval:`max_warnings` configuration option to fail the test run when the number of warnings exceeds a given threshold -- by :user:`miketheman`.

doc/en/how-to/capture-warnings.rst

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,46 @@ decorator or to all tests in a module by setting the :globalvar:`pytestmark` var
204204

205205
.. _`pytest-warnings`: https://github.com/fschulze/pytest-warnings
206206

207+
Setting a maximum number of warnings
208+
-------------------------------------
209+
210+
.. versionadded:: 9.1
211+
212+
You can use the :option:`--max-warnings` command-line option to fail the test run
213+
if the total number of warnings exceeds a given threshold:
214+
215+
.. code-block:: bash
216+
217+
pytest --max-warnings=10
218+
219+
If all tests pass but the number of warnings exceeds the threshold, pytest will exit with code ``6``
220+
(:class:`~pytest.ExitCode` ``MAX_WARNINGS_ERROR``). This is useful for gradually
221+
ratcheting down warnings in a codebase.
222+
223+
Note that :confval:`filtered warnings <filterwarnings>` do not count toward this maximum total.
224+
225+
The threshold can also be set in the configuration file using :confval:`max_warnings`:
226+
227+
.. tab:: toml
228+
229+
.. code-block:: toml
230+
231+
[pytest]
232+
max_warnings = 10
233+
234+
.. tab:: ini
235+
236+
.. code-block:: ini
237+
238+
[pytest]
239+
max_warnings = 10
240+
241+
.. note::
242+
243+
If tests fail, the exit code will be ``1`` (:class:`~pytest.ExitCode` ``TESTS_FAILED``)
244+
regardless of the warning count. ``MAX_WARNINGS_ERROR`` is only reported when all tests pass
245+
but the warning threshold is exceeded.
246+
207247
Disabling warnings summary
208248
--------------------------
209249

doc/en/reference/exit-codes.rst

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,15 @@
33
Exit codes
44
========================================================
55

6-
Running ``pytest`` can result in six different exit codes:
6+
Running ``pytest`` can result in seven different exit codes:
77

88
:Exit code 0: All tests were collected and passed successfully
99
:Exit code 1: Tests were collected and run but some of the tests failed
1010
:Exit code 2: Test execution was interrupted by the user
1111
:Exit code 3: Internal error happened while executing tests
1212
:Exit code 4: pytest command line usage error
1313
:Exit code 5: No tests were collected
14+
:Exit code 6: Maximum number of warnings exceeded (see :option:`--max-warnings`)
1415

1516
They are represented by the :class:`pytest.ExitCode` enum. The exit codes being a part of the public API can be imported and accessed directly using:
1617

doc/en/reference/reference.rst

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1636,6 +1636,34 @@ passed multiple times. The expected format is ``name=value``. For example::
16361636
into errors. For more information please refer to :ref:`warnings`.
16371637

16381638

1639+
.. confval:: max_warnings
1640+
:type: ``int``
1641+
1642+
.. versionadded:: 9.1
1643+
1644+
Maximum number of warnings allowed before the test run is considered a failure.
1645+
When all tests pass, but the total number of warnings exceeds this value, pytest exits with
1646+
:class:`pytest.ExitCode` ``MAX_WARNINGS_ERROR`` (code ``6``).
1647+
1648+
.. tab:: toml
1649+
1650+
.. code-block:: toml
1651+
1652+
[pytest]
1653+
max_warnings = 10
1654+
1655+
.. tab:: ini
1656+
1657+
.. code-block:: ini
1658+
1659+
[pytest]
1660+
max_warnings = 10
1661+
1662+
Note that :confval:`filtered warnings <filterwarnings>` do not count toward this maximum total.
1663+
1664+
Can also be set via the :option:`--max-warnings` command-line option.
1665+
1666+
16391667
.. confval:: junit_duration_report
16401668
:type: ``str``
16411669
:default: ``"total"``
@@ -3127,6 +3155,12 @@ Warnings
31273155
Set which warnings to report, see ``-W`` option of Python itself.
31283156
Can be specified multiple times.
31293157

3158+
.. option:: --max-warnings=NUM
3159+
3160+
Exit with :class:`pytest.ExitCode` ``MAX_WARNINGS_ERROR`` (code ``6``) if all the tests pass, but the number
3161+
of warnings exceeds the given threshold. By default there is no limit.
3162+
Can also be set via the :confval:`max_warnings` configuration option.
3163+
31303164
Doctest
31313165
~~~~~~~
31323166

@@ -3415,6 +3449,8 @@ All the command-line flags can also be obtained by running ``pytest --help``::
34153449
-W, --pythonwarnings PYTHONWARNINGS
34163450
Set which warnings to report, see -W option of
34173451
Python itself
3452+
--max-warnings=num Exit with error if the number of warnings exceeds
3453+
this threshold
34183454

34193455
collection:
34203456
--collect-only, --co Only collect tests, don't execute them
@@ -3537,6 +3573,9 @@ All the command-line flags can also be obtained by running ``pytest --help``::
35373573
Each line specifies a pattern for
35383574
warnings.filterwarnings. Processed after
35393575
-W/--pythonwarnings.
3576+
max_warnings (string):
3577+
Maximum number of warnings allowed before failing
3578+
the test run
35403579
norecursedirs (args): Directory patterns to avoid for recursion
35413580
testpaths (args): Directories to search for tests when no files or
35423581
directories are given on the command line

src/_pytest/config/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,8 @@ class ExitCode(enum.IntEnum):
117117
USAGE_ERROR = 4
118118
#: pytest couldn't find tests.
119119
NO_TESTS_COLLECTED = 5
120+
#: All tests pass, but maximum number of warnings exceeded.
121+
MAX_WARNINGS_ERROR = 6
120122

121123
__module__ = "pytest"
122124

src/_pytest/main.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,13 +125,26 @@ def pytest_addoption(parser: Parser) -> None:
125125
action="append",
126126
help="Set which warnings to report, see -W option of Python itself",
127127
)
128+
group.addoption(
129+
"--max-warnings",
130+
action="store",
131+
type=int,
132+
default=None,
133+
metavar="num",
134+
dest="max_warnings",
135+
help="Exit with error if all tests pass but the number of warnings exceeds this threshold",
136+
)
128137
parser.addini(
129138
"filterwarnings",
130139
type="linelist",
131140
help="Each line specifies a pattern for "
132141
"warnings.filterwarnings. "
133142
"Processed after -W/--pythonwarnings.",
134143
)
144+
parser.addini(
145+
"max_warnings",
146+
help="Exit with error if all tests pass but the number of warnings exceeds this threshold",
147+
)
135148

136149
group = parser.getgroup("collect", "collection")
137150
group.addoption(

src/_pytest/terminal.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -966,11 +966,23 @@ def pytest_sessionfinish(
966966
ExitCode.INTERRUPTED,
967967
ExitCode.USAGE_ERROR,
968968
ExitCode.NO_TESTS_COLLECTED,
969+
ExitCode.MAX_WARNINGS_ERROR,
969970
)
970971
if exitstatus in summary_exit_codes and not self.no_summary:
971972
self.config.hook.pytest_terminal_summary(
972973
terminalreporter=self, exitstatus=exitstatus, config=self.config
973974
)
975+
# Check --max-warnings threshold after all warnings have been collected.
976+
max_warnings = self._get_max_warnings()
977+
if max_warnings is not None and session.exitstatus == ExitCode.OK:
978+
warning_count = len(self.stats.get("warnings", []))
979+
if warning_count > max_warnings:
980+
session.exitstatus = ExitCode.MAX_WARNINGS_ERROR
981+
self.write_line(
982+
"Tests pass, but maximum allowed warnings exceeded: "
983+
f"{warning_count} > {max_warnings}",
984+
red=True,
985+
)
974986
if session.shouldfail:
975987
self.write_sep("!", str(session.shouldfail), red=True)
976988
if exitstatus == ExitCode.INTERRUPTED:
@@ -1057,6 +1069,16 @@ def _getcrashline(self, rep):
10571069
except AttributeError:
10581070
return ""
10591071

1072+
def _get_max_warnings(self) -> int | None:
1073+
"""Return the max_warnings threshold, from CLI or INI, or None if unset."""
1074+
value = self.config.option.max_warnings
1075+
if value is not None:
1076+
return int(value)
1077+
ini_value = self.config.getini("max_warnings")
1078+
if ini_value:
1079+
return int(ini_value)
1080+
return None
1081+
10601082
#
10611083
# Summaries for sessionfinish.
10621084
#

testing/test_warnings.py

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import sys
66
import warnings
77

8+
from _pytest.config import ExitCode
89
from _pytest.fixtures import FixtureRequest
910
from _pytest.pytester import Pytester
1011
import pytest
@@ -885,3 +886,155 @@ def test_resource_warning(tmp_path):
885886
else []
886887
)
887888
result.stdout.fnmatch_lines([*expected_extra, "*1 passed*"])
889+
890+
891+
class TestMaxWarnings:
892+
"""Tests for the --max-warnings feature."""
893+
894+
PYFILE = """
895+
import warnings
896+
def test_one():
897+
warnings.warn(UserWarning("warning one"))
898+
def test_two():
899+
warnings.warn(UserWarning("warning two"))
900+
"""
901+
902+
@pytest.mark.filterwarnings("default::UserWarning")
903+
def test_max_warnings_not_set(self, pytester: Pytester) -> None:
904+
"""Without --max-warnings, warnings don't affect exit code."""
905+
pytester.makepyfile(self.PYFILE)
906+
result = pytester.runpytest()
907+
result.assert_outcomes(passed=2, warnings=2)
908+
assert result.ret == ExitCode.OK
909+
910+
@pytest.mark.filterwarnings("default::UserWarning")
911+
def test_max_warnings_not_exceeded(self, pytester: Pytester) -> None:
912+
"""When warning count is below the threshold, exit code is OK."""
913+
pytester.makepyfile(self.PYFILE)
914+
result = pytester.runpytest("--max-warnings", "10")
915+
result.assert_outcomes(passed=2, warnings=2)
916+
assert result.ret == ExitCode.OK
917+
918+
@pytest.mark.filterwarnings("default::UserWarning")
919+
def test_max_warnings_exceeded(self, pytester: Pytester) -> None:
920+
"""When warning count exceeds threshold, exit code is MAX_WARNINGS_ERROR."""
921+
pytester.makepyfile(self.PYFILE)
922+
result = pytester.runpytest("--max-warnings", "1")
923+
assert result.ret == ExitCode.MAX_WARNINGS_ERROR
924+
925+
@pytest.mark.filterwarnings("default::UserWarning")
926+
def test_max_warnings_equal_to_count(self, pytester: Pytester) -> None:
927+
"""When warning count equals threshold exactly, exit code is OK."""
928+
pytester.makepyfile(self.PYFILE)
929+
result = pytester.runpytest("--max-warnings", "2")
930+
result.assert_outcomes(passed=2, warnings=2)
931+
assert result.ret == ExitCode.OK
932+
933+
@pytest.mark.filterwarnings("default::UserWarning")
934+
def test_max_warnings_zero(self, pytester: Pytester) -> None:
935+
"""--max-warnings 0 means no warnings are allowed."""
936+
pytester.makepyfile(self.PYFILE)
937+
result = pytester.runpytest("--max-warnings", "0")
938+
assert result.ret == ExitCode.MAX_WARNINGS_ERROR
939+
940+
@pytest.mark.filterwarnings("default::UserWarning")
941+
def test_max_warnings_exceeded_message(self, pytester: Pytester) -> None:
942+
"""Verify the output message when max warnings is exceeded."""
943+
pytester.makepyfile(self.PYFILE)
944+
result = pytester.runpytest("--max-warnings", "1")
945+
result.stdout.fnmatch_lines(
946+
["*Tests pass, but maximum allowed warnings exceeded: 2 > 1*"]
947+
)
948+
949+
@pytest.mark.filterwarnings("default::UserWarning")
950+
def test_max_warnings_ini_option(self, pytester: Pytester) -> None:
951+
"""max_warnings can be set via INI configuration."""
952+
pytester.makeini(
953+
"""
954+
[pytest]
955+
max_warnings = 1
956+
"""
957+
)
958+
pytester.makepyfile(self.PYFILE)
959+
result = pytester.runpytest()
960+
assert result.ret == ExitCode.MAX_WARNINGS_ERROR
961+
962+
@pytest.mark.filterwarnings("default::UserWarning")
963+
def test_max_warnings_with_test_failure(self, pytester: Pytester) -> None:
964+
"""When tests fail AND warnings exceed max, TESTS_FAILED takes priority."""
965+
pytester.makepyfile(
966+
"""
967+
import warnings
968+
def test_fail():
969+
warnings.warn(UserWarning("a warning"))
970+
assert False
971+
"""
972+
)
973+
result = pytester.runpytest("--max-warnings", "0")
974+
assert result.ret == ExitCode.TESTS_FAILED
975+
976+
@pytest.mark.filterwarnings("default::UserWarning")
977+
def test_max_warnings_with_filterwarnings_ignore(self, pytester: Pytester) -> None:
978+
"""Filtered (ignored) warnings don't count toward max_warnings."""
979+
pytester.makepyfile(
980+
"""
981+
import warnings
982+
def test_one():
983+
warnings.warn(UserWarning("counted"))
984+
warnings.warn(RuntimeWarning("ignored"))
985+
"""
986+
)
987+
result = pytester.runpytest(
988+
"--max-warnings",
989+
"1",
990+
"-W",
991+
"ignore::RuntimeWarning",
992+
)
993+
result.assert_outcomes(passed=1, warnings=1)
994+
assert result.ret == ExitCode.OK
995+
996+
@pytest.mark.filterwarnings("default::UserWarning")
997+
def test_max_warnings_with_filterwarnings_error(self, pytester: Pytester) -> None:
998+
"""Warnings turned into errors via filterwarnings don't count as warnings."""
999+
pytester.makepyfile(
1000+
"""
1001+
import warnings
1002+
def test_one():
1003+
warnings.warn(UserWarning("still a warning"))
1004+
def test_two():
1005+
warnings.warn(RuntimeWarning("becomes an error"))
1006+
"""
1007+
)
1008+
result = pytester.runpytest(
1009+
"--max-warnings",
1010+
"0",
1011+
"-W",
1012+
"error::RuntimeWarning",
1013+
)
1014+
# The RuntimeWarning becomes a test error, so TESTS_FAILED takes priority.
1015+
assert result.ret == ExitCode.TESTS_FAILED
1016+
1017+
@pytest.mark.filterwarnings("default::UserWarning")
1018+
def test_max_warnings_with_filterwarnings_ini_ignore(
1019+
self, pytester: Pytester
1020+
) -> None:
1021+
"""Warnings ignored via ini filterwarnings don't count toward max_warnings."""
1022+
pytester.makeini(
1023+
"""
1024+
[pytest]
1025+
filterwarnings =
1026+
ignore::RuntimeWarning
1027+
max_warnings = 1
1028+
"""
1029+
)
1030+
pytester.makepyfile(
1031+
"""
1032+
import warnings
1033+
def test_one():
1034+
warnings.warn(UserWarning("counted"))
1035+
warnings.warn(RuntimeWarning("ignored by ini"))
1036+
"""
1037+
)
1038+
result = pytester.runpytest()
1039+
result.assert_outcomes(passed=1, warnings=1)
1040+
assert result.ret == ExitCode.OK

0 commit comments

Comments
 (0)