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/python_files/vscode_pytest/__init__.py b/python_files/vscode_pytest/__init__.py index 89565dab1264..02917a33f14e 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,16 @@ 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 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, "path": get_node_path(test_case), @@ -823,6 +834,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 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: