Skip to content

Commit 0ea69c6

Browse files
committed
support branch coverage for testing
1 parent bb6e909 commit 0ea69c6

File tree

10 files changed

+85
-10
lines changed

10 files changed

+85
-10
lines changed

build/test-requirements.txt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,8 @@ namedpipe; platform_system == "Windows"
2828
# typing for Django files
2929
django-stubs
3030

31-
# for coverage
32-
coverage
31+
# for branch coverage testing, need version >= 7.7
32+
coverage>=7.7
3333
pytest-cov
3434
pytest-json
3535
pytest-timeout

python_files/tests/pytestadapter/test_coverage.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ def test_simple_pytest_coverage():
4545
assert focal_function_coverage.get("lines_missed") is not None
4646
assert set(focal_function_coverage.get("lines_covered")) == {4, 5, 7, 9, 10, 11, 12, 13, 14, 17}
4747
assert len(set(focal_function_coverage.get("lines_missed"))) >= 3
48+
assert focal_function_coverage.get("executed_branches") == 4
49+
assert focal_function_coverage.get("total_branches") == 6
4850

4951

5052
coverage_gen_file_path = TEST_DATA_PATH / "coverage_gen" / "coverage.json"
@@ -88,6 +90,8 @@ def test_coverage_gen_report(cleanup_coverage_gen_file): # noqa: ARG001
8890
assert focal_function_coverage.get("lines_missed") is not None
8991
assert set(focal_function_coverage.get("lines_covered")) == {4, 5, 7, 9, 10, 11, 12, 13, 14, 17}
9092
assert set(focal_function_coverage.get("lines_missed")) == {18, 19, 6}
93+
assert focal_function_coverage.get("executed_branches") == 4
94+
assert focal_function_coverage.get("total_branches") == 6
9195
# assert that the coverage file was created at the right path
9296
assert os.path.exists(coverage_gen_file_path) # noqa: PTH110
9397

@@ -129,3 +133,4 @@ def test_coverage_w_omit_config():
129133
assert results
130134
# assert one file is reported and one file (as specified in pyproject.toml) is omitted
131135
assert len(results) == 1
136+

python_files/tests/unittestadapter/test_coverage.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ def test_basic_coverage():
5151
assert focal_function_coverage.get("lines_missed") is not None
5252
assert set(focal_function_coverage.get("lines_covered")) == {4, 5, 7, 9, 10, 11, 12, 13, 14}
5353
assert set(focal_function_coverage.get("lines_missed")) == {6}
54+
assert focal_function_coverage.get("executed_branches") == 3
55+
assert focal_function_coverage.get("total_branches") == 4
5456

5557

5658
@pytest.mark.parametrize("manage_py_file", ["manage.py", "old_manage.py"])

python_files/unittestadapter/execution.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
import unittest
1212
from types import TracebackType
1313
from typing import Dict, List, Optional, Set, Tuple, Type, Union
14+
from packaging.version import Version
15+
1416

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"
@@ -316,12 +318,18 @@ 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
328+
coverage_version = Version(coverage.__version__)
329+
# only include branches if coverage version is 7.7.0 or greater (as this was when the api saves)
330+
if coverage_version >= Version("7.7.0"):
331+
include_branches = True
332+
325333

326334
source_ar: List[str] = []
327335
if workspace_root:
@@ -330,7 +338,7 @@ def send_run_data(raw_data, test_run_pipe):
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(branch=include_branches, source=source_ar) # is at least 1 of these required??
334342
cov.start()
335343

336344
# If no error occurred, we will have test ids to run.
@@ -362,12 +370,22 @@ def send_run_data(raw_data, test_run_pipe):
362370
file_coverage_map: Dict[str, FileCoverageInfo] = {}
363371
for file in file_set:
364372
analysis = cov.analysis2(file)
373+
taken_file_branches = 0
374+
total_file_branches = -1
375+
376+
if include_branches:
377+
branch_stats: dict[int, tuple[int, int]] = cov.branch_stats(file)
378+
total_file_branches = sum([total_exits for total_exits, _ in branch_stats.values()])
379+
taken_file_branches = sum([taken_exits for _, taken_exits in branch_stats.values()])
380+
365381
lines_executable = {int(line_no) for line_no in analysis[1]}
366382
lines_missed = {int(line_no) for line_no in analysis[3]}
367383
lines_covered = lines_executable - lines_missed
368384
file_info: FileCoverageInfo = {
369385
"lines_covered": list(lines_covered), # list of int
370386
"lines_missed": list(lines_missed), # list of int
387+
"executed_branches": taken_file_branches,
388+
"total_branches": total_file_branches,
371389
}
372390
file_coverage_map[file] = file_info
373391

python_files/unittestadapter/pvsc_utils.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,10 @@ 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
80+
81+
7882

7983

8084
class CoveragePayloadDict(Dict):

python_files/vscode_pytest/__init__.py

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import sys
1212
import traceback
1313
from typing import TYPE_CHECKING, Any, Dict, Generator, Literal, TypedDict
14+
from packaging.version import Version
1415

1516
import pytest
1617

@@ -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,14 @@ 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([total_exits for total_exits, _ in branch_stats.values()])
483+
taken_file_branches = sum([taken_exits for _, taken_exits in branch_stats.values()])
484+
462485
except NoSource:
463486
# as per issue 24308 this best way to handle this edge case
464487
continue
@@ -473,6 +496,8 @@ def pytest_sessionfinish(session, exitstatus):
473496
file_info: FileCoverageInfo = {
474497
"lines_covered": list(lines_covered), # list of int
475498
"lines_missed": list(lines_missed), # list of int
499+
"executed_branches": taken_file_branches,
500+
"total_branches": total_file_branches,
476501
}
477502
# convert relative path to absolute path
478503
if not pathlib.Path(file).is_absolute():

python_files/vscode_pytest/run_pytest_script.py

Lines changed: 1 addition & 1 deletion
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

Lines changed: 18 additions & 3 deletions
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

Lines changed: 2 additions & 0 deletions
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

Lines changed: 4 additions & 0 deletions
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)