Skip to content

Commit 5104ff6

Browse files
Unittest discovery with py-env Projects Support (#25767)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
1 parent 2d53b1f commit 5104ff6

File tree

16 files changed

+613
-18
lines changed

16 files changed

+613
-18
lines changed

.github/instructions/testing_feature_area.instructions.md

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -159,8 +159,6 @@ The adapters in the extension don't implement test discovery/run logic themselve
159159

160160
Project-based testing enables multi-project workspace support where each Python project gets its own test tree root with its own Python environment.
161161

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-
164162
### Architecture
165163

166164
- **TestProjectRegistry** (`testProjectRegistry.ts`): Central registry that:
@@ -182,23 +180,52 @@ Project-based testing enables multi-project workspace support where each Python
182180
2. **Project discovery**: `TestProjectRegistry.discoverAndRegisterProjects()` queries the API for all Python projects in each workspace.
183181
3. **Nested handling**: `configureNestedProjectIgnores()` identifies child projects and adds their paths to parent projects' ignore lists.
184182
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.
183+
5. **Python side**:
184+
- For pytest: `get_test_root_path()` in `vscode_pytest/__init__.py` returns `PROJECT_ROOT_PATH` (if set) or falls back to `cwd`.
185+
- For unittest: `discovery.py` uses `PROJECT_ROOT_PATH` as `top_level_dir` and `project_root_path` to root the test tree at the project directory.
186+
6. **Test tree**: Each project gets its own root node in the Test Explorer, with test IDs scoped by project ID using the `@@vsc@@` separator (defined in `projectUtils.ts`).
187+
188+
### Nested project handling: pytest vs unittest
189+
190+
**pytest** supports the `--ignore` flag to exclude paths during test collection. When nested projects are detected, parent projects automatically receive `--ignore` flags for child project paths. This ensures each test appears under exactly one project in the test tree.
191+
192+
**unittest** does not support path exclusion during `discover()`. Therefore, tests in nested project directories may appear under multiple project roots (both the parent and the child project). This is **expected behavior** for unittest:
193+
194+
- Each project discovers and displays all tests it finds within its directory structure
195+
- There is no deduplication or collision detection
196+
- Users may see the same test file under multiple project roots if their project structure has nesting
197+
198+
This approach was chosen because:
199+
200+
1. unittest's `TestLoader.discover()` has no built-in path exclusion mechanism
201+
2. Implementing custom exclusion would add significant complexity with minimal benefit
202+
3. The existing approach is transparent and predictable - each project shows what it finds
203+
204+
### Empty projects and root nodes
205+
206+
If a project discovers zero tests, its root node will still appear in the Test Explorer as an empty folder. This ensures consistent behavior and makes it clear which projects were discovered, even if they have no tests yet.
187207

188208
### Logging prefix
189209

190210
All project-based testing logs use the `[test-by-project]` prefix for easy filtering in the output channel.
191211

192212
### Key files
193213

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.
214+
- Python side:
215+
- `python_files/vscode_pytest/__init__.py``get_test_root_path()` function and `PROJECT_ROOT_PATH` environment variable for pytest.
216+
- `python_files/unittestadapter/discovery.py``discover_tests()` with `project_root_path` parameter and `PROJECT_ROOT_PATH` handling for unittest discovery.
217+
- `python_files/unittestadapter/execution.py``run_tests()` with `project_root_path` parameter and `PROJECT_ROOT_PATH` handling for unittest execution.
218+
- TypeScript: `testProjectRegistry.ts`, `projectAdapter.ts`, `projectUtils.ts`, and the discovery/execution adapters.
196219

197220
### Tests
198221

199222
- `src/test/testing/testController/common/testProjectRegistry.unit.test.ts` — TestProjectRegistry tests
200223
- `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
224+
- `python_files/tests/pytestadapter/test_discovery.py` — pytest PROJECT_ROOT_PATH tests (see `test_project_root_path_env_var()` and `test_symlink_with_project_root_path()`)
225+
- `python_files/tests/unittestadapter/test_discovery.py` — unittest `project_root_path` / PROJECT_ROOT_PATH discovery tests
226+
- `python_files/tests/unittestadapter/test_execution.py` — unittest `project_root_path` / PROJECT_ROOT_PATH execution tests
227+
- `src/test/testing/testController/unittest/testDiscoveryAdapter.unit.test.ts` — unittest discovery adapter PROJECT_ROOT_PATH tests
228+
- `src/test/testing/testController/unittest/testExecutionAdapter.unit.test.ts` — unittest execution adapter PROJECT_ROOT_PATH tests
202229

203230
## Coverage support (how it works)
204231

python_files/tests/unittestadapter/test_discovery.py

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,3 +325,123 @@ def test_simple_django_collect():
325325
assert (
326326
len(actual_item["tests"]["children"][0]["children"][0]["children"][0]["children"]) == 3
327327
)
328+
329+
330+
def test_project_root_path_with_cwd_override() -> None:
331+
"""Test unittest discovery with project_root_path parameter.
332+
333+
This simulates project-based testing where the cwd in the payload should be
334+
the project root (project_root_path) rather than the start_dir.
335+
336+
When project_root_path is provided:
337+
- The cwd in the response should match project_root_path
338+
- The test tree root should still be built correctly based on top_level_dir
339+
"""
340+
# Use unittest_skip folder as our "project" directory
341+
project_path = TEST_DATA_PATH / "unittest_skip"
342+
start_dir = os.fsdecode(project_path)
343+
pattern = "unittest_*"
344+
345+
# Call discover_tests with project_root_path to simulate PROJECT_ROOT_PATH
346+
actual = discover_tests(start_dir, pattern, None, project_root_path=start_dir)
347+
348+
assert actual["status"] == "success"
349+
# cwd in response should match the project_root_path (project root)
350+
assert actual["cwd"] == os.fsdecode(project_path), (
351+
f"Expected cwd '{os.fsdecode(project_path)}', got '{actual['cwd']}'"
352+
)
353+
assert "tests" in actual
354+
# Verify the test tree structure matches expected output
355+
assert is_same_tree(
356+
actual.get("tests"),
357+
expected_discovery_test_output.skip_unittest_folder_discovery_output,
358+
["id_", "lineno", "name"],
359+
)
360+
assert "error" not in actual
361+
362+
363+
def test_project_root_path_with_different_cwd_and_start_dir() -> None:
364+
"""Test unittest discovery where project_root_path differs from start_dir.
365+
366+
This simulates the scenario where:
367+
- start_dir points to a subfolder where tests are located
368+
- project_root_path (PROJECT_ROOT_PATH) points to the project root
369+
370+
The cwd in the response should be the project root, while discovery
371+
still runs from the start_dir.
372+
"""
373+
# Use utils_complex_tree as our test case - discovery from a subfolder
374+
project_path = TEST_DATA_PATH / "utils_complex_tree"
375+
start_dir = os.fsdecode(
376+
pathlib.PurePath(
377+
TEST_DATA_PATH,
378+
"utils_complex_tree",
379+
"test_outer_folder",
380+
"test_inner_folder",
381+
)
382+
)
383+
pattern = "test_*.py"
384+
top_level_dir = os.fsdecode(project_path)
385+
386+
# Call discover_tests with project_root_path set to project root
387+
actual = discover_tests(start_dir, pattern, top_level_dir, project_root_path=top_level_dir)
388+
389+
assert actual["status"] == "success"
390+
# cwd should be the project root (project_root_path), not the start_dir
391+
assert actual["cwd"] == os.fsdecode(project_path), (
392+
f"Expected cwd '{os.fsdecode(project_path)}', got '{actual['cwd']}'"
393+
)
394+
assert "error" not in actual
395+
# Test tree should still be structured correctly with top_level_dir as root
396+
assert is_same_tree(
397+
actual.get("tests"),
398+
expected_discovery_test_output.complex_tree_expected_output,
399+
["id_", "lineno", "name"],
400+
)
401+
402+
403+
@pytest.mark.skipif(
404+
sys.platform == "win32",
405+
reason="Symlinks require elevated privileges on Windows",
406+
)
407+
def test_symlink_with_project_root_path() -> None:
408+
"""Test unittest discovery with both symlink and PROJECT_ROOT_PATH set.
409+
410+
This tests the combination of:
411+
1. A symlinked test directory
412+
2. project_root_path (PROJECT_ROOT_PATH) set to the symlink path
413+
414+
This simulates project-based testing where the project root is a symlink,
415+
ensuring test IDs and paths are correctly resolved through the symlink.
416+
"""
417+
with helpers.create_symlink(TEST_DATA_PATH, "unittest_skip", "symlink_unittest") as (
418+
_source,
419+
destination,
420+
):
421+
assert destination.is_symlink()
422+
423+
# Run discovery with:
424+
# - start_dir pointing to the symlink destination
425+
# - project_root_path set to the symlink destination (simulating PROJECT_ROOT_PATH)
426+
start_dir = os.fsdecode(destination)
427+
pattern = "unittest_*"
428+
429+
actual = discover_tests(start_dir, pattern, None, project_root_path=start_dir)
430+
431+
assert actual["status"] == "success", (
432+
f"Status is not 'success', error is: {actual.get('error')}"
433+
)
434+
# cwd should be the symlink path (project_root_path)
435+
assert actual["cwd"] == os.fsdecode(destination), (
436+
f"CWD does not match symlink path: expected {os.fsdecode(destination)}, got {actual['cwd']}"
437+
)
438+
assert "tests" in actual
439+
assert actual["tests"] is not None
440+
# The test tree root should be named after the symlink directory
441+
assert actual["tests"]["name"] == "symlink_unittest", (
442+
f"Expected root name 'symlink_unittest', got '{actual['tests']['name']}'"
443+
)
444+
# The test tree root path should use the symlink path
445+
assert actual["tests"]["path"] == os.fsdecode(destination), (
446+
f"Expected root path to be symlink, got '{actual['tests']['path']}'"
447+
)

python_files/tests/unittestadapter/test_execution.py

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,3 +341,134 @@ def test_basic_run_django():
341341
assert id_result["outcome"] == "failure"
342342
else:
343343
assert id_result["outcome"] == "success"
344+
345+
346+
def test_project_root_path_with_cwd_override(mock_send_run_data) -> None: # noqa: ARG001
347+
"""Test unittest execution with project_root_path parameter.
348+
349+
This simulates project-based testing where the cwd in the payload should be
350+
the project root (project_root_path) rather than the start_dir.
351+
352+
When project_root_path is provided:
353+
- The cwd in the response should match project_root_path
354+
- Test execution should still work correctly with start_dir
355+
"""
356+
# Use unittest_folder as our "project" directory
357+
project_path = TEST_DATA_PATH / "unittest_folder"
358+
start_dir = os.fsdecode(project_path)
359+
pattern = "test_add*"
360+
test_ids = [
361+
"test_add.TestAddFunction.test_add_positive_numbers",
362+
]
363+
364+
os.environ["TEST_RUN_PIPE"] = "fake"
365+
366+
# Call run_tests with project_root_path to simulate PROJECT_ROOT_PATH
367+
actual = run_tests(
368+
start_dir,
369+
test_ids,
370+
pattern,
371+
None,
372+
1,
373+
None,
374+
project_root_path=start_dir,
375+
)
376+
377+
assert actual["status"] == "success"
378+
# cwd in response should match the project_root_path (project root)
379+
assert actual["cwd"] == os.fsdecode(project_path), (
380+
f"Expected cwd '{os.fsdecode(project_path)}', got '{actual['cwd']}'"
381+
)
382+
assert actual["result"] is not None
383+
assert test_ids[0] in actual["result"]
384+
assert actual["result"][test_ids[0]]["outcome"] == "success"
385+
386+
387+
def test_project_root_path_with_different_cwd_and_start_dir(mock_send_run_data) -> None: # noqa: ARG001
388+
"""Test unittest execution where project_root_path differs from start_dir.
389+
390+
This simulates the scenario where:
391+
- start_dir points to a subfolder where tests are located
392+
- project_root_path (PROJECT_ROOT_PATH) points to the project root
393+
394+
The cwd in the response should be the project root, while execution
395+
still runs from the start_dir.
396+
"""
397+
# Use utils_nested_cases as our test case
398+
project_path = TEST_DATA_PATH / "utils_nested_cases"
399+
start_dir = os.fsdecode(project_path)
400+
pattern = "*"
401+
test_ids = [
402+
"file_one.CaseTwoFileOne.test_one",
403+
]
404+
405+
os.environ["TEST_RUN_PIPE"] = "fake"
406+
407+
# Call run_tests with project_root_path set to project root
408+
actual = run_tests(
409+
start_dir,
410+
test_ids,
411+
pattern,
412+
None,
413+
1,
414+
None,
415+
project_root_path=os.fsdecode(project_path),
416+
)
417+
418+
assert actual["status"] == "success"
419+
# cwd should be the project root (project_root_path)
420+
assert actual["cwd"] == os.fsdecode(project_path), (
421+
f"Expected cwd '{os.fsdecode(project_path)}', got '{actual['cwd']}'"
422+
)
423+
assert actual["result"] is not None
424+
assert test_ids[0] in actual["result"]
425+
426+
427+
@pytest.mark.skipif(
428+
sys.platform == "win32",
429+
reason="Symlinks require elevated privileges on Windows",
430+
)
431+
def test_symlink_with_project_root_path(mock_send_run_data) -> None: # noqa: ARG001
432+
"""Test unittest execution with both symlink and project_root_path set.
433+
434+
This tests the combination of:
435+
1. A symlinked test directory
436+
2. project_root_path (PROJECT_ROOT_PATH) set to the symlink path
437+
438+
This simulates project-based testing where the project root is a symlink,
439+
ensuring execution payloads correctly use the symlink path.
440+
"""
441+
with helpers.create_symlink(TEST_DATA_PATH, "unittest_folder", "symlink_unittest_exec") as (
442+
_source,
443+
destination,
444+
):
445+
assert destination.is_symlink()
446+
447+
# Run execution with:
448+
# - start_dir pointing to the symlink destination
449+
# - project_root_path set to the symlink destination (simulating PROJECT_ROOT_PATH)
450+
start_dir = os.fsdecode(destination)
451+
pattern = "test_add*"
452+
test_ids = [
453+
"test_add.TestAddFunction.test_add_positive_numbers",
454+
]
455+
456+
os.environ["TEST_RUN_PIPE"] = "fake"
457+
458+
actual = run_tests(
459+
start_dir,
460+
test_ids,
461+
pattern,
462+
None,
463+
1,
464+
None,
465+
project_root_path=start_dir,
466+
)
467+
468+
assert actual["status"] == "success", (
469+
f"Status is not 'success', error is: {actual.get('error')}"
470+
)
471+
# cwd should be the symlink path (project_root_path)
472+
assert actual["cwd"] == os.fsdecode(destination), (
473+
f"CWD does not match symlink path: expected {os.fsdecode(destination)}, got {actual['cwd']}"
474+
)

python_files/unittestadapter/discovery.py

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,13 @@ def discover_tests(
2727
start_dir: str,
2828
pattern: str,
2929
top_level_dir: Optional[str],
30+
project_root_path: Optional[str] = None,
3031
) -> DiscoveryPayloadDict:
3132
"""Returns a dictionary containing details of the discovered tests.
3233
3334
The returned dict has the following keys:
3435
35-
- cwd: Absolute path to the test start directory;
36+
- cwd: Absolute path to the test start directory (or project_root_path if provided);
3637
- status: Test discovery status, can be "success" or "error";
3738
- tests: Discoverered tests if any, not present otherwise. Note that the status can be "error" but the payload can still contain tests;
3839
- error: Discovery error if any, not present otherwise.
@@ -56,8 +57,15 @@ def discover_tests(
5657
"": [list of errors]
5758
"status": "error",
5859
}
60+
61+
Args:
62+
start_dir: Directory where test discovery starts
63+
pattern: Pattern to match test files (e.g., "test*.py")
64+
top_level_dir: Top-level directory for the test tree hierarchy
65+
project_root_path: Optional project root path for the cwd in the response payload
66+
(used for project-based testing to root test tree at project)
5967
"""
60-
cwd = os.path.abspath(start_dir) # noqa: PTH100
68+
cwd = os.path.abspath(project_root_path or start_dir) # noqa: PTH100
6169
if "/" in start_dir: # is a subdir
6270
parent_dir = os.path.dirname(start_dir) # noqa: PTH120
6371
sys.path.insert(0, parent_dir)
@@ -133,7 +141,19 @@ def discover_tests(
133141
print(error_msg, file=sys.stderr)
134142
raise VSCodeUnittestError(error_msg) # noqa: B904
135143
else:
144+
# Check for PROJECT_ROOT_PATH environment variable (project-based testing).
145+
# When set, this overrides top_level_dir to root the test tree at the project directory.
146+
project_root_path = os.environ.get("PROJECT_ROOT_PATH")
147+
if project_root_path:
148+
top_level_dir = project_root_path
149+
print(
150+
f"PROJECT_ROOT_PATH is set, using {project_root_path} as top_level_dir for discovery"
151+
)
152+
136153
# Perform regular unittest test discovery.
137-
payload = discover_tests(start_dir, pattern, top_level_dir)
154+
# Pass project_root_path so the payload's cwd matches the project root.
155+
payload = discover_tests(
156+
start_dir, pattern, top_level_dir, project_root_path=project_root_path
157+
)
138158
# Post this discovery payload.
139159
send_post_request(payload, test_run_pipe)

0 commit comments

Comments
 (0)