From 51b0a7f02e9455c23fd1488d5a9580ff09cbf952 Mon Sep 17 00:00:00 2001 From: Srikanth Palutla <37922378+sripal2013@users.noreply.github.com> Date: Wed, 28 Oct 2026 14:16:46 -0500 Subject: [PATCH] feat(testing): expose pytest marks as tags in Test Explorer Extract pytest marks during test discovery and expose them as VS Code TestTags (mark.), so tests can be filtered by mark in the Test Explorer (e.g. @python-tests:mark.slow). Marks are deduplicated preserving discovery order, and the parametrize marker is excluded. Items from adapters that do not report marks keep only the default run/debug tags. Fixes #20350 --- .../tests/pytestadapter/.data/test_marks.py | 31 ++++ .../expected_discovery_test_output.py | 145 ++++++++++++++++++ .../tests/pytestadapter/test_discovery.py | 32 ++++ python_files/vscode_pytest/__init__.py | 13 ++ .../testing/testController/common/types.ts | 3 + .../testing/testController/common/utils.ts | 6 +- .../testing/testController/utils.unit.test.ts | 101 ++++++++++++ 7 files changed, 329 insertions(+), 2 deletions(-) create mode 100644 python_files/tests/pytestadapter/.data/test_marks.py diff --git a/python_files/tests/pytestadapter/.data/test_marks.py b/python_files/tests/pytestadapter/.data/test_marks.py new file mode 100644 index 000000000000..25a5e708b4a3 --- /dev/null +++ b/python_files/tests/pytestadapter/.data/test_marks.py @@ -0,0 +1,31 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import pytest + + +@pytest.mark.slow # test_marker--test_with_single_mark +def test_with_single_mark(): + assert True + + +@pytest.mark.slow # test_marker--test_with_multiple_marks +@pytest.mark.integration +def test_with_multiple_marks(): + assert True + + +def test_with_no_marks(): # test_marker--test_with_no_marks + assert True + + +@pytest.mark.slow # test_marker--test_with_duplicate_marks +@pytest.mark.slow +def test_with_duplicate_marks(): + assert True + + +@pytest.mark.parametrize("x", [1, 2]) # test_marker--test_parametrize_with_mark +@pytest.mark.slow +def test_parametrize_with_mark(x): + assert x > 0 diff --git a/python_files/tests/pytestadapter/expected_discovery_test_output.py b/python_files/tests/pytestadapter/expected_discovery_test_output.py index 047f1c72ad17..fcd5f8e7b65a 100644 --- a/python_files/tests/pytestadapter/expected_discovery_test_output.py +++ b/python_files/tests/pytestadapter/expected_discovery_test_output.py @@ -1094,6 +1094,7 @@ "tests/test_a.py::test_a_function", SYMLINK_FOLDER_PATH_TESTS_TEST_A, ), + "tags": [], } ], }, @@ -1119,6 +1120,7 @@ "tests/test_b.py::test_b_function", SYMLINK_FOLDER_PATH_TESTS_TEST_B, ), + "tags": [], } ], }, @@ -2081,3 +2083,146 @@ ], "id_": os.fspath(unittest_folder_path), } + +# This is the expected output for the test_marks.py file. +# └── test_marks.py +# └── test_with_single_mark (tags: ["slow"]) +# └── test_with_multiple_marks (tags: ["integration", "slow"]) +# └── test_with_no_marks (tags: []) +# └── test_with_duplicate_marks (tags: ["slow"]) +# └── test_parametrize_with_mark (function) +# └── [1] (tags: ["slow"]) +# └── [2] (tags: ["slow"]) +marks_test_file_path = TEST_DATA_PATH / "test_marks.py" +marks_test_expected_output = { + "name": ".data", + "path": TEST_DATA_PATH_STR, + "type_": "folder", + "children": [ + { + "name": "test_marks.py", + "path": os.fspath(marks_test_file_path), + "type_": "file", + "id_": os.fspath(marks_test_file_path), + "children": [ + { + "name": "test_with_single_mark", + "path": os.fspath(marks_test_file_path), + "lineno": find_test_line_number( + "test_with_single_mark", + marks_test_file_path, + ), + "type_": "test", + "id_": get_absolute_test_id( + "test_marks.py::test_with_single_mark", + marks_test_file_path, + ), + "runID": get_absolute_test_id( + "test_marks.py::test_with_single_mark", + marks_test_file_path, + ), + "tags": ["slow"], + }, + { + "name": "test_with_multiple_marks", + "path": os.fspath(marks_test_file_path), + "lineno": find_test_line_number( + "test_with_multiple_marks", + marks_test_file_path, + ), + "type_": "test", + "id_": get_absolute_test_id( + "test_marks.py::test_with_multiple_marks", + marks_test_file_path, + ), + "runID": get_absolute_test_id( + "test_marks.py::test_with_multiple_marks", + marks_test_file_path, + ), + "tags": ["integration", "slow"], + }, + { + "name": "test_with_no_marks", + "path": os.fspath(marks_test_file_path), + "lineno": find_test_line_number( + "test_with_no_marks", + marks_test_file_path, + ), + "type_": "test", + "id_": get_absolute_test_id( + "test_marks.py::test_with_no_marks", + marks_test_file_path, + ), + "runID": get_absolute_test_id( + "test_marks.py::test_with_no_marks", + marks_test_file_path, + ), + "tags": [], + }, + { + "name": "test_with_duplicate_marks", + "path": os.fspath(marks_test_file_path), + "lineno": find_test_line_number( + "test_with_duplicate_marks", + marks_test_file_path, + ), + "type_": "test", + "id_": get_absolute_test_id( + "test_marks.py::test_with_duplicate_marks", + marks_test_file_path, + ), + "runID": get_absolute_test_id( + "test_marks.py::test_with_duplicate_marks", + marks_test_file_path, + ), + "tags": ["slow"], + }, + { + "name": "test_parametrize_with_mark", + "path": os.fspath(marks_test_file_path), + "type_": "function", + "id_": os.fspath(marks_test_file_path) + "::test_parametrize_with_mark", + "children": [ + { + "name": "[1]", + "path": os.fspath(marks_test_file_path), + "lineno": find_test_line_number( + "test_parametrize_with_mark[1]", + marks_test_file_path, + ), + "type_": "test", + "id_": get_absolute_test_id( + "test_marks.py::test_parametrize_with_mark[1]", + marks_test_file_path, + ), + "runID": get_absolute_test_id( + "test_marks.py::test_parametrize_with_mark[1]", + marks_test_file_path, + ), + "tags": ["slow"], + }, + { + "name": "[2]", + "path": os.fspath(marks_test_file_path), + "lineno": find_test_line_number( + "test_parametrize_with_mark[2]", + marks_test_file_path, + ), + "type_": "test", + "id_": get_absolute_test_id( + "test_marks.py::test_parametrize_with_mark[2]", + marks_test_file_path, + ), + "runID": get_absolute_test_id( + "test_marks.py::test_parametrize_with_mark[2]", + marks_test_file_path, + ), + "tags": ["slow"], + }, + ], + }, + ], + } + ], + "id_": TEST_DATA_PATH_STR, +} diff --git a/python_files/tests/pytestadapter/test_discovery.py b/python_files/tests/pytestadapter/test_discovery.py index 7e7336b87c65..7b56aa957ec3 100644 --- a/python_files/tests/pytestadapter/test_discovery.py +++ b/python_files/tests/pytestadapter/test_discovery.py @@ -339,6 +339,38 @@ def test_pytest_collect(file, expected_const): ) +def test_pytest_marks_discovery(): + """Test that pytest marks are exposed as tags during discovery. + + Covers single marks, multiple marks (in discovery order), unmarked + tests, duplicate marks (deduplicated), and parametrized tests with + marks, excluding the `parametrize` marker itself. + """ + actual = helpers.runner( + [ + os.fspath(helpers.TEST_DATA_PATH / "test_marks.py"), + "--collect-only", + ] + ) + + assert actual + actual_list: List[Dict[str, Any]] = actual + if actual_list is not None: + actual_item = actual_list.pop(0) + assert all(item in actual_item for item in ("status", "cwd", "error")) + assert actual_item.get("status") == "success", ( + f"Status is not 'success', error is: {actual_item.get('error')}" + ) + assert actual_item.get("cwd") == os.fspath(helpers.TEST_DATA_PATH) + assert is_same_tree( + actual_item.get("tests"), + expected_discovery_test_output.marks_test_expected_output, + ["id_", "lineno", "name", "runID", "tags"], + ), ( + f"Tests tree does not match expected value. \\n Expected: {json.dumps(expected_discovery_test_output.marks_test_expected_output, indent=4)}. \\n Actual: {json.dumps(actual_item.get('tests'), indent=4)}" + ) + + @pytest.mark.skipif( sys.platform == "win32", reason="See https://stackoverflow.com/questions/32877260/privlege-error-trying-to-create-symlink-using-python-on-windows-10", diff --git a/python_files/vscode_pytest/__init__.py b/python_files/vscode_pytest/__init__.py index 88f6327f6e8b..0cd59725e443 100644 --- a/python_files/vscode_pytest/__init__.py +++ b/python_files/vscode_pytest/__init__.py @@ -59,6 +59,7 @@ class TestItem(TestData): lineno: str runID: str + tags: list[str] class TestNode(TestData): @@ -847,6 +848,17 @@ def create_test_node( str(test_case.location[1] + 1) if (test_case.location[1] is not None) else "" ) absolute_test_id = get_absolute_test_id(test_case.nodeid, get_node_path(test_case)) + + # Extract pytest marks as tags (deduplicated, preserving discovery order). + # `parametrize` is excluded: it describes parameterization, not a user mark. + tags: list[str] = list( + dict.fromkeys( + marker.name + for marker in (getattr(test_case, "own_markers", None) or []) + if marker.name and marker.name != "parametrize" + ) + ) + return { "name": test_case.name, "path": get_node_path(test_case), @@ -854,6 +866,7 @@ def create_test_node( "type_": "test", "id_": absolute_test_id, "runID": absolute_test_id, + "tags": tags, } diff --git a/src/client/testing/testController/common/types.ts b/src/client/testing/testController/common/types.ts index 93a0639a7ba8..29a252361ec7 100644 --- a/src/client/testing/testController/common/types.ts +++ b/src/client/testing/testController/common/types.ts @@ -205,6 +205,9 @@ export type DiscoveredTestCommon = { export type DiscoveredTestItem = DiscoveredTestCommon & { lineno: number | string; runID: string; + // Pytest marks exposed as tags (e.g. ["slow", "integration"]). + // Absent for items from adapters that do not report marks. + tags?: string[]; }; export type DiscoveredTestNode = DiscoveredTestCommon & { diff --git a/src/client/testing/testController/common/utils.ts b/src/client/testing/testController/common/utils.ts index 583d57648548..b71fb0aca732 100644 --- a/src/client/testing/testController/common/utils.ts +++ b/src/client/testing/testController/common/utils.ts @@ -282,7 +282,10 @@ export function populateTestTree( // Create project-scoped vsId const vsId = projectId ? `${projectId}${PROJECT_ID_SEPARATOR}${child.id_}` : child.id_; const testItem = testController.createTestItem(vsId, child.name, Uri.file(child.path)); - testItem.tags = [RunTestTag, DebugTestTag]; + + // Create tags from pytest marks (if available) and combine with default tags. + const pytestMarkTags = (child.tags ?? []).map((tag) => ({ id: `mark.${tag}` })); + testItem.tags = [RunTestTag, DebugTestTag, ...pytestMarkTags]; let range: Range | undefined; if (child.lineno) { @@ -297,7 +300,6 @@ export function populateTestTree( } testItem.canResolveChildren = false; testItem.range = range; - testItem.tags = [RunTestTag, DebugTestTag]; testRoot!.children.add(testItem); // add to our map - use runID as key, vsId as value diff --git a/src/test/testing/testController/utils.unit.test.ts b/src/test/testing/testController/utils.unit.test.ts index 0fae5ffbcee2..7f5dd4df334d 100644 --- a/src/test/testing/testController/utils.unit.test.ts +++ b/src/test/testing/testController/utils.unit.test.ts @@ -262,6 +262,107 @@ suite('populateTestTree tests', () => { assert.deepStrictEqual(mockTestItem.tags, [RunTestTag, DebugTestTag]); }); + test('should convert pytest mark tags to TestTags on TestItems', () => { + // Arrange + // Tree structure: + // RootWorkspaceFolder (folder) + // └── test_example (test, tags: ["slow", "integration"]) + const testItem: DiscoveredTestItem = { + path: '/test/path/test.py', + name: 'test_example', + type_: 'test', + id_: 'test-id', + lineno: 10, + runID: 'run-id-123', + tags: ['slow', 'integration'], + }; + + const testTreeData: DiscoveredTestNode = { + path: '/test/path/root', + name: 'RootWorkspaceFolder', + type_: 'folder', + id_: 'root-id', + children: [testItem], + }; + + const childrenAddStub = sandbox.stub(); + const mockRootItem: TestItem = { + id: 'root-id', + children: { + add: childrenAddStub, + }, + } as any; + + const mockTestItem: TestItem = { + id: 'test-id', + label: 'test_example', + uri: Uri.file('/test/path/test.py'), + canResolveChildren: false, + tags: [], + range: undefined, + } as any; + + createTestItemStub.returns(mockTestItem); + + // Act + populateTestTree(testController, testTreeData, mockRootItem, resultResolver, cancelationToken); + + // Assert + assert.ok(childrenAddStub.calledOnceWith(mockTestItem)); + assert.deepStrictEqual(mockTestItem.tags, [ + RunTestTag, + DebugTestTag, + { id: 'mark.slow' }, + { id: 'mark.integration' }, + ]); + }); + + test('should keep only default tags when no pytest mark tags are present', () => { + // Arrange + const testItem: DiscoveredTestItem = { + path: '/test/path/test.py', + name: 'test_example', + type_: 'test', + id_: 'test-id', + lineno: 10, + runID: 'run-id-123', + }; + + const testTreeData: DiscoveredTestNode = { + path: '/test/path/root', + name: 'RootWorkspaceFolder', + type_: 'folder', + id_: 'root-id', + children: [testItem], + }; + + const childrenAddStub = sandbox.stub(); + const mockRootItem: TestItem = { + id: 'root-id', + children: { + add: childrenAddStub, + }, + } as any; + + const mockTestItem: TestItem = { + id: 'test-id', + label: 'test_example', + uri: Uri.file('/test/path/test.py'), + canResolveChildren: false, + tags: [], + range: undefined, + } as any; + + createTestItemStub.returns(mockTestItem); + + // Act + populateTestTree(testController, testTreeData, mockRootItem, resultResolver, cancelationToken); + + // Assert + assert.ok(childrenAddStub.calledOnceWith(mockTestItem)); + assert.deepStrictEqual(mockTestItem.tags, [RunTestTag, DebugTestTag]); + }); + test('should create TestItem with correct range when lineno is provided', () => { // Arrange const testItem: DiscoveredTestItem = {