Skip to content

Commit 5cc9aac

Browse files
test discovery 2.0 (#25760)
## Summary This PR implements **project-based test discovery** for pytest, enabling multi-project workspace support. When the Python Environments API is available, the extension now discovers Python projects within workspaces and creates separate test tree roots for each project with its own Python environment. ## What's New ### Project-Based Testing Architecture - **TestProjectRegistry**: Manages the lifecycle of Python test projects, including: - Discovering projects via Python Environments API - Creating ProjectAdapter instances per workspace - Computing nested project relationships for ignore lists - Fallback to "legacy" single-adapter mode when API unavailable - **ProjectAdapter**: Interface representing a single Python project with test infrastructure: - Project identity (ID, name, URI) - Python environment from the environments API - Test framework adapters (discovery/execution) - Nested project ignore paths ### Key Features - ✅ **Multi-project workspaces**: Each Python project gets its own test tree root - ✅ **Nested project handling**: Parent projects automatically ignore nested child projects via `--ignore` flags - ✅ **Graceful fallback**: Falls back to legacy single-adapter mode if Python Environments API is unavailable - ✅ **Project root path**: Python-side `get_test_root_path()` function returns appropriate root for test tree ### Code Improvements - Standardized logging prefixes to `[test-by-project]` across all files - Centralized adapter creation via `createTestAdapters()` helper method - Extracted reusable methods for discovery, execution, and file watching ## Scope & Limitations > **⚠️ Important: unittest is NOT supported in this PR** > > This PR focuses exclusively on **pytest**. unittest support for project-based testing will be implemented in a future PR. ## Testing - Added unit tests for `TestProjectRegistry` class - Added unit tests for Python-side `get_test_root_path()` function - Manual testing with multi-project workspaces ## Related Issues first step in: microsoft/vscode-python-environments#987 --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
1 parent 0b477a3 commit 5cc9aac

File tree

18 files changed

+2387
-98
lines changed

18 files changed

+2387
-98
lines changed

.github/instructions/testing-workflow.instructions.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -578,3 +578,4 @@ envConfig.inspect
578578
- When mocking `testController.createTestItem()` in unit tests, use `typemoq.It.isAny()` for parameters when testing handler behavior (not ID/label generation logic), but consider using specific matchers (e.g., `It.is((id: string) => id.startsWith('_error_'))`) when the actual values being passed are important for correctness - this balances test precision with maintainability (2)
579579
- Remove unused variables from test code immediately - leftover tracking variables like `validationCallCount` that aren't referenced indicate dead code that should be simplified (1)
580580
- Use `Uri.file(path).fsPath` for both sides of path comparisons in tests to ensure cross-platform compatibility - Windows converts forward slashes to backslashes automatically (1)
581+
- When tests fail with "Cannot stub non-existent property", the method likely moved to a different class during refactoring - find the class that owns the method and test that class directly instead of stubbing on the original class (1)

.github/instructions/testing_feature_area.instructions.md

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ This document maps the testing support in the extension: discovery, execution (r
2626
- `src/client/testing/serviceRegistry.ts` — DI/wiring for testing services.
2727
- Workspace orchestration
2828
- `src/client/testing/testController/workspaceTestAdapter.ts``WorkspaceTestAdapter` (provider-agnostic entry used by controller).
29+
- **Project-based testing (multi-project workspaces)**
30+
- `src/client/testing/testController/common/testProjectRegistry.ts``TestProjectRegistry` (manages project lifecycle, discovery, and nested project handling).
31+
- `src/client/testing/testController/common/projectAdapter.ts``ProjectAdapter` interface (represents a single Python project with its own test infrastructure).
32+
- `src/client/testing/testController/common/projectUtils.ts` — utilities for project ID generation, display names, and shared adapter creation.
2933
- Provider adapters
3034
- Unittest
3135
- `src/client/testing/testController/unittest/testDiscoveryAdapter.ts`
@@ -151,6 +155,51 @@ The adapters in the extension don't implement test discovery/run logic themselve
151155
- Settings are consumed by `src/client/testing/common/testConfigurationManager.ts`, `src/client/testing/configurationFactory.ts`, and adapters under `src/client/testing/testController/*` which read settings to build CLI args and env for subprocesses.
152156
- The setting definitions and descriptions are in `package.json` and localized strings in `package.nls.json`.
153157

158+
## Project-based testing (multi-project workspaces)
159+
160+
Project-based testing enables multi-project workspace support where each Python project gets its own test tree root with its own Python environment.
161+
162+
> **⚠️ Note: unittest support for project-based testing is NOT yet implemented.** Project-based testing currently only works with pytest. unittest support will be added in a future PR.
163+
164+
### Architecture
165+
166+
- **TestProjectRegistry** (`testProjectRegistry.ts`): Central registry that:
167+
168+
- Discovers Python projects via the Python Environments API
169+
- Creates and manages `ProjectAdapter` instances per workspace
170+
- Computes nested project relationships and configures ignore lists
171+
- Falls back to "legacy" single-adapter mode when API unavailable
172+
173+
- **ProjectAdapter** (`projectAdapter.ts`): Interface representing a single project with:
174+
- Project identity (ID, name, URI from Python Environments API)
175+
- Python environment with execution details
176+
- Test framework adapters (discovery/execution)
177+
- Nested project ignore paths (for parent projects)
178+
179+
### How it works
180+
181+
1. **Activation**: When the extension activates, `PythonTestController` checks if the Python Environments API is available.
182+
2. **Project discovery**: `TestProjectRegistry.discoverAndRegisterProjects()` queries the API for all Python projects in each workspace.
183+
3. **Nested handling**: `configureNestedProjectIgnores()` identifies child projects and adds their paths to parent projects' ignore lists.
184+
4. **Test discovery**: For each project, the controller calls `project.discoveryAdapter.discoverTests()` with the project's URI. The adapter sets `PROJECT_ROOT_PATH` environment variable for the Python runner.
185+
5. **Python side**: `get_test_root_path()` in `vscode_pytest/__init__.py` returns `PROJECT_ROOT_PATH` (if set) or falls back to `cwd`.
186+
6. **Test tree**: Each project gets its own root node in the Test Explorer, with test IDs scoped by project ID using the `||` separator.
187+
188+
### Logging prefix
189+
190+
All project-based testing logs use the `[test-by-project]` prefix for easy filtering in the output channel.
191+
192+
### Key files
193+
194+
- Python side: `python_files/vscode_pytest/__init__.py``get_test_root_path()` function and `PROJECT_ROOT_PATH` environment variable.
195+
- TypeScript: `testProjectRegistry.ts`, `projectAdapter.ts`, `projectUtils.ts`, and the discovery adapters.
196+
197+
### Tests
198+
199+
- `src/test/testing/testController/common/testProjectRegistry.unit.test.ts` — TestProjectRegistry tests
200+
- `src/test/testing/testController/common/projectUtils.unit.test.ts` — Project utility function tests
201+
- `python_files/tests/pytestadapter/test_get_test_root_path.py` — Python-side get_test_root_path() tests
202+
154203
## Coverage support (how it works)
155204

156205
- Coverage is supported by running the Python helper scripts with coverage enabled and then collecting a coverage payload from the runner.

python_files/tests/pytestadapter/expected_discovery_test_output.py

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1870,3 +1870,214 @@
18701870
],
18711871
"id_": TEST_DATA_PATH_STR,
18721872
}
1873+
1874+
# =====================================================================================
1875+
# PROJECT_ROOT_PATH environment variable tests
1876+
# These test the project-based testing feature where PROJECT_ROOT_PATH changes
1877+
# the test tree root from cwd to the specified project path.
1878+
# =====================================================================================
1879+
1880+
# This is the expected output for unittest_folder when PROJECT_ROOT_PATH is set to unittest_folder.
1881+
# The root of the tree is unittest_folder (not .data), simulating project-based testing.
1882+
#
1883+
# **Project Configuration:**
1884+
# In the VS Code Python extension, projects are defined by the Python Environments extension.
1885+
# Each project has a root directory (identified by pyproject.toml, setup.py, etc.).
1886+
# When PROJECT_ROOT_PATH is set, pytest uses that path as the test tree root instead of cwd.
1887+
#
1888+
# **Test Tree Structure:**
1889+
# Without PROJECT_ROOT_PATH (legacy mode):
1890+
# └── .data (cwd = workspace root)
1891+
# └── unittest_folder
1892+
# └── test_add.py, test_subtract.py...
1893+
#
1894+
# With PROJECT_ROOT_PATH set to unittest_folder (project-based mode):
1895+
# └── unittest_folder (ROOT - set via PROJECT_ROOT_PATH env var)
1896+
# ├── test_add.py
1897+
# │ └── TestAddFunction
1898+
# │ ├── test_add_negative_numbers
1899+
# │ └── test_add_positive_numbers
1900+
# │ └── TestDuplicateFunction
1901+
# │ └── test_dup_a
1902+
# └── test_subtract.py
1903+
# └── TestSubtractFunction
1904+
# ├── test_subtract_negative_numbers
1905+
# └── test_subtract_positive_numbers
1906+
# └── TestDuplicateFunction
1907+
# └── test_dup_s
1908+
#
1909+
# Note: This reuses the unittest_folder paths defined earlier in this file.
1910+
project_root_unittest_folder_expected_output = {
1911+
"name": "unittest_folder",
1912+
"path": os.fspath(unittest_folder_path),
1913+
"type_": "folder",
1914+
"children": [
1915+
{
1916+
"name": "test_add.py",
1917+
"path": os.fspath(test_add_path),
1918+
"type_": "file",
1919+
"id_": os.fspath(test_add_path),
1920+
"children": [
1921+
{
1922+
"name": "TestAddFunction",
1923+
"path": os.fspath(test_add_path),
1924+
"type_": "class",
1925+
"children": [
1926+
{
1927+
"name": "test_add_negative_numbers",
1928+
"path": os.fspath(test_add_path),
1929+
"lineno": find_test_line_number(
1930+
"test_add_negative_numbers",
1931+
os.fspath(test_add_path),
1932+
),
1933+
"type_": "test",
1934+
"id_": get_absolute_test_id(
1935+
"test_add.py::TestAddFunction::test_add_negative_numbers",
1936+
test_add_path,
1937+
),
1938+
"runID": get_absolute_test_id(
1939+
"test_add.py::TestAddFunction::test_add_negative_numbers",
1940+
test_add_path,
1941+
),
1942+
},
1943+
{
1944+
"name": "test_add_positive_numbers",
1945+
"path": os.fspath(test_add_path),
1946+
"lineno": find_test_line_number(
1947+
"test_add_positive_numbers",
1948+
os.fspath(test_add_path),
1949+
),
1950+
"type_": "test",
1951+
"id_": get_absolute_test_id(
1952+
"test_add.py::TestAddFunction::test_add_positive_numbers",
1953+
test_add_path,
1954+
),
1955+
"runID": get_absolute_test_id(
1956+
"test_add.py::TestAddFunction::test_add_positive_numbers",
1957+
test_add_path,
1958+
),
1959+
},
1960+
],
1961+
"id_": get_absolute_test_id(
1962+
"test_add.py::TestAddFunction",
1963+
test_add_path,
1964+
),
1965+
"lineno": find_class_line_number("TestAddFunction", test_add_path),
1966+
},
1967+
{
1968+
"name": "TestDuplicateFunction",
1969+
"path": os.fspath(test_add_path),
1970+
"type_": "class",
1971+
"children": [
1972+
{
1973+
"name": "test_dup_a",
1974+
"path": os.fspath(test_add_path),
1975+
"lineno": find_test_line_number(
1976+
"test_dup_a",
1977+
os.fspath(test_add_path),
1978+
),
1979+
"type_": "test",
1980+
"id_": get_absolute_test_id(
1981+
"test_add.py::TestDuplicateFunction::test_dup_a",
1982+
test_add_path,
1983+
),
1984+
"runID": get_absolute_test_id(
1985+
"test_add.py::TestDuplicateFunction::test_dup_a",
1986+
test_add_path,
1987+
),
1988+
},
1989+
],
1990+
"id_": get_absolute_test_id(
1991+
"test_add.py::TestDuplicateFunction",
1992+
test_add_path,
1993+
),
1994+
"lineno": find_class_line_number("TestDuplicateFunction", test_add_path),
1995+
},
1996+
],
1997+
},
1998+
{
1999+
"name": "test_subtract.py",
2000+
"path": os.fspath(test_subtract_path),
2001+
"type_": "file",
2002+
"id_": os.fspath(test_subtract_path),
2003+
"children": [
2004+
{
2005+
"name": "TestSubtractFunction",
2006+
"path": os.fspath(test_subtract_path),
2007+
"type_": "class",
2008+
"children": [
2009+
{
2010+
"name": "test_subtract_negative_numbers",
2011+
"path": os.fspath(test_subtract_path),
2012+
"lineno": find_test_line_number(
2013+
"test_subtract_negative_numbers",
2014+
os.fspath(test_subtract_path),
2015+
),
2016+
"type_": "test",
2017+
"id_": get_absolute_test_id(
2018+
"test_subtract.py::TestSubtractFunction::test_subtract_negative_numbers",
2019+
test_subtract_path,
2020+
),
2021+
"runID": get_absolute_test_id(
2022+
"test_subtract.py::TestSubtractFunction::test_subtract_negative_numbers",
2023+
test_subtract_path,
2024+
),
2025+
},
2026+
{
2027+
"name": "test_subtract_positive_numbers",
2028+
"path": os.fspath(test_subtract_path),
2029+
"lineno": find_test_line_number(
2030+
"test_subtract_positive_numbers",
2031+
os.fspath(test_subtract_path),
2032+
),
2033+
"type_": "test",
2034+
"id_": get_absolute_test_id(
2035+
"test_subtract.py::TestSubtractFunction::test_subtract_positive_numbers",
2036+
test_subtract_path,
2037+
),
2038+
"runID": get_absolute_test_id(
2039+
"test_subtract.py::TestSubtractFunction::test_subtract_positive_numbers",
2040+
test_subtract_path,
2041+
),
2042+
},
2043+
],
2044+
"id_": get_absolute_test_id(
2045+
"test_subtract.py::TestSubtractFunction",
2046+
test_subtract_path,
2047+
),
2048+
"lineno": find_class_line_number("TestSubtractFunction", test_subtract_path),
2049+
},
2050+
{
2051+
"name": "TestDuplicateFunction",
2052+
"path": os.fspath(test_subtract_path),
2053+
"type_": "class",
2054+
"children": [
2055+
{
2056+
"name": "test_dup_s",
2057+
"path": os.fspath(test_subtract_path),
2058+
"lineno": find_test_line_number(
2059+
"test_dup_s",
2060+
os.fspath(test_subtract_path),
2061+
),
2062+
"type_": "test",
2063+
"id_": get_absolute_test_id(
2064+
"test_subtract.py::TestDuplicateFunction::test_dup_s",
2065+
test_subtract_path,
2066+
),
2067+
"runID": get_absolute_test_id(
2068+
"test_subtract.py::TestDuplicateFunction::test_dup_s",
2069+
test_subtract_path,
2070+
),
2071+
},
2072+
],
2073+
"id_": get_absolute_test_id(
2074+
"test_subtract.py::TestDuplicateFunction",
2075+
test_subtract_path,
2076+
),
2077+
"lineno": find_class_line_number("TestDuplicateFunction", test_subtract_path),
2078+
},
2079+
],
2080+
},
2081+
],
2082+
"id_": os.fspath(unittest_folder_path),
2083+
}

python_files/tests/pytestadapter/test_discovery.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -386,3 +386,97 @@ def test_plugin_collect(file, expected_const, extra_arg):
386386
), (
387387
f"Tests tree does not match expected value. \n Expected: {json.dumps(expected_const, indent=4)}. \n Actual: {json.dumps(actual_item.get('tests'), indent=4)}"
388388
)
389+
390+
391+
def test_project_root_path_env_var():
392+
"""Test pytest discovery with PROJECT_ROOT_PATH environment variable set.
393+
394+
This simulates project-based testing where the test tree root should be
395+
the project root (PROJECT_ROOT_PATH) rather than the workspace cwd.
396+
397+
When PROJECT_ROOT_PATH is set:
398+
- The test tree root (name, path, id_) should match PROJECT_ROOT_PATH
399+
- The cwd in the response should match PROJECT_ROOT_PATH
400+
- Test files should be direct children of the root (not nested under a subfolder)
401+
"""
402+
# Use unittest_folder as our "project" subdirectory
403+
project_path = helpers.TEST_DATA_PATH / "unittest_folder"
404+
405+
actual = helpers.runner_with_cwd_env(
406+
[os.fspath(project_path), "--collect-only"],
407+
helpers.TEST_DATA_PATH, # cwd is parent of project
408+
{"PROJECT_ROOT_PATH": os.fspath(project_path)}, # Set project root
409+
)
410+
411+
assert actual
412+
actual_list: List[Dict[str, Any]] = actual
413+
if actual_list is not None:
414+
actual_item = actual_list.pop(0)
415+
416+
assert all(item in actual_item for item in ("status", "cwd", "error"))
417+
assert actual_item.get("status") == "success", (
418+
f"Status is not 'success', error is: {actual_item.get('error')}"
419+
)
420+
# cwd in response should be PROJECT_ROOT_PATH
421+
assert actual_item.get("cwd") == os.fspath(project_path), (
422+
f"Expected cwd '{os.fspath(project_path)}', got '{actual_item.get('cwd')}'"
423+
)
424+
assert is_same_tree(
425+
actual_item.get("tests"),
426+
expected_discovery_test_output.project_root_unittest_folder_expected_output,
427+
["id_", "lineno", "name", "runID"],
428+
), (
429+
f"Tests tree does not match expected value. \n Expected: {json.dumps(expected_discovery_test_output.project_root_unittest_folder_expected_output, indent=4)}. \n Actual: {json.dumps(actual_item.get('tests'), indent=4)}"
430+
)
431+
432+
433+
@pytest.mark.skipif(
434+
sys.platform == "win32",
435+
reason="Symlinks require elevated privileges on Windows",
436+
)
437+
def test_symlink_with_project_root_path():
438+
"""Test pytest discovery with both symlink and PROJECT_ROOT_PATH set.
439+
440+
This tests the combination of:
441+
1. A symlinked test directory (--rootdir points to symlink)
442+
2. PROJECT_ROOT_PATH set to the symlink path
443+
444+
This simulates project-based testing where the project root is a symlink,
445+
ensuring test IDs and paths are correctly resolved through the symlink.
446+
"""
447+
with helpers.create_symlink(helpers.TEST_DATA_PATH, "root", "symlink_folder") as (
448+
source,
449+
destination,
450+
):
451+
assert destination.is_symlink()
452+
453+
# Run pytest with:
454+
# - cwd being the resolved symlink path (simulating subprocess from node)
455+
# - PROJECT_ROOT_PATH set to the symlink destination
456+
actual = helpers.runner_with_cwd_env(
457+
["--collect-only", f"--rootdir={os.fspath(destination)}"],
458+
source, # cwd is the resolved (non-symlink) path
459+
{"PROJECT_ROOT_PATH": os.fspath(destination)}, # Project root is the symlink
460+
)
461+
462+
expected = expected_discovery_test_output.symlink_expected_discovery_output
463+
assert actual
464+
actual_list: List[Dict[str, Any]] = actual
465+
if actual_list is not None:
466+
actual_item = actual_list.pop(0)
467+
try:
468+
assert all(item in actual_item for item in ("status", "cwd", "error")), (
469+
"Required keys are missing"
470+
)
471+
assert actual_item.get("status") == "success", (
472+
f"Status is not 'success', error is: {actual_item.get('error')}"
473+
)
474+
# cwd should be the PROJECT_ROOT_PATH (the symlink destination)
475+
assert actual_item.get("cwd") == os.fspath(destination), (
476+
f"CWD does not match symlink path: expected {os.fspath(destination)}, got {actual_item.get('cwd')}"
477+
)
478+
assert actual_item.get("tests") == expected, "Tests do not match expected value"
479+
except AssertionError as e:
480+
# Print the actual_item in JSON format if an assertion fails
481+
print(json.dumps(actual_item, indent=4))
482+
pytest.fail(str(e))

0 commit comments

Comments
 (0)