Skip to content

Commit 4b7650e

Browse files
authored
support branch coverage for testing (#24980)
fixes #24976
1 parent f319416 commit 4b7650e

File tree

10 files changed

+122
-24
lines changed

10 files changed

+122
-24
lines changed

build/test-requirements.txt

-1
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@ namedpipe; platform_system == "Windows"
2828
# typing for Django files
2929
django-stubs
3030

31-
# for coverage
3231
coverage
3332
pytest-cov
3433
pytest-json

python_files/tests/pytestadapter/test_coverage.py

+22-9
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@
55
import pathlib
66
import sys
77

8+
import coverage
89
import pytest
10+
from packaging.version import Version
911

1012
script_dir = pathlib.Path(__file__).parent.parent
1113
sys.path.append(os.fspath(script_dir))
@@ -34,9 +36,9 @@ def test_simple_pytest_coverage():
3436
cov_folder_path = TEST_DATA_PATH / "coverage_gen"
3537
actual = runner_with_cwd_env(args, cov_folder_path, env_add)
3638
assert actual
37-
coverage = actual[-1]
38-
assert coverage
39-
results = coverage["result"]
39+
cov = actual[-1]
40+
assert cov
41+
results = cov["result"]
4042
assert results
4143
assert len(results) == 3
4244
focal_function_coverage = results.get(os.fspath(TEST_DATA_PATH / "coverage_gen" / "reverse.py"))
@@ -46,6 +48,12 @@ def test_simple_pytest_coverage():
4648
assert set(focal_function_coverage.get("lines_covered")) == {4, 5, 7, 9, 10, 11, 12, 13, 14, 17}
4749
assert len(set(focal_function_coverage.get("lines_missed"))) >= 3
4850

51+
coverage_version = Version(coverage.__version__)
52+
# only include check for branches if the version is >= 7.7.0
53+
if coverage_version >= Version("7.7.0"):
54+
assert focal_function_coverage.get("executed_branches") == 4
55+
assert focal_function_coverage.get("total_branches") == 6
56+
4957

5058
coverage_gen_file_path = TEST_DATA_PATH / "coverage_gen" / "coverage.json"
5159

@@ -77,9 +85,9 @@ def test_coverage_gen_report(cleanup_coverage_gen_file): # noqa: ARG001
7785
print("cov_folder_path", cov_folder_path)
7886
actual = runner_with_cwd_env(args, cov_folder_path, env_add)
7987
assert actual
80-
coverage = actual[-1]
81-
assert coverage
82-
results = coverage["result"]
88+
cov = actual[-1]
89+
assert cov
90+
results = cov["result"]
8391
assert results
8492
assert len(results) == 3
8593
focal_function_coverage = results.get(os.fspath(TEST_DATA_PATH / "coverage_gen" / "reverse.py"))
@@ -88,6 +96,11 @@ def test_coverage_gen_report(cleanup_coverage_gen_file): # noqa: ARG001
8896
assert focal_function_coverage.get("lines_missed") is not None
8997
assert set(focal_function_coverage.get("lines_covered")) == {4, 5, 7, 9, 10, 11, 12, 13, 14, 17}
9098
assert set(focal_function_coverage.get("lines_missed")) == {18, 19, 6}
99+
coverage_version = Version(coverage.__version__)
100+
# only include check for branches if the version is >= 7.7.0
101+
if coverage_version >= Version("7.7.0"):
102+
assert focal_function_coverage.get("executed_branches") == 4
103+
assert focal_function_coverage.get("total_branches") == 6
91104
# assert that the coverage file was created at the right path
92105
assert os.path.exists(coverage_gen_file_path) # noqa: PTH110
93106

@@ -123,9 +136,9 @@ def test_coverage_w_omit_config():
123136
actual = runner_with_cwd_env([], cov_folder_path, env_add)
124137
assert actual
125138
print("actual", json.dumps(actual, indent=2))
126-
coverage = actual[-1]
127-
assert coverage
128-
results = coverage["result"]
139+
cov = actual[-1]
140+
assert cov
141+
results = cov["result"]
129142
assert results
130143
# assert one file is reported and one file (as specified in pyproject.toml) is omitted
131144
assert len(results) == 1

python_files/tests/unittestadapter/test_coverage.py

+20-6
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@
88
import pathlib
99
import sys
1010

11+
import coverage
1112
import pytest
13+
from packaging.version import Version
1214

1315
sys.path.append(os.fspath(pathlib.Path(__file__).parent))
1416

@@ -40,9 +42,9 @@ def test_basic_coverage():
4042
)
4143

4244
assert actual
43-
coverage = actual[-1]
44-
assert coverage
45-
results = coverage["result"]
45+
cov = actual[-1]
46+
assert cov
47+
results = cov["result"]
4648
assert results
4749
assert len(results) == 3
4850
focal_function_coverage = results.get(os.fspath(TEST_DATA_PATH / "coverage_ex" / "reverse.py"))
@@ -51,6 +53,11 @@ def test_basic_coverage():
5153
assert focal_function_coverage.get("lines_missed") is not None
5254
assert set(focal_function_coverage.get("lines_covered")) == {4, 5, 7, 9, 10, 11, 12, 13, 14}
5355
assert set(focal_function_coverage.get("lines_missed")) == {6}
56+
coverage_version = Version(coverage.__version__)
57+
# only include check for branches if the version is >= 7.7.0
58+
if coverage_version >= Version("7.7.0"):
59+
assert focal_function_coverage.get("executed_branches") == 3
60+
assert focal_function_coverage.get("total_branches") == 4
5461

5562

5663
@pytest.mark.parametrize("manage_py_file", ["manage.py", "old_manage.py"])
@@ -79,9 +86,9 @@ def test_basic_django_coverage(manage_py_file):
7986
)
8087

8188
assert actual
82-
coverage = actual[-1]
83-
assert coverage
84-
results = coverage["result"]
89+
cov = actual[-1]
90+
assert cov
91+
results = cov["result"]
8592
assert results
8693
assert len(results) == 16
8794
polls_views_coverage = results.get(str(data_path / "polls" / "views.py"))
@@ -90,3 +97,10 @@ def test_basic_django_coverage(manage_py_file):
9097
assert polls_views_coverage.get("lines_missed") is not None
9198
assert set(polls_views_coverage.get("lines_covered")) == {3, 4, 6}
9299
assert set(polls_views_coverage.get("lines_missed")) == {7}
100+
101+
model_cov = results.get(str(data_path / "polls" / "models.py"))
102+
coverage_version = Version(coverage.__version__)
103+
# only include check for branches if the version is >= 7.7.0
104+
if coverage_version >= Version("7.7.0"):
105+
assert model_cov.get("executed_branches") == 1
106+
assert model_cov.get("total_branches") == 2

python_files/unittestadapter/execution.py

+21-1
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
from types import TracebackType
1313
from typing import Dict, List, Optional, Set, Tuple, Type, Union
1414

15+
from packaging.version import Version
16+
1517
# Adds the scripts directory to the PATH as a workaround for enabling shell for test execution.
1618
path_var_name = "PATH" if "PATH" in os.environ else "Path"
1719
os.environ[path_var_name] = (
@@ -316,21 +318,29 @@ def send_run_data(raw_data, test_run_pipe):
316318
# For unittest COVERAGE_ENABLED is to the root of the workspace so correct data is collected
317319
cov = None
318320
is_coverage_run = os.environ.get("COVERAGE_ENABLED") is not None
321+
include_branches = False
319322
if is_coverage_run:
320323
print(
321324
"COVERAGE_ENABLED env var set, starting coverage. workspace_root used as parent dir:",
322325
workspace_root,
323326
)
324327
import coverage
325328

329+
coverage_version = Version(coverage.__version__)
330+
# only include branches if coverage version is 7.7.0 or greater (as this was when the api saves)
331+
if coverage_version >= Version("7.7.0"):
332+
include_branches = True
333+
326334
source_ar: List[str] = []
327335
if workspace_root:
328336
source_ar.append(workspace_root)
329337
if top_level_dir:
330338
source_ar.append(top_level_dir)
331339
if start_dir:
332340
source_ar.append(os.path.abspath(start_dir)) # noqa: PTH100
333-
cov = coverage.Coverage(branch=True, source=source_ar) # is at least 1 of these required??
341+
cov = coverage.Coverage(
342+
branch=include_branches, source=source_ar
343+
) # is at least 1 of these required??
334344
cov.start()
335345

336346
# If no error occurred, we will have test ids to run.
@@ -362,12 +372,22 @@ def send_run_data(raw_data, test_run_pipe):
362372
file_coverage_map: Dict[str, FileCoverageInfo] = {}
363373
for file in file_set:
364374
analysis = cov.analysis2(file)
375+
taken_file_branches = 0
376+
total_file_branches = -1
377+
378+
if include_branches:
379+
branch_stats: dict[int, tuple[int, int]] = cov.branch_stats(file)
380+
total_file_branches = sum([total_exits for total_exits, _ in branch_stats.values()])
381+
taken_file_branches = sum([taken_exits for _, taken_exits in branch_stats.values()])
382+
365383
lines_executable = {int(line_no) for line_no in analysis[1]}
366384
lines_missed = {int(line_no) for line_no in analysis[3]}
367385
lines_covered = lines_executable - lines_missed
368386
file_info: FileCoverageInfo = {
369387
"lines_covered": list(lines_covered), # list of int
370388
"lines_missed": list(lines_missed), # list of int
389+
"executed_branches": taken_file_branches,
390+
"total_branches": total_file_branches,
371391
}
372392
file_coverage_map[file] = file_info
373393

python_files/unittestadapter/pvsc_utils.py

+2
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,8 @@ class ExecutionPayloadDict(TypedDict):
7575
class FileCoverageInfo(TypedDict):
7676
lines_covered: List[int]
7777
lines_missed: List[int]
78+
executed_branches: int
79+
total_branches: int
7880

7981

8082
class CoveragePayloadDict(Dict):

python_files/vscode_pytest/__init__.py

+32-3
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from typing import TYPE_CHECKING, Any, Dict, Generator, Literal, TypedDict
1414

1515
import pytest
16+
from packaging.version import Version
1617

1718
if TYPE_CHECKING:
1819
from pluggy import Result
@@ -61,6 +62,7 @@ def __init__(self, message):
6162
collected_tests_so_far = []
6263
TEST_RUN_PIPE = os.getenv("TEST_RUN_PIPE")
6364
SYMLINK_PATH = None
65+
INCLUDE_BRANCHES = False
6466

6567

6668
def pytest_load_initial_conftests(early_config, parser, args): # noqa: ARG001
@@ -70,6 +72,9 @@ def pytest_load_initial_conftests(early_config, parser, args): # noqa: ARG001
7072
raise VSCodePytestError(
7173
"\n \nERROR: pytest-cov is not installed, please install this before running pytest with coverage as pytest-cov is required. \n"
7274
)
75+
if "--cov-branch" in args:
76+
global INCLUDE_BRANCHES
77+
INCLUDE_BRANCHES = True
7378

7479
global TEST_RUN_PIPE
7580
TEST_RUN_PIPE = os.getenv("TEST_RUN_PIPE")
@@ -363,6 +368,8 @@ def check_skipped_condition(item):
363368
class FileCoverageInfo(TypedDict):
364369
lines_covered: list[int]
365370
lines_missed: list[int]
371+
executed_branches: int
372+
total_branches: int
366373

367374

368375
def pytest_sessionfinish(session, exitstatus):
@@ -436,6 +443,15 @@ def pytest_sessionfinish(session, exitstatus):
436443
# load the report and build the json result to return
437444
import coverage
438445

446+
coverage_version = Version(coverage.__version__)
447+
global INCLUDE_BRANCHES
448+
# only include branches if coverage version is 7.7.0 or greater (as this was when the api saves)
449+
if coverage_version < Version("7.7.0") and INCLUDE_BRANCHES:
450+
print(
451+
"Plugin warning[vscode-pytest]: Branch coverage not supported in this coverage versions < 7.7.0. Please upgrade coverage package if you would like to see branch coverage."
452+
)
453+
INCLUDE_BRANCHES = False
454+
439455
try:
440456
from coverage.exceptions import NoSource
441457
except ImportError:
@@ -448,9 +464,8 @@ def pytest_sessionfinish(session, exitstatus):
448464
file_coverage_map: dict[str, FileCoverageInfo] = {}
449465

450466
# remove files omitted per coverage report config if any
451-
omit_files = cov.config.report_omit
452-
if omit_files:
453-
print("Plugin info[vscode-pytest]: Omit files/rules: ", omit_files)
467+
omit_files: list[str] | None = cov.config.report_omit
468+
if omit_files is not None:
454469
for pattern in omit_files:
455470
for file in list(file_set):
456471
if pathlib.Path(file).match(pattern):
@@ -459,6 +474,18 @@ def pytest_sessionfinish(session, exitstatus):
459474
for file in file_set:
460475
try:
461476
analysis = cov.analysis2(file)
477+
taken_file_branches = 0
478+
total_file_branches = -1
479+
480+
if INCLUDE_BRANCHES:
481+
branch_stats: dict[int, tuple[int, int]] = cov.branch_stats(file)
482+
total_file_branches = sum(
483+
[total_exits for total_exits, _ in branch_stats.values()]
484+
)
485+
taken_file_branches = sum(
486+
[taken_exits for _, taken_exits in branch_stats.values()]
487+
)
488+
462489
except NoSource:
463490
# as per issue 24308 this best way to handle this edge case
464491
continue
@@ -473,6 +500,8 @@ def pytest_sessionfinish(session, exitstatus):
473500
file_info: FileCoverageInfo = {
474501
"lines_covered": list(lines_covered), # list of int
475502
"lines_missed": list(lines_missed), # list of int
503+
"executed_branches": taken_file_branches,
504+
"total_branches": total_file_branches,
476505
}
477506
# convert relative path to absolute path
478507
if not pathlib.Path(file).is_absolute():

python_files/vscode_pytest/run_pytest_script.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ def run_pytest(args):
4747
coverage_enabled = True
4848
break
4949
if not coverage_enabled:
50-
args = [*args, "--cov=."]
50+
args = [*args, "--cov=.", "--cov-branch"]
5151

5252
run_test_ids_pipe = os.environ.get("RUN_TEST_IDS_PIPE")
5353
if run_test_ids_pipe:

src/client/testing/testController/common/resultResolver.ts

+18-3
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,13 @@ import {
1717
Range,
1818
} from 'vscode';
1919
import * as util from 'util';
20-
import { CoveragePayload, DiscoveredTestPayload, ExecutionTestPayload, ITestResultResolver } from './types';
20+
import {
21+
CoveragePayload,
22+
DiscoveredTestPayload,
23+
ExecutionTestPayload,
24+
FileCoverageMetrics,
25+
ITestResultResolver,
26+
} from './types';
2127
import { TestProvider } from '../../types';
2228
import { traceError, traceVerbose } from '../../../logging';
2329
import { Testing } from '../../../common/utils/localize';
@@ -120,16 +126,25 @@ export class PythonResultResolver implements ITestResultResolver {
120126
}
121127
for (const [key, value] of Object.entries(payload.result)) {
122128
const fileNameStr = key;
123-
const fileCoverageMetrics = value;
129+
const fileCoverageMetrics: FileCoverageMetrics = value;
124130
const linesCovered = fileCoverageMetrics.lines_covered ? fileCoverageMetrics.lines_covered : []; // undefined if no lines covered
125131
const linesMissed = fileCoverageMetrics.lines_missed ? fileCoverageMetrics.lines_missed : []; // undefined if no lines missed
132+
const executedBranches = fileCoverageMetrics.executed_branches;
133+
const totalBranches = fileCoverageMetrics.total_branches;
126134

127135
const lineCoverageCount = new TestCoverageCount(
128136
linesCovered.length,
129137
linesCovered.length + linesMissed.length,
130138
);
139+
let fileCoverage: FileCoverage;
131140
const uri = Uri.file(fileNameStr);
132-
const fileCoverage = new FileCoverage(uri, lineCoverageCount);
141+
if (totalBranches === -1) {
142+
// branch coverage was not enabled and should not be displayed
143+
fileCoverage = new FileCoverage(uri, lineCoverageCount);
144+
} else {
145+
const branchCoverageCount = new TestCoverageCount(executedBranches, totalBranches);
146+
fileCoverage = new FileCoverage(uri, lineCoverageCount, branchCoverageCount);
147+
}
133148
runInstance.addCoverage(fileCoverage);
134149

135150
// create detailed coverage array for each file (only line coverage on detailed, not branch)

src/client/testing/testController/common/types.ts

+2
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,8 @@ export type FileCoverageMetrics = {
222222
lines_covered: number[];
223223
// eslint-disable-next-line camelcase
224224
lines_missed: number[];
225+
executed_branches: number;
226+
total_branches: number;
225227
};
226228

227229
export type ExecutionTestPayload = {

src/test/testing/common/testingAdapter.test.ts

+4
Original file line numberDiff line numberDiff line change
@@ -711,6 +711,8 @@ suite('End to End Tests: test adapters', () => {
711711
// since only one test was run, the other test in the same file will have missed coverage lines
712712
assert.strictEqual(simpleFileCov.lines_covered.length, 3, 'Expected 1 line to be covered in even.py');
713713
assert.strictEqual(simpleFileCov.lines_missed.length, 1, 'Expected 3 lines to be missed in even.py');
714+
assert.strictEqual(simpleFileCov.executed_branches, 1, 'Expected 1 branch to be executed in even.py');
715+
assert.strictEqual(simpleFileCov.total_branches, 2, 'Expected 2 branches in even.py');
714716
return Promise.resolve();
715717
};
716718

@@ -759,6 +761,8 @@ suite('End to End Tests: test adapters', () => {
759761
// since only one test was run, the other test in the same file will have missed coverage lines
760762
assert.strictEqual(simpleFileCov.lines_covered.length, 3, 'Expected 1 line to be covered in even.py');
761763
assert.strictEqual(simpleFileCov.lines_missed.length, 1, 'Expected 3 lines to be missed in even.py');
764+
assert.strictEqual(simpleFileCov.executed_branches, 1, 'Expected 1 branch to be executed in even.py');
765+
assert.strictEqual(simpleFileCov.total_branches, 2, 'Expected 2 branches in even.py');
762766

763767
return Promise.resolve();
764768
};

0 commit comments

Comments
 (0)