From b25daeeb99da1417e83e158ec77f5cd44fad05b1 Mon Sep 17 00:00:00 2001 From: Andrew Lavin Date: Sat, 17 Jan 2026 22:51:37 -0800 Subject: [PATCH 1/4] feat(testing): expose pytest marks as tags in Test Explorer Extract pytest marks (e.g., @pytest.mark.slow, @pytest.mark.integration) during test discovery and expose them as VS Code TestTags with IDs like "mark.slow", "mark.integration". This enables filtering tests by marks in the Test Explorer UI using @python-tests:mark.slow syntax. Changes: - Add tags field to TestItem TypedDict in pytest plugin - Extract marks from test_case.own_markers in create_test_node() - Add tags field to DiscoveredTestItem TypeScript type - Create TestTag objects from marks in populateTestTree() Fixes microsoft/vscode-python#20350 Co-Authored-By: Claude Opus 4.5 --- python_files/vscode_pytest/__init__.py | 10 ++++++++++ src/client/testing/testController/common/types.ts | 1 + src/client/testing/testController/common/utils.ts | 6 ++++-- 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/python_files/vscode_pytest/__init__.py b/python_files/vscode_pytest/__init__.py index 89565dab1264..935a83360fdd 100644 --- a/python_files/vscode_pytest/__init__.py +++ b/python_files/vscode_pytest/__init__.py @@ -56,6 +56,7 @@ class TestItem(TestData): lineno: str runID: str + tags: list[str] class TestNode(TestData): @@ -816,6 +817,14 @@ 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 + tags: list[str] = [] + if hasattr(test_case, "own_markers"): + for marker in test_case.own_markers: + if marker.name and marker.name != "parametrize": + tags.append(marker.name) + return { "name": test_case.name, "path": get_node_path(test_case), @@ -823,6 +832,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 6121b3e24442..783b2b307d54 100644 --- a/src/client/testing/testController/common/types.ts +++ b/src/client/testing/testController/common/types.ts @@ -190,6 +190,7 @@ export type DiscoveredTestCommon = { export type DiscoveredTestItem = DiscoveredTestCommon & { lineno: number | string; runID: string; + 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 606865e5ad7e..8594dcb40973 100644 --- a/src/client/testing/testController/common/utils.ts +++ b/src/client/testing/testController/common/utils.ts @@ -227,7 +227,10 @@ export function populateTestTree( if (!token?.isCancellationRequested) { if (isTestItem(child)) { const testItem = testController.createTestItem(child.id_, 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) { @@ -242,7 +245,6 @@ export function populateTestTree( } testItem.canResolveChildren = false; testItem.range = range; - testItem.tags = [RunTestTag, DebugTestTag]; testRoot!.children.add(testItem); // add to our map From 97ffb9638d143a2b31ae87f3e81b267178a2d5dc Mon Sep 17 00:00:00 2001 From: Andrew Lavin Date: Tue, 20 Jan 2026 13:34:58 -0800 Subject: [PATCH 2/4] fix(testing): deduplicate pytest marks when extracting tags Prevents duplicate tags when the same marker is applied multiple times or through plugin interactions. Uses dict.fromkeys() to preserve order while deduplicating. Co-Authored-By: Claude Opus 4.5 --- python_files/vscode_pytest/__init__.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/python_files/vscode_pytest/__init__.py b/python_files/vscode_pytest/__init__.py index 935a83360fdd..d87d91b75875 100644 --- a/python_files/vscode_pytest/__init__.py +++ b/python_files/vscode_pytest/__init__.py @@ -818,12 +818,11 @@ def create_test_node( ) absolute_test_id = get_absolute_test_id(test_case.nodeid, get_node_path(test_case)) - # Extract pytest marks as tags - tags: list[str] = [] - if hasattr(test_case, "own_markers"): - for marker in test_case.own_markers: - if marker.name and marker.name != "parametrize": - tags.append(marker.name) + # Extract pytest marks as tags (deduplicated, preserving order) + 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, From 2407f30a50eed297c442c436dc7598551158af8a Mon Sep 17 00:00:00 2001 From: Andrew Lavin Date: Mon, 2 Feb 2026 21:19:24 -0800 Subject: [PATCH 3/4] style: fix ruff formatting in vscode_pytest/__init__.py Co-Authored-By: Claude Opus 4.5 --- python_files/vscode_pytest/__init__.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/python_files/vscode_pytest/__init__.py b/python_files/vscode_pytest/__init__.py index d87d91b75875..02917a33f14e 100644 --- a/python_files/vscode_pytest/__init__.py +++ b/python_files/vscode_pytest/__init__.py @@ -819,10 +819,13 @@ def create_test_node( absolute_test_id = get_absolute_test_id(test_case.nodeid, get_node_path(test_case)) # Extract pytest marks as tags (deduplicated, preserving order) - 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" - )) + 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, From 6138eda3a9a827a02ada4b49a612f02bb77adaa7 Mon Sep 17 00:00:00 2001 From: Andrew Lavin Date: Mon, 2 Feb 2026 23:12:21 -0800 Subject: [PATCH 4/4] test(testing): add tests for pytest marks as tags feature Add TypeScript unit tests verifying populateTestTree correctly converts tags to TestTag objects with mark. prefix, and handles empty/undefined tags. Add Python discovery test verifying mark extraction, deduplication, parametrize filtering, and tag ordering. Co-Authored-By: Claude Opus 4.5 --- .../tests/pytestadapter/.data/test_marks.py | 31 ++++ .../expected_discovery_test_output.py | 143 ++++++++++++++++++ .../tests/pytestadapter/test_discovery.py | 33 ++++ .../testing/testController/utils.unit.test.ts | 136 +++++++++++++++++ 4 files changed, 343 insertions(+) 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 b6f0779cf982..295367ae5265 100644 --- a/python_files/tests/pytestadapter/expected_discovery_test_output.py +++ b/python_files/tests/pytestadapter/expected_discovery_test_output.py @@ -1776,6 +1776,149 @@ black_formatter_folder_path = TEST_DATA_PATH / "2496-black-formatter" black_app_path = black_formatter_folder_path / "app.py" black_test_app_path = black_formatter_folder_path / "test_app.py" +# 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: ["slow", "integration"]) +# └── 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", + 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", + 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, +} + black_formatter_expected_output = { "name": ".data", "path": TEST_DATA_PATH_STR, diff --git a/python_files/tests/pytestadapter/test_discovery.py b/python_files/tests/pytestadapter/test_discovery.py index 842ee3c7c707..fa2d1a4b769e 100644 --- a/python_files/tests/pytestadapter/test_discovery.py +++ b/python_files/tests/pytestadapter/test_discovery.py @@ -208,6 +208,39 @@ def test_pytest_collect(file, expected_const): ) +def test_pytest_marks_as_tags(): + """Test that pytest marks are extracted as tags during discovery. + + Verifies that: + - Single marks produce a single tag + - Multiple marks produce multiple tags + - Duplicate marks are deduplicated + - @pytest.mark.parametrize is excluded from tags + - Tests with no marks have an empty tags list + """ + actual = helpers.runner( + [ + os.fspath(helpers.TEST_DATA_PATH / "test_marks.py"), + "--collect-only", + ] + ) + + assert actual + actual_list: List[Dict[str, Any]] = actual + actual_item = actual_list.pop(0) + 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/src/test/testing/testController/utils.unit.test.ts b/src/test/testing/testController/utils.unit.test.ts index c6d9a70831a9..b96391ffc1c9 100644 --- a/src/test/testing/testController/utils.unit.test.ts +++ b/src/test/testing/testController/utils.unit.test.ts @@ -625,6 +625,142 @@ suite('populateTestTree tests', () => { assert.deepStrictEqual(mockNestedNode.tags, [RunTestTag, DebugTestTag]); assert.deepStrictEqual(mockTestItem.tags, [RunTestTag, DebugTestTag]); }); + + test('should add pytest mark tags with mark. prefix to test items', () => { + // Arrange + const testItem: DiscoveredTestItem = { + path: '/test/path/test.py', + name: 'test_marked', + type_: 'test', + id_: 'test-marked-id', + lineno: 5, + runID: 'run-marked', + tags: ['slow', 'integration'], + }; + + const testTreeData: DiscoveredTestNode = { + path: '/test/path/root', + name: 'RootTest', + type_: 'folder', + id_: 'root-id', + children: [testItem], + }; + + const mockRootItem: TestItem = { + id: 'root-id', + tags: [], + canResolveChildren: true, + children: { add: sandbox.stub() }, + } as any; + + const mockTestItem: TestItem = { + id: 'test-marked-id', + tags: [], + canResolveChildren: false, + } as any; + + createTestItemStub.onCall(0).returns(mockRootItem); + createTestItemStub.onCall(1).returns(mockTestItem); + + // Act + populateTestTree(testController, testTreeData, undefined, resultResolver, cancelationToken); + + // Assert + assert.deepStrictEqual(mockRootItem.tags, [RunTestTag, DebugTestTag]); + assert.deepStrictEqual(mockTestItem.tags, [ + RunTestTag, + DebugTestTag, + { id: 'mark.slow' }, + { id: 'mark.integration' }, + ]); + }); + + test('should handle test items with empty tags array', () => { + // Arrange + const testItem: DiscoveredTestItem = { + path: '/test/path/test.py', + name: 'test_no_tags', + type_: 'test', + id_: 'test-no-tags-id', + lineno: 5, + runID: 'run-no-tags', + tags: [], + }; + + const testTreeData: DiscoveredTestNode = { + path: '/test/path/root', + name: 'RootTest', + type_: 'folder', + id_: 'root-id', + children: [testItem], + }; + + const mockRootItem: TestItem = { + id: 'root-id', + tags: [], + canResolveChildren: true, + children: { add: sandbox.stub() }, + } as any; + + const mockTestItem: TestItem = { + id: 'test-no-tags-id', + tags: [], + canResolveChildren: false, + } as any; + + createTestItemStub.onCall(0).returns(mockRootItem); + createTestItemStub.onCall(1).returns(mockTestItem); + + // Act + populateTestTree(testController, testTreeData, undefined, resultResolver, cancelationToken); + + // Assert + assert.deepStrictEqual(mockTestItem.tags, [RunTestTag, DebugTestTag]); + }); + + test('should handle test items with undefined tags', () => { + // Arrange + const testItem: DiscoveredTestItem = { + path: '/test/path/test.py', + name: 'test_undef_tags', + type_: 'test', + id_: 'test-undef-id', + lineno: 5, + runID: 'run-undef', + // tags intentionally omitted + }; + + const testTreeData: DiscoveredTestNode = { + path: '/test/path/root', + name: 'RootTest', + type_: 'folder', + id_: 'root-id', + children: [testItem], + }; + + const mockRootItem: TestItem = { + id: 'root-id', + tags: [], + canResolveChildren: true, + children: { add: sandbox.stub() }, + } as any; + + const mockTestItem: TestItem = { + id: 'test-undef-id', + tags: [], + canResolveChildren: false, + } as any; + + createTestItemStub.onCall(0).returns(mockRootItem); + createTestItemStub.onCall(1).returns(mockTestItem); + + // Act + populateTestTree(testController, testTreeData, undefined, resultResolver, cancelationToken); + + // Assert + assert.deepStrictEqual(mockTestItem.tags, [RunTestTag, DebugTestTag]); + }); + test('should handle a test node with no lineno property', () => { // Arrange // Tree structure: