Skip to content

Commit c6fc9f4

Browse files
committed
support pytest-subtests plugin
1 parent 4153f7c commit c6fc9f4

File tree

8 files changed

+141
-7
lines changed

8 files changed

+141
-7
lines changed

build/test-requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,3 +36,4 @@ pytest-json
3636

3737
# for pytest-describe related tests
3838
pytest-describe
39+
pytest-subtests
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# Copyright (c) Microsoft Corporation. All rights reserved.
2+
# Licensed under the MIT License.
3+
4+
def test_a(subtests):
5+
with subtests.test(msg="test_a"):
6+
assert 1 == 1
7+
with subtests.test(msg="Second subtest"):
8+
assert 2 == 1

python_files/tests/pytestadapter/expected_execution_test_output.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
TEST_ADD_FUNCTION = "unittest_folder/test_add.py::TestAddFunction::"
77
SUCCESS = "success"
88
FAILURE = "failure"
9+
SUBTEST_FAILURE = "subtest-failure"
910

1011
# This is the expected output for the unittest_folder execute tests
1112
# └── unittest_folder
@@ -734,3 +735,38 @@
734735
"subtest": None,
735736
},
736737
}
738+
739+
# This is the expected output for the test_pytest_subtests_plugin.py file.
740+
# └── test_pytest_subtests_plugin.py
741+
# └── test_a
742+
# └── test_a [test_a]: subtest-success
743+
# └── test_a [Second subtest]: subtest-failure
744+
test_pytest_subtests_plugin_path = TEST_DATA_PATH / "test_pytest_subtests_plugin.py"
745+
pytest_subtests_plugin_expected_execution_output = {
746+
get_absolute_test_id(
747+
"test_pytest_subtests_plugin.py::test_a**{test_a [test_a]}**",
748+
test_pytest_subtests_plugin_path,
749+
): {
750+
"test": get_absolute_test_id(
751+
"test_pytest_subtests_plugin.py::test_a**{test_a [test_a]}**",
752+
test_pytest_subtests_plugin_path,
753+
),
754+
"outcome": "subtest-success",
755+
"message": None,
756+
"traceback": None,
757+
"subtest": None,
758+
},
759+
get_absolute_test_id(
760+
"test_pytest_subtests_plugin.py::test_a**{test_a [Second subtest]}**",
761+
test_pytest_subtests_plugin_path,
762+
): {
763+
"test": get_absolute_test_id(
764+
"test_pytest_subtests_plugin.py::test_a**{test_a [Second subtest]}**",
765+
test_pytest_subtests_plugin_path,
766+
),
767+
"outcome": SUBTEST_FAILURE,
768+
"message": "ERROR MESSAGE",
769+
"traceback": None,
770+
"subtest": None,
771+
},
772+
}

python_files/tests/pytestadapter/test_execution.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,34 @@ def test_pytest_execution(test_ids, expected_const):
227227
assert actual_result_dict == expected_const
228228

229229

230+
def test_pytest_subtest_plugin():
231+
test_subtest_plugin_path = TEST_DATA_PATH / "test_pytest_subtests_plugin.py"
232+
233+
test_a_id = get_absolute_test_id(
234+
"test_subtest_plugin_path::test_a",
235+
test_subtest_plugin_path,
236+
)
237+
args = [test_a_id]
238+
expected_const = expected_execution_test_output.pytest_subtests_plugin_expected_execution_output
239+
actual = runner(args)
240+
assert actual
241+
actual_list: List[Dict[str, Dict[str, Any]]] = actual
242+
assert len(actual_list) == len(expected_const)
243+
actual_result_dict = {}
244+
if actual_list is not None:
245+
for actual_item in actual_list:
246+
assert all(item in actual_item for item in ("status", "cwd", "result"))
247+
assert actual_item.get("status") == "success"
248+
assert actual_item.get("cwd") == os.fspath(TEST_DATA_PATH)
249+
actual_result_dict.update(actual_item["result"])
250+
for key in actual_result_dict:
251+
if actual_result_dict[key]["outcome"] == "subtest-failure":
252+
actual_result_dict[key]["message"] = "ERROR MESSAGE"
253+
if actual_result_dict[key]["traceback"] is not None:
254+
actual_result_dict[key]["traceback"] = "TRACEBACK"
255+
assert actual_result_dict == expected_const
256+
257+
230258
def test_symlink_run():
231259
"""Test to test pytest discovery with the command line arg --rootdir specified as a symlink path.
232260

python_files/vscode_pytest/__init__.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -278,8 +278,19 @@ def pytest_report_teststatus(report, config): # noqa: ARG001
278278
node_path = map_id_to_path[report.nodeid]
279279
except KeyError:
280280
node_path = cwd
281-
# Calculate the absolute test id and use this as the ID moving forward.
281+
282282
absolute_node_id = get_absolute_test_id(report.nodeid, node_path)
283+
parent_test_name = report.nodeid.split("::")[-1]
284+
if report.head_line and report.head_line != parent_test_name:
285+
# add parent node to collected_tests_so_far to not double report
286+
collected_tests_so_far.append(absolute_node_id)
287+
# If the report has a head_line, then it is a pytest-subtest
288+
# and we need to adjust the nodeid to reflect the subtest.
289+
if report_value == "failure":
290+
report_value = "subtest-failure"
291+
elif report_value == "success":
292+
report_value = "subtest-success"
293+
absolute_node_id = absolute_node_id + "**{" + report.head_line + "}**"
283294
if absolute_node_id not in collected_tests_so_far:
284295
collected_tests_so_far.append(absolute_node_id)
285296
item_result = create_test_outcome(

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -242,7 +242,7 @@ export class PythonResultResolver implements ITestResultResolver {
242242
}
243243
} else if (testItem.outcome === 'subtest-failure') {
244244
// split on [] or () based on how the subtest is setup.
245-
const [parentTestCaseId, subtestId] = splitTestNameWithRegex(keyTemp);
245+
const [parentTestCaseId, subtestId] = splitTestNameWithRegex(keyTemp, this.testProvider);
246246
const parentTestItem = this.runIdToTestItem.get(parentTestCaseId);
247247
const data = testItem;
248248
// find the subtest's parent test item
@@ -280,7 +280,7 @@ export class PythonResultResolver implements ITestResultResolver {
280280
}
281281
} else if (testItem.outcome === 'subtest-success') {
282282
// split on [] or () based on how the subtest is setup.
283-
const [parentTestCaseId, subtestId] = splitTestNameWithRegex(keyTemp);
283+
const [parentTestCaseId, subtestId] = splitTestNameWithRegex(keyTemp, this.testProvider);
284284
const parentTestItem = this.runIdToTestItem.get(parentTestCaseId);
285285

286286
// find the subtest's parent test item

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

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
import { Deferred, createDeferred } from '../../../common/utils/async';
2323
import { createReaderPipe, generateRandomPipeName } from '../../../common/pipes/namedPipes';
2424
import { EXTENSION_ROOT_DIR } from '../../../constants';
25+
import { TestProvider } from '../../types';
2526

2627
export function fixLogLines(content: string): string {
2728
const lines = content.split(/\r?\n/g);
@@ -464,10 +465,15 @@ export function createDiscoveryErrorPayload(
464465
* @param testName The full test name string.
465466
* @returns A tuple where the first item is the parent test name and the second item is the subtest section or `testName` if no subtest section exists.
466467
*/
467-
export function splitTestNameWithRegex(testName: string): [string, string] {
468+
export function splitTestNameWithRegex(testName: string, testProvider: TestProvider): [string, string] {
468469
// If a match is found, return the parent test name and the subtest (whichever was captured between parenthesis or square brackets).
469470
// Otherwise, return the entire testName for the parent and entire testName for the subtest.
470-
const regex = /^(.*?) ([\[(].*[\])])$/;
471+
let regex: RegExp;
472+
if (testProvider === 'pytest') {
473+
regex = /^(.*?)\*\*{(.*?)}\*\*$/;
474+
} else {
475+
regex = /^(.*?) ([\[(].*[\])])$/;
476+
}
471477
const match = testName.match(regex);
472478
if (match) {
473479
return [match[1].trim(), match[2] || match[3] || testName];

src/test/testing/testController/utils.unit.test.ts

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ ${data}${secondPayload}`;
106106
assert.deepStrictEqual(rpcContent.remainingRawData, '');
107107
});
108108

109-
suite('Test Controller Utils: Other', () => {
109+
suite('Test Controller Utils: Unittest', () => {
110110
interface TestCase {
111111
name: string;
112112
input: string;
@@ -155,11 +155,55 @@ ${data}${secondPayload}`;
155155

156156
testCases.forEach((testCase) => {
157157
test(`splitTestNameWithRegex: ${testCase.name}`, () => {
158-
const splitResult = splitTestNameWithRegex(testCase.input);
158+
const splitResult = splitTestNameWithRegex(testCase.input, 'unittest');
159159
assert.deepStrictEqual(splitResult, [testCase.expectedParent, testCase.expectedSubtest]);
160160
});
161161
});
162162
});
163+
164+
suite('Test Controller Utils: Pytest', () => {
165+
interface TestCase {
166+
name: string;
167+
input: string;
168+
expectedParent: string;
169+
expectedSubtest: string;
170+
}
171+
172+
const testCases: Array<TestCase> = [
173+
{
174+
name: 'basic example',
175+
input: 'test_pytest_subtests_plugin.py::test_a**{test_a [Second subtest]}**',
176+
expectedParent: 'test_pytest_subtests_plugin.py::test_a',
177+
expectedSubtest: 'test_a [Second subtest]',
178+
},
179+
{
180+
name: 'duplicate name',
181+
input: 'test_pytest_subtests_plugin.py::test_a**{test_a [test_a]}**',
182+
expectedParent: 'test_pytest_subtests_plugin.py::test_a',
183+
expectedSubtest: 'test_a [test_a]',
184+
},
185+
{
186+
name: 'weird characters name',
187+
input: 'test_pytest_subtests_plugin.py::L34Tc**{test_a111 [test_a]}**',
188+
expectedParent: 'test_pytest_subtests_plugin.py::L34Tc',
189+
expectedSubtest: 'test_a111 [test_a]',
190+
},
191+
{
192+
name: 'name with stars',
193+
input: 'test_pytest_subtests_plugin.py::L34Tc**{test_a111**** [test_a]}**',
194+
expectedParent: 'test_pytest_subtests_plugin.py::L34Tc',
195+
expectedSubtest: 'test_a111**** [test_a]',
196+
},
197+
];
198+
199+
testCases.forEach((testCase) => {
200+
test(`splitTestNameWithRegex: ${testCase.name}`, () => {
201+
const splitResult = splitTestNameWithRegex(testCase.input, 'pytest');
202+
assert.deepStrictEqual(splitResult, [testCase.expectedParent, testCase.expectedSubtest]);
203+
});
204+
});
205+
});
206+
163207
suite('Test Controller Utils: Args Mapping', () => {
164208
suite('addValueIfKeyNotExist', () => {
165209
test('should add key-value pair if key does not exist', () => {

0 commit comments

Comments
 (0)