Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/instructions/testing-workflow.instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -578,3 +578,4 @@ envConfig.inspect
- 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)
- Remove unused variables from test code immediately - leftover tracking variables like `validationCallCount` that aren't referenced indicate dead code that should be simplified (1)
- 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)
- 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)
49 changes: 49 additions & 0 deletions .github/instructions/testing_feature_area.instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ This document maps the testing support in the extension: discovery, execution (r
- `src/client/testing/serviceRegistry.ts` — DI/wiring for testing services.
- Workspace orchestration
- `src/client/testing/testController/workspaceTestAdapter.ts` — `WorkspaceTestAdapter` (provider-agnostic entry used by controller).
- **Project-based testing (multi-project workspaces)**
- `src/client/testing/testController/common/testProjectRegistry.ts` — `TestProjectRegistry` (manages project lifecycle, discovery, and nested project handling).
- `src/client/testing/testController/common/projectAdapter.ts` — `ProjectAdapter` interface (represents a single Python project with its own test infrastructure).
- `src/client/testing/testController/common/projectUtils.ts` — utilities for project ID generation, display names, and shared adapter creation.
- Provider adapters
- Unittest
- `src/client/testing/testController/unittest/testDiscoveryAdapter.ts`
Expand Down Expand Up @@ -151,6 +155,51 @@ The adapters in the extension don't implement test discovery/run logic themselve
- 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.
- The setting definitions and descriptions are in `package.json` and localized strings in `package.nls.json`.

## Project-based testing (multi-project workspaces)

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

> **⚠️ 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.

### Architecture

- **TestProjectRegistry** (`testProjectRegistry.ts`): Central registry that:

- Discovers Python projects via the Python Environments API
- Creates and manages `ProjectAdapter` instances per workspace
- Computes nested project relationships and configures ignore lists
- Falls back to "legacy" single-adapter mode when API unavailable

- **ProjectAdapter** (`projectAdapter.ts`): Interface representing a single project with:
- Project identity (ID, name, URI from Python Environments API)
- Python environment with execution details
- Test framework adapters (discovery/execution)
- Nested project ignore paths (for parent projects)

### How it works

1. **Activation**: When the extension activates, `PythonTestController` checks if the Python Environments API is available.
2. **Project discovery**: `TestProjectRegistry.discoverAndRegisterProjects()` queries the API for all Python projects in each workspace.
3. **Nested handling**: `configureNestedProjectIgnores()` identifies child projects and adds their paths to parent projects' ignore lists.
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.
5. **Python side**: `get_test_root_path()` in `vscode_pytest/__init__.py` returns `PROJECT_ROOT_PATH` (if set) or falls back to `cwd`.
6. **Test tree**: Each project gets its own root node in the Test Explorer, with test IDs scoped by project ID using the `||` separator.

### Logging prefix

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

### Key files

- Python side: `python_files/vscode_pytest/__init__.py` — `get_test_root_path()` function and `PROJECT_ROOT_PATH` environment variable.
- TypeScript: `testProjectRegistry.ts`, `projectAdapter.ts`, `projectUtils.ts`, and the discovery adapters.

### Tests

- `src/test/testing/testController/common/testProjectRegistry.unit.test.ts` — TestProjectRegistry tests
- `src/test/testing/testController/common/projectUtils.unit.test.ts` — Project utility function tests
- `python_files/tests/pytestadapter/test_get_test_root_path.py` — Python-side get_test_root_path() tests

## Coverage support (how it works)

- Coverage is supported by running the Python helper scripts with coverage enabled and then collecting a coverage payload from the runner.
Expand Down
198 changes: 198 additions & 0 deletions python_files/tests/pytestadapter/expected_discovery_test_output.py
Original file line number Diff line number Diff line change
Expand Up @@ -1870,3 +1870,201 @@
],
"id_": TEST_DATA_PATH_STR,
}

# =====================================================================================
# PROJECT_ROOT_PATH environment variable tests
# These test the project-based testing feature where PROJECT_ROOT_PATH changes
# the test tree root from cwd to the specified project path.
# =====================================================================================

# This is the expected output for unittest_folder when PROJECT_ROOT_PATH is set to unittest_folder.
# The root of the tree is unittest_folder (not .data), simulating project-based testing.
# └── unittest_folder (ROOT - set via PROJECT_ROOT_PATH)
# ├── test_add.py
# │ └── TestAddFunction
# │ ├── test_add_negative_numbers
# │ └── test_add_positive_numbers
# │ └── TestDuplicateFunction
# │ └── test_dup_a
# └── test_subtract.py
# └── TestSubtractFunction
# ├── test_subtract_negative_numbers
# └── test_subtract_positive_numbers
# └── TestDuplicateFunction
# └── test_dup_s
#
# Note: This reuses the unittest_folder paths defined earlier in this file.
project_root_unittest_folder_expected_output = {
"name": "unittest_folder",
"path": os.fspath(unittest_folder_path),
"type_": "folder",
"children": [
{
"name": "test_add.py",
"path": os.fspath(test_add_path),
"type_": "file",
"id_": os.fspath(test_add_path),
"children": [
{
"name": "TestAddFunction",
"path": os.fspath(test_add_path),
"type_": "class",
"children": [
{
"name": "test_add_negative_numbers",
"path": os.fspath(test_add_path),
"lineno": find_test_line_number(
"test_add_negative_numbers",
os.fspath(test_add_path),
),
"type_": "test",
"id_": get_absolute_test_id(
"test_add.py::TestAddFunction::test_add_negative_numbers",
test_add_path,
),
"runID": get_absolute_test_id(
"test_add.py::TestAddFunction::test_add_negative_numbers",
test_add_path,
),
},
{
"name": "test_add_positive_numbers",
"path": os.fspath(test_add_path),
"lineno": find_test_line_number(
"test_add_positive_numbers",
os.fspath(test_add_path),
),
"type_": "test",
"id_": get_absolute_test_id(
"test_add.py::TestAddFunction::test_add_positive_numbers",
test_add_path,
),
"runID": get_absolute_test_id(
"test_add.py::TestAddFunction::test_add_positive_numbers",
test_add_path,
),
},
],
"id_": get_absolute_test_id(
"test_add.py::TestAddFunction",
test_add_path,
),
"lineno": find_class_line_number("TestAddFunction", test_add_path),
},
{
"name": "TestDuplicateFunction",
"path": os.fspath(test_add_path),
"type_": "class",
"children": [
{
"name": "test_dup_a",
"path": os.fspath(test_add_path),
"lineno": find_test_line_number(
"test_dup_a",
os.fspath(test_add_path),
),
"type_": "test",
"id_": get_absolute_test_id(
"test_add.py::TestDuplicateFunction::test_dup_a",
test_add_path,
),
"runID": get_absolute_test_id(
"test_add.py::TestDuplicateFunction::test_dup_a",
test_add_path,
),
},
],
"id_": get_absolute_test_id(
"test_add.py::TestDuplicateFunction",
test_add_path,
),
"lineno": find_class_line_number("TestDuplicateFunction", test_add_path),
},
],
},
{
"name": "test_subtract.py",
"path": os.fspath(test_subtract_path),
"type_": "file",
"id_": os.fspath(test_subtract_path),
"children": [
{
"name": "TestSubtractFunction",
"path": os.fspath(test_subtract_path),
"type_": "class",
"children": [
{
"name": "test_subtract_negative_numbers",
"path": os.fspath(test_subtract_path),
"lineno": find_test_line_number(
"test_subtract_negative_numbers",
os.fspath(test_subtract_path),
),
"type_": "test",
"id_": get_absolute_test_id(
"test_subtract.py::TestSubtractFunction::test_subtract_negative_numbers",
test_subtract_path,
),
"runID": get_absolute_test_id(
"test_subtract.py::TestSubtractFunction::test_subtract_negative_numbers",
test_subtract_path,
),
},
{
"name": "test_subtract_positive_numbers",
"path": os.fspath(test_subtract_path),
"lineno": find_test_line_number(
"test_subtract_positive_numbers",
os.fspath(test_subtract_path),
),
"type_": "test",
"id_": get_absolute_test_id(
"test_subtract.py::TestSubtractFunction::test_subtract_positive_numbers",
test_subtract_path,
),
"runID": get_absolute_test_id(
"test_subtract.py::TestSubtractFunction::test_subtract_positive_numbers",
test_subtract_path,
),
},
],
"id_": get_absolute_test_id(
"test_subtract.py::TestSubtractFunction",
test_subtract_path,
),
"lineno": find_class_line_number("TestSubtractFunction", test_subtract_path),
},
{
"name": "TestDuplicateFunction",
"path": os.fspath(test_subtract_path),
"type_": "class",
"children": [
{
"name": "test_dup_s",
"path": os.fspath(test_subtract_path),
"lineno": find_test_line_number(
"test_dup_s",
os.fspath(test_subtract_path),
),
"type_": "test",
"id_": get_absolute_test_id(
"test_subtract.py::TestDuplicateFunction::test_dup_s",
test_subtract_path,
),
"runID": get_absolute_test_id(
"test_subtract.py::TestDuplicateFunction::test_dup_s",
test_subtract_path,
),
},
],
"id_": get_absolute_test_id(
"test_subtract.py::TestDuplicateFunction",
test_subtract_path,
),
"lineno": find_class_line_number("TestDuplicateFunction", test_subtract_path),
},
],
},
],
"id_": os.fspath(unittest_folder_path),
}
42 changes: 42 additions & 0 deletions python_files/tests/pytestadapter/test_discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -386,3 +386,45 @@ def test_plugin_collect(file, expected_const, extra_arg):
), (
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)}"
)


def test_project_root_path_env_var():
"""Test pytest discovery with PROJECT_ROOT_PATH environment variable set.

This simulates project-based testing where the test tree root should be
the project root (PROJECT_ROOT_PATH) rather than the workspace cwd.

When PROJECT_ROOT_PATH is set:
- The test tree root (name, path, id_) should match PROJECT_ROOT_PATH
- The cwd in the response should match PROJECT_ROOT_PATH
- Test files should be direct children of the root (not nested under a subfolder)
"""
# Use unittest_folder as our "project" subdirectory
project_path = helpers.TEST_DATA_PATH / "unittest_folder"

actual = helpers.runner_with_cwd_env(
[os.fspath(project_path), "--collect-only"],
helpers.TEST_DATA_PATH, # cwd is parent of project
{"PROJECT_ROOT_PATH": os.fspath(project_path)}, # Set project root
)

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')}"
)
# cwd in response should be PROJECT_ROOT_PATH
assert actual_item.get("cwd") == os.fspath(project_path), (
f"Expected cwd '{os.fspath(project_path)}', got '{actual_item.get('cwd')}'"
)
assert is_same_tree(
actual_item.get("tests"),
expected_discovery_test_output.project_root_unittest_folder_expected_output,
["id_", "lineno", "name", "runID"],
), (
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)}"
)
Loading
Loading