Skip to content

Commit 8268131

Browse files
authored
Implementation of Test Coverage (#24118)
fixes #22671
1 parent 717e518 commit 8268131

30 files changed

+895
-81
lines changed

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,3 +49,8 @@ dist/**
4949
package.nls.*.json
5050
l10n/
5151
python-env-tools/**
52+
# coverage files produced as test output
53+
python_files/tests/*/.data/.coverage*
54+
python_files/tests/*/.data/*/.coverage*
55+
src/testTestingRootWkspc/coverageWorkspace/.coverage
56+
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
11
# List of requirements for functional tests
22
versioneer
33
numpy
4+
pytest
5+
pytest-cov

build/test-requirements.txt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,3 +27,7 @@ namedpipe; platform_system == "Windows"
2727

2828
# typing for Django files
2929
django-stubs
30+
31+
# for coverage
32+
coverage
33+
pytest-cov
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
# Copyright (c) Microsoft Corporation. All rights reserved.
2+
# Licensed under the MIT License.
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# Copyright (c) Microsoft Corporation. All rights reserved.
2+
# Licensed under the MIT License.
3+
4+
def reverse_string(s):
5+
if s is None or s == "":
6+
return "Error: Input is None"
7+
return s[::-1]
8+
9+
def reverse_sentence(sentence):
10+
if sentence is None or sentence == "":
11+
return "Error: Input is None"
12+
words = sentence.split()
13+
reversed_words = [reverse_string(word) for word in words]
14+
return " ".join(reversed_words)
15+
16+
# Example usage
17+
if __name__ == "__main__":
18+
sample_string = "hello"
19+
print(reverse_string(sample_string)) # Output: "olleh"
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# Copyright (c) Microsoft Corporation. All rights reserved.
2+
# Licensed under the MIT License.
3+
4+
from .reverse import reverse_sentence, reverse_string
5+
6+
7+
def test_reverse_sentence():
8+
"""
9+
Tests the reverse_sentence function to ensure it correctly reverses each word in a sentence.
10+
11+
Test cases:
12+
- "hello world" should be reversed to "olleh dlrow"
13+
- "Python is fun" should be reversed to "nohtyP si nuf"
14+
- "a b c" should remain "a b c" as each character is a single word
15+
"""
16+
assert reverse_sentence("hello world") == "olleh dlrow"
17+
assert reverse_sentence("Python is fun") == "nohtyP si nuf"
18+
assert reverse_sentence("a b c") == "a b c"
19+
20+
def test_reverse_sentence_error():
21+
assert reverse_sentence("") == "Error: Input is None"
22+
assert reverse_sentence(None) == "Error: Input is None"
23+
24+
25+
def test_reverse_string():
26+
assert reverse_string("hello") == "olleh"
27+
assert reverse_string("Python") == "nohtyP"
28+
# this test specifically does not cover the error cases

python_files/tests/pytestadapter/helpers.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,26 @@ def runner_with_cwd(args: List[str], path: pathlib.Path) -> Optional[List[Dict[s
203203
return runner_with_cwd_env(args, path, {})
204204

205205

206+
def split_array_at_item(arr: List[str], item: str) -> Tuple[List[str], List[str]]:
207+
"""
208+
Splits an array into two subarrays at the specified item.
209+
210+
Args:
211+
arr (List[str]): The array to be split.
212+
item (str): The item at which to split the array.
213+
214+
Returns:
215+
Tuple[List[str], List[str]]: A tuple containing two subarrays. The first subarray includes the item and all elements before it. The second subarray includes all elements after the item. If the item is not found, the first subarray is the original array and the second subarray is empty.
216+
"""
217+
if item in arr:
218+
index = arr.index(item)
219+
before = arr[: index + 1]
220+
after = arr[index + 1 :]
221+
return before, after
222+
else:
223+
return arr, []
224+
225+
206226
def runner_with_cwd_env(
207227
args: List[str], path: pathlib.Path, env_add: Dict[str, str]
208228
) -> Optional[List[Dict[str, Any]]]:
@@ -217,10 +237,34 @@ def runner_with_cwd_env(
217237
# If we are running Django, generate a unittest-specific pipe name.
218238
process_args = [sys.executable, *args]
219239
pipe_name = generate_random_pipe_name("unittest-discovery-test")
240+
elif "_TEST_VAR_UNITTEST" in env_add:
241+
before_args, after_ids = split_array_at_item(args, "*test*.py")
242+
process_args = [sys.executable, *before_args]
243+
pipe_name = generate_random_pipe_name("unittest-execution-test")
244+
test_ids_pipe = os.fspath(
245+
script_dir / "tests" / "unittestadapter" / ".data" / "coverage_ex" / "10943021.txt"
246+
)
247+
env_add.update({"RUN_TEST_IDS_PIPE": test_ids_pipe})
248+
test_ids_arr = after_ids
249+
with open(test_ids_pipe, "w") as f: # noqa: PTH123
250+
f.write("\n".join(test_ids_arr))
220251
else:
221252
process_args = [sys.executable, "-m", "pytest", "-p", "vscode_pytest", "-s", *args]
222253
pipe_name = generate_random_pipe_name("pytest-discovery-test")
223254

255+
if "COVERAGE_ENABLED" in env_add and "_TEST_VAR_UNITTEST" not in env_add:
256+
process_args = [
257+
sys.executable,
258+
"-m",
259+
"pytest",
260+
"-p",
261+
"vscode_pytest",
262+
"--cov=.",
263+
"--cov-branch",
264+
"-s",
265+
*args,
266+
]
267+
224268
# Generate pipe name, pipe name specific per OS type.
225269

226270
# Windows design
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# Copyright (c) Microsoft Corporation. All rights reserved.
2+
# Licensed under the MIT License.
3+
import os
4+
import pathlib
5+
import sys
6+
7+
script_dir = pathlib.Path(__file__).parent.parent
8+
sys.path.append(os.fspath(script_dir))
9+
10+
from .helpers import ( # noqa: E402
11+
TEST_DATA_PATH,
12+
runner_with_cwd_env,
13+
)
14+
15+
16+
def test_simple_pytest_coverage():
17+
"""
18+
Test coverage payload is correct for simple pytest example. Output of coverage run is below.
19+
20+
Name Stmts Miss Branch BrPart Cover
21+
---------------------------------------------------
22+
__init__.py 0 0 0 0 100%
23+
reverse.py 13 3 8 2 76%
24+
test_reverse.py 11 0 0 0 100%
25+
---------------------------------------------------
26+
TOTAL 24 3 8 2 84%
27+
28+
"""
29+
args = []
30+
env_add = {"COVERAGE_ENABLED": "True"}
31+
cov_folder_path = TEST_DATA_PATH / "coverage_gen"
32+
actual = runner_with_cwd_env(args, cov_folder_path, env_add)
33+
assert actual
34+
coverage = actual[-1]
35+
assert coverage
36+
results = coverage["result"]
37+
assert results
38+
assert len(results) == 3
39+
focal_function_coverage = results.get(os.fspath(TEST_DATA_PATH / "coverage_gen" / "reverse.py"))
40+
assert focal_function_coverage
41+
assert focal_function_coverage.get("lines_covered") is not None
42+
assert focal_function_coverage.get("lines_missed") is not None
43+
assert set(focal_function_coverage.get("lines_covered")) == {4, 5, 7, 9, 10, 11, 12, 13, 14, 17}
44+
assert set(focal_function_coverage.get("lines_missed")) == {18, 19, 6}
45+
assert (
46+
focal_function_coverage.get("executed_branches") > 0
47+
), "executed_branches are a number greater than 0."
48+
assert (
49+
focal_function_coverage.get("total_branches") > 0
50+
), "total_branches are a number greater than 0."
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
# Copyright (c) Microsoft Corporation. All rights reserved.
2+
# Licensed under the MIT License.
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# Copyright (c) Microsoft Corporation. All rights reserved.
2+
# Licensed under the MIT License.
3+
4+
def reverse_string(s):
5+
if s is None or s == "":
6+
return "Error: Input is None"
7+
return s[::-1]
8+
9+
def reverse_sentence(sentence):
10+
if sentence is None or sentence == "":
11+
return "Error: Input is None"
12+
words = sentence.split()
13+
reversed_words = [reverse_string(word) for word in words]
14+
return " ".join(reversed_words)
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# Copyright (c) Microsoft Corporation. All rights reserved.
2+
# Licensed under the MIT License.
3+
4+
import unittest
5+
from reverse import reverse_sentence, reverse_string
6+
7+
class TestReverseFunctions(unittest.TestCase):
8+
9+
def test_reverse_sentence(self):
10+
"""
11+
Tests the reverse_sentence function to ensure it correctly reverses each word in a sentence.
12+
13+
Test cases:
14+
- "hello world" should be reversed to "olleh dlrow"
15+
- "Python is fun" should be reversed to "nohtyP si nuf"
16+
- "a b c" should remain "a b c" as each character is a single word
17+
"""
18+
self.assertEqual(reverse_sentence("hello world"), "olleh dlrow")
19+
self.assertEqual(reverse_sentence("Python is fun"), "nohtyP si nuf")
20+
self.assertEqual(reverse_sentence("a b c"), "a b c")
21+
22+
def test_reverse_sentence_error(self):
23+
self.assertEqual(reverse_sentence(""), "Error: Input is None")
24+
self.assertEqual(reverse_sentence(None), "Error: Input is None")
25+
26+
def test_reverse_string(self):
27+
self.assertEqual(reverse_string("hello"), "olleh")
28+
self.assertEqual(reverse_string("Python"), "nohtyP")
29+
# this test specifically does not cover the error cases
30+
31+
if __name__ == '__main__':
32+
unittest.main()
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
# Copyright (c) Microsoft Corporation. All rights reserved.
2+
# Licensed under the MIT License.
3+
4+
# Copyright (c) Microsoft Corporation. All rights reserved.
5+
# Licensed under the MIT License.
6+
7+
import os
8+
import pathlib
9+
import sys
10+
11+
sys.path.append(os.fspath(pathlib.Path(__file__).parent))
12+
13+
python_files_path = pathlib.Path(__file__).parent.parent.parent
14+
sys.path.insert(0, os.fspath(python_files_path))
15+
sys.path.insert(0, os.fspath(python_files_path / "lib" / "python"))
16+
17+
from tests.pytestadapter import helpers # noqa: E402
18+
19+
TEST_DATA_PATH = pathlib.Path(__file__).parent / ".data"
20+
21+
22+
def test_basic_coverage():
23+
"""This test runs on a simple django project with three tests, two of which pass and one that fails."""
24+
coverage_ex_folder: pathlib.Path = TEST_DATA_PATH / "coverage_ex"
25+
execution_script: pathlib.Path = python_files_path / "unittestadapter" / "execution.py"
26+
test_ids = [
27+
"test_reverse.TestReverseFunctions.test_reverse_sentence",
28+
"test_reverse.TestReverseFunctions.test_reverse_sentence_error",
29+
"test_reverse.TestReverseFunctions.test_reverse_string",
30+
]
31+
argv = [os.fsdecode(execution_script), "--udiscovery", "-vv", "-s", ".", "-p", "*test*.py"]
32+
argv = argv + test_ids
33+
34+
actual = helpers.runner_with_cwd_env(
35+
argv,
36+
coverage_ex_folder,
37+
{"COVERAGE_ENABLED": os.fspath(coverage_ex_folder), "_TEST_VAR_UNITTEST": "True"},
38+
)
39+
40+
assert actual
41+
coverage = actual[-1]
42+
assert coverage
43+
results = coverage["result"]
44+
assert results
45+
assert len(results) == 3
46+
focal_function_coverage = results.get(os.fspath(TEST_DATA_PATH / "coverage_ex" / "reverse.py"))
47+
assert focal_function_coverage
48+
assert focal_function_coverage.get("lines_covered") is not None
49+
assert focal_function_coverage.get("lines_missed") is not None
50+
assert set(focal_function_coverage.get("lines_covered")) == {4, 5, 7, 9, 10, 11, 12, 13, 14}
51+
assert set(focal_function_coverage.get("lines_missed")) == {6}
52+
assert (
53+
focal_function_coverage.get("executed_branches") > 0
54+
), "executed_branches are a number greater than 0."
55+
assert (
56+
focal_function_coverage.get("total_branches") > 0
57+
), "total_branches are a number greater than 0."

0 commit comments

Comments
 (0)