| #!/usr/bin/env vpython3 |
| # Copyright 2026 The Chromium Authors |
| # Use of this source code is governed by a BSD-style license that can be |
| # found in the LICENSE file. |
| """Unit tests for fetch_coverage_metrics.py.""" |
| |
| import base64 |
| import contextlib |
| import io |
| import json |
| import sys |
| import textwrap |
| import unittest |
| from unittest import mock |
| import requests |
| from pyfakefs import fake_filesystem_unittest |
| import fetch_coverage_metrics as fcm |
| |
| |
| class FetchCoverageMetricsTest(fake_filesystem_unittest.TestCase): |
| """Tests percentage calculation, network helpers, and main CLI workflow.""" |
| |
| def setUp(self) -> None: |
| """Sets up virtual filesystem and network mocks for test cases.""" |
| self.setUpPyfakefs() |
| self.get_patcher = mock.patch('requests.get') |
| self.mock_get = self.get_patcher.start() |
| self.addCleanup(self.get_patcher.stop) |
| |
| self.make_patcher = mock.patch('fetch_coverage_metrics.make_request') |
| self.mock_make = self.make_patcher.start() |
| self.addCleanup(self.make_patcher.stop) |
| |
| self.lines_patcher = mock.patch( |
| 'fetch_coverage_metrics.fetch_gerrit_file_lines') |
| self.mock_lines = self.lines_patcher.start() |
| self.addCleanup(self.lines_patcher.stop) |
| |
| def test_parse_percentage_dict(self) -> None: |
| """Verifies ratio dict percentage calculation.""" |
| val = {'covered': 4, 'total': 5} |
| self.assertEqual(fcm.parse_percentage(val), 80.0) |
| |
| def test_parse_percentage_dict_zero_total(self) -> None: |
| """Verifies zero total ratio dict defaults to 100%.""" |
| val = {'covered': 0, 'total': 0} |
| self.assertEqual(fcm.parse_percentage(val), 100.0) |
| |
| def test_parse_percentage_scalar(self) -> None: |
| """Verifies numeric scalar percentage parsing.""" |
| self.assertEqual(fcm.parse_percentage(65.5), 65.5) |
| self.assertEqual(fcm.parse_percentage('42.0'), 42.0) |
| |
| def test_parse_percentage_invalid(self) -> None: |
| """Verifies invalid values return None.""" |
| self.assertIsNone(fcm.parse_percentage(None)) |
| self.assertIsNone(fcm.parse_percentage('not-a-num')) |
| |
| def test_make_request_success(self) -> None: |
| """Verifies make_request sends Authorization header.""" |
| self.make_patcher.stop() |
| mock_resp = mock.MagicMock() |
| mock_resp.content = b'{"data": {}}' |
| self.mock_get.return_value = mock_resp |
| |
| res = fcm.make_request('https://example.com/api', 'my-token') |
| self.assertEqual(res, b'{"data": {}}') |
| self.mock_get.assert_called_once_with( |
| 'https://example.com/api', |
| headers={'Authorization': 'Bearer my-token'}, |
| timeout=30) |
| |
| def test_make_request_http_error(self) -> None: |
| """Verifies RequestException is caught and logged.""" |
| self.make_patcher.stop() |
| self.mock_get.side_effect = requests.RequestException('404 not found') |
| buf = io.StringIO() |
| with contextlib.redirect_stderr(buf): |
| res = fcm.make_request('https://example.com/api') |
| self.assertIsNone(res) |
| self.assertIn('Error fetching', buf.getvalue()) |
| |
| def test_fetch_gerrit_file_lines_success(self) -> None: |
| """Verifies base64 decoding of Gerrit file content.""" |
| self.lines_patcher.stop() |
| raw_b64 = base64.b64encode(b'line 1\nline 2') |
| self.mock_make.return_value = raw_b64 |
| lines = fcm.fetch_gerrit_file_lines('host', 1, 1, 'path/foo.cc', 'tok') |
| self.assertEqual(lines, {'1': 'line 1', '2': 'line 2'}) |
| self.mock_make.assert_called_once() |
| |
| def test_fetch_gerrit_file_lines_no_data(self) -> None: |
| """Verifies missing request data returns empty dict.""" |
| self.lines_patcher.stop() |
| self.mock_make.return_value = None |
| lines = fcm.fetch_gerrit_file_lines('host', 1, 1, 'path/foo.cc') |
| self.assertEqual(lines, {}) |
| |
| def test_fetch_gerrit_file_lines_decode_error(self) -> None: |
| """Verifies base64 decoding errors return empty dict and log.""" |
| self.lines_patcher.stop() |
| self.mock_make.return_value = b'!!!not_base64!!!' |
| buf = io.StringIO() |
| with contextlib.redirect_stderr(buf): |
| lines = fcm.fetch_gerrit_file_lines('host', 1, 1, 'path/foo.cc') |
| self.assertEqual(lines, {}) |
| self.assertIn('Error decoding file', buf.getvalue()) |
| |
| def test_main_success_stdout(self) -> None: |
| """Verifies full CLI workflow printing JSON to stdout.""" |
| pct_json = { |
| 'data': { |
| 'files': [ |
| { |
| 'path': 'partial_cov.cc', |
| 'absolute_coverage': { |
| 'covered': 85, |
| 'total': 100 |
| }, |
| }, |
| { |
| 'path': 'high_cov.cc', |
| 'absolute_coverage': { |
| 'covered': 10, |
| 'total': 10 |
| }, |
| }, |
| ] |
| } |
| } |
| lines_json = { |
| 'data': { |
| 'files': [{ |
| 'path': |
| 'partial_cov.cc', |
| 'lines': [ |
| { |
| 'line': 10, |
| 'count': 0 |
| }, |
| { |
| 'line': 20, |
| 'count': 5 |
| }, |
| ], |
| }] |
| } |
| } |
| self.mock_make.side_effect = [ |
| json.dumps(pct_json).encode('utf-8'), |
| json.dumps(lines_json).encode('utf-8'), |
| ] |
| self.mock_lines.return_value = {'10': 'uncovered();', '20': 'cov();'} |
| |
| test_args = [ |
| 'fcm.py', '--host', 'h', '--project', 'p', '--change', '123', |
| '--patchset', '1' |
| ] |
| out_buf = io.StringIO() |
| with mock.patch.object(sys, 'argv', test_args): |
| with contextlib.redirect_stdout(out_buf): |
| fcm.main() |
| |
| expected_json = textwrap.dedent("""\ |
| { |
| "partial_cov.cc": { |
| "metrics": { |
| "absolute_coverage": 85.0 |
| }, |
| "low_coverage_type": [ |
| "absolute_coverage" |
| ], |
| "uncovered_lines": { |
| "10": "uncovered();" |
| } |
| }, |
| "high_cov.cc": { |
| "metrics": { |
| "absolute_coverage": 100.0 |
| }, |
| "low_coverage_type": [], |
| "uncovered_lines": {} |
| } |
| }""") |
| self.assertEqual(out_buf.getvalue().strip(), expected_json) |
| |
| def test_main_success_file_output(self) -> None: |
| """Verifies CLI workflow saving report to disk via pathlib.""" |
| pct_json = {'data': {'files': {'foo.java': {'absolute_coverage': 10.0}}}} |
| lines_json = {'data': {'files': {'foo.java': {'lines': []}}}} |
| self.mock_make.side_effect = [ |
| json.dumps(pct_json).encode('utf-8'), |
| json.dumps(lines_json).encode('utf-8'), |
| ] |
| self.mock_lines.return_value = {} |
| |
| out_path = '/mock/out/report.json' |
| self.fs.create_dir('/mock/out') |
| test_args = [ |
| 'fcm.py', '--host', 'h', '--project', 'p', '--change', '1', |
| '--patchset', '1', '--output', out_path |
| ] |
| with mock.patch.object(sys, 'argv', test_args): |
| fcm.main() |
| |
| saved = self.fs.get_object(out_path).contents |
| self.assertIn('"foo.java"', saved) |
| |
| def test_main_files_filtering(self) -> None: |
| """Verifies CLI --files argument filters out non-matching files.""" |
| pct_json = { |
| 'data': { |
| 'files': { |
| 'foo.java': {'absolute_coverage': 10.0}, |
| 'bar.cc': {'absolute_coverage': 20.0}, |
| } |
| } |
| } |
| lines_json = {'data': {'files': {'foo.java': {'lines': []}}}} |
| self.mock_make.side_effect = [ |
| json.dumps(pct_json).encode('utf-8'), |
| json.dumps(lines_json).encode('utf-8'), |
| ] |
| self.mock_lines.return_value = {} |
| |
| test_args = [ |
| 'fcm.py', '--host', 'h', '--project', 'p', '--change', '1', |
| '--patchset', '1', '--files', 'foo.java' |
| ] |
| out_buf = io.StringIO() |
| with mock.patch.object(sys, 'argv', test_args): |
| with contextlib.redirect_stdout(out_buf): |
| fcm.main() |
| |
| res = json.loads(out_buf.getvalue()) |
| self.assertIn('foo.java', res) |
| self.assertNotIn('bar.cc', res) |
| |
| def test_main_percentages_fetch_failure(self) -> None: |
| """Verifies exit(1) when percentages fetch returns None.""" |
| self.mock_make.return_value = None |
| test_args = [ |
| 'fcm.py', '--host', 'h', '--project', 'p', '--change', '1', |
| '--patchset', '1' |
| ] |
| err_buf = io.StringIO() |
| with mock.patch.object(sys, 'argv', test_args): |
| with contextlib.redirect_stderr(err_buf): |
| with self.assertRaises(SystemExit) as cm: |
| fcm.main() |
| self.assertEqual(cm.exception.code, 1) |
| self.assertIn('Failed to fetch', err_buf.getvalue()) |
| |
| def test_main_percentages_json_failure(self) -> None: |
| """Verifies exit(1) when percentages JSON parsing fails.""" |
| self.mock_make.return_value = b'not_json' |
| test_args = [ |
| 'fcm.py', '--host', 'h', '--project', 'p', '--change', '1', |
| '--patchset', '1' |
| ] |
| err_buf = io.StringIO() |
| with mock.patch.object(sys, 'argv', test_args): |
| with contextlib.redirect_stderr(err_buf): |
| with self.assertRaises(SystemExit) as cm: |
| fcm.main() |
| self.assertEqual(cm.exception.code, 1) |
| self.assertIn('Failed to parse', err_buf.getvalue()) |
| |
| def test_main_lines_query_error(self) -> None: |
| """Verifies lines query exception logs to stderr and continues.""" |
| pct_json = {'data': {'files': {'bar.cc': {'absolute_coverage': 0.0}}}} |
| self.mock_make.side_effect = [ |
| json.dumps(pct_json).encode('utf-8'), |
| b'bad_lines_json', |
| ] |
| test_args = [ |
| 'fcm.py', '--host', 'h', '--project', 'p', '--change', '1', |
| '--patchset', '1' |
| ] |
| err_buf = io.StringIO() |
| out_buf = io.StringIO() |
| with mock.patch.object(sys, 'argv', test_args): |
| with contextlib.redirect_stderr(err_buf): |
| with contextlib.redirect_stdout(out_buf): |
| fcm.main() |
| self.assertIn('Error processing lines', err_buf.getvalue()) |
| self.assertIn('"uncovered_lines": {}', out_buf.getvalue()) |
| |
| |
| if __name__ == '__main__': |
| unittest.main() |