Repository navigation
test(coverage): check the coverage collection with the bundled coverage tool #4417
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
rickeylev
merged 6 commits into
bazel-contrib:main
from
aignas:aignas.test.coverage-collection
Nov 8, 2026
Merged
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
1909697
test(coverage): check the coverage collection with the bundled covera…
aignas e0351f6
fix: register the coverage dev toolchain with a full python version
aignas 088bce4
test(coverage): check the covered and not covered lines in separate t…
aignas 2f48dcb
test(coverage): simplify the coverage collection test setup
aignas 3304042
test(coverage): make collect_code_coverage a bool py_reconfig attr
aignas 35e08ea
test(coverage): use pytest_test for the coverage collection test
aignas File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,38 @@ | ||
| load("//python:py_library.bzl", "py_library") | ||
| load("//python:pytest_test.bzl", "pytest_test") | ||
| load("//tests/support:py_reconfig.bzl", "py_reconfig_test") | ||
| load("//tests/support:support.bzl", "NOT_WINDOWS", "SUPPORTS_BZLMOD") | ||
|
|
||
| py_library( | ||
| name = "lib", | ||
| srcs = ["lib.py"], | ||
| ) | ||
|
|
||
| # The program that is built with coverage enabled. It is run by the | ||
| # `coverage_collection_test` below, which sets the same environment variables | ||
| # that `bazel coverage` would set. | ||
| py_reconfig_test( | ||
| name = "instrumented", | ||
| srcs = ["instrumented.py"], | ||
| collect_code_coverage = True, | ||
| main = "instrumented.py", | ||
| # The bundled coverage tool is configured only for this version, see the | ||
| # `MODULE.bazel` file. | ||
| python_version = "3.12.16", | ||
| tags = ["manual"], | ||
| # The bundled coverage tool is not supported on Windows and it is only | ||
| # configured with bzlmod. | ||
| target_compatible_with = NOT_WINDOWS + SUPPORTS_BZLMOD, | ||
| deps = [":lib"], | ||
| ) | ||
|
|
||
| pytest_test( | ||
| name = "coverage_collection_test", | ||
| srcs = ["coverage_collection_test.py"], | ||
| data = [":instrumented"], | ||
| env = { | ||
| "INSTRUMENTED": "$(rlocationpath :instrumented)", | ||
| }, | ||
| target_compatible_with = NOT_WINDOWS + SUPPORTS_BZLMOD, | ||
| deps = ["//python/runfiles"], | ||
| ) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,162 @@ | ||
| """Check that the coverage is collected with the bundled coverage tool. | ||
|
|
||
| The tests run the instrumented program with the same environment variables | ||
| that `bazel coverage` sets and check the lcov file that it produces. | ||
| """ | ||
|
|
||
| import glob | ||
| import os | ||
| import pathlib | ||
| import subprocess | ||
| import unittest | ||
|
|
||
| from python.runfiles import runfiles | ||
|
|
||
| _LIB = "tests/coverage/lib.py" | ||
| _MAIN = "tests/coverage/instrumented.py" | ||
|
|
||
|
|
||
| def _parse_lcov(path): | ||
| """Parse the line and branch coverage from an lcov file. | ||
|
|
||
| Returns: | ||
| A dict of the source file to a struct-like dict with the `lines` | ||
| (line number to hit count) and `branches` (line number to the list of | ||
| the branch hit counts). | ||
| """ | ||
| result = {} | ||
| current = None | ||
| for line in pathlib.Path(path).read_text().splitlines(): | ||
| if line.startswith("SF:"): | ||
| current = result.setdefault( | ||
| line[len("SF:") :], | ||
| {"lines": {}, "branches": {}}, | ||
| ) | ||
| elif current is None: | ||
| continue | ||
| elif line.startswith("DA:"): | ||
| lineno, hits = line[len("DA:") :].split(",")[:2] | ||
| current["lines"][int(lineno)] = int(hits) | ||
| elif line.startswith("BRDA:"): | ||
| lineno, _, _, taken = line[len("BRDA:") :].split(",") | ||
| current["branches"].setdefault(int(lineno), []).append( | ||
| 0 if taken == "-" else int(taken) | ||
| ) | ||
| return result | ||
|
|
||
|
|
||
| class CoverageCollectionTest(unittest.TestCase): | ||
| @classmethod | ||
| def setUpClass(cls): | ||
| rf = runfiles.Create() | ||
| assert rf is not None | ||
| program = rf.Rlocation(os.environ["INSTRUMENTED"]) | ||
| assert program is not None and os.path.exists(program), program | ||
| lib_path = rf.Rlocation("_main/" + _LIB) | ||
| assert lib_path is not None | ||
| cls.lib_lines = pathlib.Path(lib_path).read_text().splitlines() | ||
|
|
||
| tmp = pathlib.Path(os.environ["TEST_TMPDIR"]) | ||
| coverage_dir = tmp / "coverage" | ||
| coverage_dir.mkdir() | ||
| manifest = tmp / "coverage_manifest.txt" | ||
| # Like `bazel coverage`, list all of the instrumented files. | ||
| manifest.write_text("\n".join([_LIB, _MAIN]) + "\n") | ||
|
|
||
| # Do not leak the coverage settings of this test (e.g. when running | ||
| # `bazel coverage`) into the instrumented program. | ||
| env = { | ||
| k: v | ||
| for k, v in os.environ.items() | ||
| if not k.startswith("COVERAGE") and k != "PYTHON_COVERAGE" | ||
| } | ||
| env.update(rf.EnvVars()) | ||
| env["COVERAGE"] = "1" | ||
| env["COVERAGE_DIR"] = str(coverage_dir) | ||
| env["COVERAGE_MANIFEST"] = str(manifest) | ||
|
|
||
| cls.result = subprocess.run( | ||
| [program], | ||
| env=env, | ||
| capture_output=True, | ||
| text=True, | ||
| ) | ||
| cls.lcov_files = glob.glob(str(coverage_dir / "pylcov_*.dat")) | ||
| cls.lcov = _parse_lcov(cls.lcov_files[0]) if len(cls.lcov_files) == 1 else {} | ||
|
|
||
| def _lineno(self, content): | ||
| """Return the 1-based line number of the unique line in lib.py.""" | ||
| matches = [ | ||
| i + 1 for i, line in enumerate(self.lib_lines) if line.strip() == content | ||
| ] | ||
| self.assertEqual(len(matches), 1, f"'{content}' in {_LIB}: {matches}") | ||
| return matches[0] | ||
|
|
||
| def _line_hits(self, content): | ||
| self.assertIn(_LIB, self.lcov, f"lcov: {self.lcov}") | ||
| lineno = self._lineno(content) | ||
| lines = self.lcov[_LIB]["lines"] | ||
| self.assertIn(lineno, lines, f"line {lineno} '{content}' not in lcov: {lines}") | ||
| return lines[lineno] | ||
|
|
||
| def test_program_succeeds(self): | ||
| self.assertEqual( | ||
| self.result.returncode, | ||
| 0, | ||
| f"stdout:\n{self.result.stdout}\nstderr:\n{self.result.stderr}", | ||
| ) | ||
| self.assertEqual(self.result.stdout.splitlines(), ["truthy", "hello coverage"]) | ||
|
|
||
| def test_single_lcov_file_is_produced(self): | ||
| self.assertEqual( | ||
| len(self.lcov_files), | ||
| 1, | ||
| f"lcov files: {self.lcov_files}\nstderr:\n{self.result.stderr}", | ||
| ) | ||
|
|
||
| def test_instrumented_files_use_relative_paths(self): | ||
| # NOTE: coverage.py also reports the files from the source directories | ||
| # that were not imported, so only check the instrumented files. | ||
| for path in [_LIB, _MAIN]: | ||
| with self.subTest(path=path): | ||
| self.assertIn(path, self.lcov, f"lcov files: {sorted(self.lcov)}") | ||
| self.assertEqual( | ||
| [f for f in self.lcov if f != path and f.endswith("/" + path)], | ||
| [], | ||
| ) | ||
|
|
||
| def test_executed_lines_are_covered(self): | ||
| for content in [ | ||
| "if value:", | ||
| 'return "truthy"', | ||
| 'return "hello " + name', | ||
| ]: | ||
| with self.subTest(line=content): | ||
| self.assertGreater(self._line_hits(content), 0) | ||
|
|
||
| def test_definitions_are_covered(self): | ||
| # Module level statements are executed on import. | ||
| for content in [ | ||
| "def covered(value):", | ||
| "def not_covered():", | ||
| "class Greeter:", | ||
| "def unused(self):", | ||
| ]: | ||
| with self.subTest(line=content): | ||
| self.assertGreater(self._line_hits(content), 0) | ||
|
|
||
| def test_skipped_branch_is_not_covered(self): | ||
| self.assertEqual(self._line_hits('return "falsy"'), 0) | ||
|
|
||
| def test_uncalled_function_is_not_covered(self): | ||
| self.assertEqual(self._line_hits('return "never called"'), 0) | ||
|
|
||
| def test_uncalled_method_is_not_covered(self): | ||
| self.assertEqual(self._line_hits('return "unused method"'), 0) | ||
|
|
||
| def test_partial_branch_coverage(self): | ||
| lineno = self._lineno("if value:") | ||
| branches = self.lcov[_LIB]["branches"] | ||
| self.assertIn(lineno, branches, f"branches: {branches}") | ||
| # Only the `True` branch of the `if` was taken. | ||
| self.assertEqual(sorted(b > 0 for b in branches[lineno]), [False, True]) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| """The program for which the coverage is collected.""" | ||
|
|
||
| from tests.coverage import lib | ||
|
|
||
| if __name__ == "__main__": | ||
| print(lib.covered(True)) | ||
| print(lib.Greeter().greet("coverage")) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| """A small library to check which lines are reported as covered. | ||
|
|
||
| The lines are looked up by their content in the tests, so keep them unique. | ||
| """ | ||
|
|
||
|
|
||
| def covered(value): | ||
| if value: | ||
| return "truthy" | ||
| return "falsy" | ||
|
|
||
|
|
||
| def not_covered(): | ||
| return "never called" | ||
|
|
||
|
|
||
| class Greeter: | ||
| def greet(self, name): | ||
| return "hello " + name | ||
|
|
||
| def unused(self): | ||
| return "unused method" |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Prefer
runfiles.CreateOrRaise()andrf.root()path navigation overrunfiles.Create()+assertandRlocation().Suggested fix:
This is an agent-generated comment reviewed and approved by the user.