diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 15a028dd..ac21f5d4 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -2,6 +2,17 @@ Changelog ========= +* Fixed the terminal summary contradicting the exit code when ``--cov-fail-under`` is used. + + The exit code is decided with coverage.py's ``should_fail_under``, which compares the total rounded to + ``[report] precision``, but the summary banner compared the raw total. When the raw total was below the + threshold and rounded up to meet it, the run exited 0 while printing a red + ``FAIL Required test coverage of N% not reached``. + The banner now uses the same predicate as the exit code, so ``[report] precision`` is honoured in both. + See `#638 `_ + (`#728 `_ reports the same inconsistency + from the opposite direction). + 7.1.0 (2026-03-21) ------------------ diff --git a/src/pytest_cov/plugin.py b/src/pytest_cov/plugin.py index 9f5c3f76..d70294b9 100644 --- a/src/pytest_cov/plugin.py +++ b/src/pytest_cov/plugin.py @@ -409,8 +409,14 @@ def pytest_terminal_summary(self, terminalreporter): terminalreporter.write(report) if self.options.cov_fail_under is not None and self.options.cov_fail_under > 0: + # import coverage lazily here to avoid importing + # it for unit tests that don't need it + from coverage.results import should_fail_under + self.write_heading(terminalreporter) - failed = self.cov_total < self.options.cov_fail_under + # Use the same predicate that decided the exit code in + # pytest_sessionfinish, so this banner cannot contradict it. + failed = should_fail_under(self.cov_total, self.options.cov_fail_under, self.options.cov_precision) markup = {'red': True, 'bold': True} if failed else {'green': True} message = '{fail}Required test coverage of {required}% {reached}. Total coverage: {actual:.2f}%\n'.format( required=self.options.cov_fail_under, diff --git a/tests/test_pytest_cov.py b/tests/test_pytest_cov.py index b291f432..1a0f40a7 100644 --- a/tests/test_pytest_cov.py +++ b/tests/test_pytest_cov.py @@ -535,6 +535,31 @@ def test_cov_min_float_value_not_reached(testdir): result.stdout.fnmatch_lines(['FAIL Required test coverage of 88.89% not reached. Total coverage: 88.89%']) +def test_cov_min_float_value_rounds_up_to_threshold(testdir): + # The summary banner must agree with the exit code. SCRIPT covers + # 88.888...%, which rounds to 89% at the default precision of 0, so the + # run passes. The banner used to compare the raw total instead and + # printed a red FAIL on a passing run. See #638 and #728. + script = testdir.makepyfile(SCRIPT) + + result = testdir.runpytest('-v', f'--cov={script.dirpath()}', '--cov-report=term-missing', '--cov-fail-under=88.9', script) + assert result.ret == 0 + result.stdout.fnmatch_lines(['Required test coverage of 88.9% reached. Total coverage: 88.89%']) + + +def test_cov_min_float_value_rounds_up_to_threshold_with_precision(testdir): + # Same divergence, driven by an explicit [report] precision as in #638. + script = testdir.makepyfile(SCRIPT) + testdir.tmpdir.join('.coveragerc').write(""" +[report] +precision = 1 +""") + + result = testdir.runpytest('-v', f'--cov={script.dirpath()}', '--cov-report=term-missing', '--cov-fail-under=88.9', script) + assert result.ret == 0 + result.stdout.fnmatch_lines(['Required test coverage of 88.9% reached. Total coverage: 88.89%']) + + def test_cov_min_float_value_not_reached_cli(testdir): script = testdir.makepyfile(SCRIPT) result = testdir.runpytest(