Skip to content

Commit b0504d1

Browse files
committed
updates
1 parent 802f93b commit b0504d1

8 files changed

Lines changed: 329 additions & 5 deletions

File tree

.github/instructions/testing_feature_area.instructions.md

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,22 @@ Project-based testing enables multi-project workspace support where each Python
185185
- For unittest: `discovery.py` uses `PROJECT_ROOT_PATH` as `top_level_dir` and `cwd_override` to root the test tree at the project directory.
186186
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`).
187187

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+
188204
### Logging prefix
189205

190206
All project-based testing logs use the `[test-by-project]` prefix for easy filtering in the output channel.
@@ -193,15 +209,19 @@ All project-based testing logs use the `[test-by-project]` prefix for easy filte
193209

194210
- Python side:
195211
- `python_files/vscode_pytest/__init__.py``get_test_root_path()` function and `PROJECT_ROOT_PATH` environment variable for pytest.
196-
- `python_files/unittestadapter/discovery.py``discover_tests()` with `cwd_override` parameter and `PROJECT_ROOT_PATH` handling for unittest.
197-
- TypeScript: `testProjectRegistry.ts`, `projectAdapter.ts`, `projectUtils.ts`, and the discovery adapters.
212+
- `python_files/unittestadapter/discovery.py``discover_tests()` with `cwd_override` parameter and `PROJECT_ROOT_PATH` handling for unittest discovery.
213+
- `python_files/unittestadapter/execution.py``run_tests()` with `cwd_override` parameter and `PROJECT_ROOT_PATH` handling for unittest execution.
214+
- TypeScript: `testProjectRegistry.ts`, `projectAdapter.ts`, `projectUtils.ts`, and the discovery/execution adapters.
198215

199216
### Tests
200217

201218
- `src/test/testing/testController/common/testProjectRegistry.unit.test.ts` — TestProjectRegistry tests
202219
- `src/test/testing/testController/common/projectUtils.unit.test.ts` — Project utility function tests
203220
- `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()`)
204-
- `python_files/tests/unittestadapter/test_discovery.py` — unittest `cwd_override` / PROJECT_ROOT_PATH tests
221+
- `python_files/tests/unittestadapter/test_discovery.py` — unittest `cwd_override` / PROJECT_ROOT_PATH discovery tests
222+
- `python_files/tests/unittestadapter/test_execution.py` — unittest `cwd_override` / PROJECT_ROOT_PATH execution tests
223+
- `src/test/testing/testController/unittest/testDiscoveryAdapter.unit.test.ts` — unittest discovery adapter PROJECT_ROOT_PATH tests
224+
- `src/test/testing/testController/unittest/testExecutionAdapter.unit.test.ts` — unittest execution adapter PROJECT_ROOT_PATH tests
205225

206226
## Coverage support (how it works)
207227

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 cwd_override parameter.
348+
349+
This simulates project-based testing where the cwd in the payload should be
350+
the project root (cwd_override) rather than the start_dir.
351+
352+
When cwd_override is provided:
353+
- The cwd in the response should match cwd_override
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 cwd_override to simulate PROJECT_ROOT_PATH
367+
actual = run_tests(
368+
start_dir,
369+
test_ids,
370+
pattern,
371+
None,
372+
1,
373+
None,
374+
cwd_override=start_dir,
375+
)
376+
377+
assert actual["status"] == "success"
378+
# cwd in response should match the cwd_override (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() -> None:
388+
"""Test unittest execution where cwd_override differs from start_dir.
389+
390+
This simulates the scenario where:
391+
- start_dir points to a subfolder where tests are located
392+
- cwd_override (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 cwd_override set to project root
408+
actual = run_tests(
409+
start_dir,
410+
test_ids,
411+
pattern,
412+
None,
413+
1,
414+
None,
415+
cwd_override=os.fsdecode(project_path),
416+
)
417+
418+
assert actual["status"] == "success"
419+
# cwd should be the project root (cwd_override)
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 cwd_override set.
433+
434+
This tests the combination of:
435+
1. A symlinked test directory
436+
2. cwd_override (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+
# - cwd_override 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+
cwd_override=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 (cwd_override)
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/execution.py

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@
3636
ErrorType = Union[Tuple[Type[BaseException], BaseException, TracebackType], Tuple[None, None, None]]
3737
test_run_pipe = ""
3838
START_DIR = ""
39+
# PROJECT_ROOT_PATH: Used for project-based testing to override cwd in payload
40+
# When set, this should be used as the cwd in all execution payloads
41+
PROJECT_ROOT_PATH = None # type: Optional[str]
3942

4043

4144
class TestOutcomeEnum(str, enum.Enum):
@@ -191,8 +194,22 @@ def run_tests(
191194
verbosity: int,
192195
failfast: Optional[bool], # noqa: FBT001
193196
locals_: Optional[bool] = None, # noqa: FBT001
197+
cwd_override: Optional[str] = None,
194198
) -> ExecutionPayloadDict:
195-
cwd = os.path.abspath(start_dir) # noqa: PTH100
199+
"""Run unittests and return the execution payload.
200+
201+
Args:
202+
start_dir: Directory where test discovery starts
203+
test_ids: List of test IDs to run
204+
pattern: Pattern to match test files
205+
top_level_dir: Top-level directory for test tree hierarchy
206+
verbosity: Verbosity level for test output
207+
failfast: Stop on first failure
208+
locals_: Show local variables in tracebacks
209+
cwd_override: Optional override for the cwd in the response payload
210+
(used for project-based testing to set project root)
211+
"""
212+
cwd = os.path.abspath(cwd_override or start_dir) # noqa: PTH100
196213
if "/" in start_dir: # is a subdir
197214
parent_dir = os.path.dirname(start_dir) # noqa: PTH120
198215
sys.path.insert(0, parent_dir)
@@ -259,7 +276,8 @@ def run_tests(
259276

260277
def send_run_data(raw_data, test_run_pipe):
261278
status = raw_data["outcome"]
262-
cwd = os.path.abspath(START_DIR) # noqa: PTH100
279+
# Use PROJECT_ROOT_PATH if set (project-based testing), otherwise use START_DIR
280+
cwd = os.path.abspath(PROJECT_ROOT_PATH or START_DIR) # noqa: PTH100
263281
test_id = raw_data["subtest"] or raw_data["test"]
264282
test_dict = {}
265283
test_dict[test_id] = raw_data
@@ -348,7 +366,19 @@ def send_run_data(raw_data, test_run_pipe):
348366
args = argv[index + 1 :] or []
349367
django_execution_runner(manage_py_path, test_ids, args)
350368
else:
369+
# Check for PROJECT_ROOT_PATH environment variable (project-based testing).
370+
# When set, this overrides the cwd in the payload to match the project root.
371+
project_root_path = os.environ.get("PROJECT_ROOT_PATH")
372+
if project_root_path:
373+
# Update the module-level variable for send_run_data to use
374+
# pylint: disable=global-statement
375+
globals()["PROJECT_ROOT_PATH"] = project_root_path
376+
print(
377+
f"PROJECT_ROOT_PATH is set, using {project_root_path} as cwd for execution payload"
378+
)
379+
351380
# Perform regular unittest execution.
381+
# Pass project_root_path as cwd_override so the payload's cwd matches the project root.
352382
payload = run_tests(
353383
start_dir,
354384
test_ids,
@@ -357,6 +387,7 @@ def send_run_data(raw_data, test_run_pipe):
357387
verbosity,
358388
failfast,
359389
locals_,
390+
cwd_override=project_root_path,
360391
)
361392

362393
if is_coverage_run:

src/client/testing/testController/common/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,7 @@ export interface ITestExecutionAdapter {
183183
executionFactory: IPythonExecutionFactory,
184184
debugLauncher?: ITestDebugLauncher,
185185
interpreter?: PythonEnvironment,
186+
project?: ProjectAdapter,
186187
): Promise<void>;
187188
}
188189

src/client/testing/testController/pytest/pytestExecutionAdapter.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import * as utils from '../common/utils';
2121
import { IEnvironmentVariablesProvider } from '../../../common/variables/types';
2222
import { PythonEnvironment } from '../../../pythonEnvironments/info';
2323
import { getEnvironment, runInBackground, useEnvExtension } from '../../../envExt/api.internal';
24+
import { ProjectAdapter } from '../common/projectAdapter';
2425

2526
export class PytestTestExecutionAdapter implements ITestExecutionAdapter {
2627
constructor(
@@ -37,6 +38,7 @@ export class PytestTestExecutionAdapter implements ITestExecutionAdapter {
3738
executionFactory: IPythonExecutionFactory,
3839
debugLauncher?: ITestDebugLauncher,
3940
interpreter?: PythonEnvironment,
41+
project?: ProjectAdapter,
4042
): Promise<void> {
4143
const deferredTillServerClose: Deferred<void> = utils.createTestingDeferred();
4244

@@ -71,6 +73,7 @@ export class PytestTestExecutionAdapter implements ITestExecutionAdapter {
7173
executionFactory,
7274
debugLauncher,
7375
interpreter,
76+
project,
7477
);
7578
} finally {
7679
await deferredTillServerClose.promise;
@@ -87,6 +90,7 @@ export class PytestTestExecutionAdapter implements ITestExecutionAdapter {
8790
executionFactory: IPythonExecutionFactory,
8891
debugLauncher?: ITestDebugLauncher,
8992
interpreter?: PythonEnvironment,
93+
project?: ProjectAdapter,
9094
): Promise<ExecutionTestPayload> {
9195
const relativePathToPytest = 'python_files';
9296
const fullPluginPath = path.join(EXTENSION_ROOT_DIR, relativePathToPytest);
@@ -102,6 +106,13 @@ export class PytestTestExecutionAdapter implements ITestExecutionAdapter {
102106
const pythonPathCommand = [fullPluginPath, ...pythonPathParts].join(path.delimiter);
103107
mutableEnv.PYTHONPATH = pythonPathCommand;
104108
mutableEnv.TEST_RUN_PIPE = resultNamedPipeName;
109+
110+
// Set PROJECT_ROOT_PATH for project-based testing (tells Python where to root the test tree)
111+
if (project) {
112+
mutableEnv.PROJECT_ROOT_PATH = project.projectUri.fsPath;
113+
traceInfo(`[test-by-project] Setting PROJECT_ROOT_PATH=${project.projectUri.fsPath} for pytest execution`);
114+
}
115+
105116
if (profileKind && profileKind === TestRunProfileKind.Coverage) {
106117
mutableEnv.COVERAGE_ENABLED = 'True';
107118
}

src/client/testing/testController/unittest/testExecutionAdapter.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import { ITestDebugLauncher, LaunchOptions } from '../../common/types';
2727
import { UNITTEST_PROVIDER } from '../../common/constants';
2828
import * as utils from '../common/utils';
2929
import { getEnvironment, runInBackground, useEnvExtension } from '../../../envExt/api.internal';
30+
import { ProjectAdapter } from '../common/projectAdapter';
3031

3132
/**
3233
* Wrapper Class for unittest test execution. This is where we call `runTestCommand`?
@@ -46,6 +47,8 @@ export class UnittestTestExecutionAdapter implements ITestExecutionAdapter {
4647
runInstance: TestRun,
4748
executionFactory: IPythonExecutionFactory,
4849
debugLauncher?: ITestDebugLauncher,
50+
_interpreter?: unknown, // Not used - kept for interface compatibility
51+
project?: ProjectAdapter,
4952
): Promise<void> {
5053
// deferredTillServerClose awaits named pipe server close
5154
const deferredTillServerClose: Deferred<void> = utils.createTestingDeferred();
@@ -80,6 +83,7 @@ export class UnittestTestExecutionAdapter implements ITestExecutionAdapter {
8083
profileKind,
8184
executionFactory,
8285
debugLauncher,
86+
project,
8387
);
8488
} catch (error) {
8589
traceError(`Error in running unittest tests: ${error}`);
@@ -97,6 +101,7 @@ export class UnittestTestExecutionAdapter implements ITestExecutionAdapter {
97101
profileKind: boolean | TestRunProfileKind | undefined,
98102
executionFactory: IPythonExecutionFactory,
99103
debugLauncher?: ITestDebugLauncher,
104+
project?: ProjectAdapter,
100105
): Promise<ExecutionTestPayload> {
101106
const settings = this.configSettings.getSettings(uri);
102107
const { unittestArgs } = settings.testing;
@@ -111,6 +116,15 @@ export class UnittestTestExecutionAdapter implements ITestExecutionAdapter {
111116
const pythonPathCommand = [cwd, ...pythonPathParts].join(path.delimiter);
112117
mutableEnv.PYTHONPATH = pythonPathCommand;
113118
mutableEnv.TEST_RUN_PIPE = resultNamedPipeName;
119+
120+
// Set PROJECT_ROOT_PATH for project-based testing (tells Python where to root the test tree)
121+
if (project) {
122+
mutableEnv.PROJECT_ROOT_PATH = project.projectUri.fsPath;
123+
traceInfo(
124+
`[test-by-project] Setting PROJECT_ROOT_PATH=${project.projectUri.fsPath} for unittest execution`,
125+
);
126+
}
127+
114128
if (profileKind && profileKind === TestRunProfileKind.Coverage) {
115129
mutableEnv.COVERAGE_ENABLED = cwd;
116130
}

src/client/testing/testController/workspaceTestAdapter.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { IPythonExecutionFactory } from '../../common/process/types';
1515
import { ITestDebugLauncher } from '../common/types';
1616
import { buildErrorNodeOptions } from './common/utils';
1717
import { PythonEnvironment } from '../../pythonEnvironments/info';
18+
import { ProjectAdapter } from './common/projectAdapter';
1819

1920
/**
2021
* This class exposes a test-provider-agnostic way of discovering tests.
@@ -47,6 +48,7 @@ export class WorkspaceTestAdapter {
4748
profileKind?: boolean | TestRunProfileKind,
4849
debugLauncher?: ITestDebugLauncher,
4950
interpreter?: PythonEnvironment,
51+
project?: ProjectAdapter,
5052
): Promise<void> {
5153
if (this.executing) {
5254
traceError('Test execution already in progress, not starting a new one.');
@@ -84,6 +86,7 @@ export class WorkspaceTestAdapter {
8486
executionFactory,
8587
debugLauncher,
8688
interpreter,
89+
project,
8790
);
8891
deferred.resolve();
8992
} catch (ex) {

0 commit comments

Comments
 (0)