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 = {