Skip to content

Commit f048ef4

Browse files
authored
Python Environments Project Support for Unittest Execution (#25779)
1 parent 3b87651 commit f048ef4

File tree

7 files changed

+179
-17
lines changed

7 files changed

+179
-17
lines changed

src/client/testing/common/debugLauncher.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,7 @@ export class DebugLauncher implements ITestDebugLauncher {
177177
include: false,
178178
});
179179

180-
DebugLauncher.applyDefaults(debugConfig!, workspaceFolder, configSettings);
180+
DebugLauncher.applyDefaults(debugConfig!, workspaceFolder, configSettings, options.cwd);
181181

182182
return this.convertConfigToArgs(debugConfig!, workspaceFolder, options);
183183
}
@@ -224,14 +224,17 @@ export class DebugLauncher implements ITestDebugLauncher {
224224
cfg: LaunchRequestArguments,
225225
workspaceFolder: WorkspaceFolder,
226226
configSettings: IPythonSettings,
227+
optionsCwd?: string,
227228
) {
228229
// cfg.pythonPath is handled by LaunchConfigurationResolver.
229230

230231
if (!cfg.console) {
231232
cfg.console = 'internalConsole';
232233
}
233234
if (!cfg.cwd) {
234-
cfg.cwd = configSettings.testing.cwd || workspaceFolder.uri.fsPath;
235+
// For project-based testing, use the project's cwd (optionsCwd) if provided.
236+
// Otherwise fall back to settings.testing.cwd or the workspace folder.
237+
cfg.cwd = optionsCwd || configSettings.testing.cwd || workspaceFolder.uri.fsPath;
235238
}
236239
if (!cfg.env) {
237240
cfg.env = {};

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

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { CancellationToken, TestController, Uri, MarkdownString } from 'vscode';
55
import * as util from 'util';
66
import { DiscoveredTestPayload } from './types';
77
import { TestProvider } from '../../types';
8-
import { traceError } from '../../../logging';
8+
import { traceError, traceWarn } from '../../../logging';
99
import { Testing } from '../../../common/utils/localize';
1010
import { createErrorTestItem } from './testItemUtilities';
1111
import { buildErrorNodeOptions, populateTestTree } from './utils';
@@ -93,6 +93,28 @@ export class TestDiscoveryHandler {
9393

9494
traceError(testingErrorConst, 'for workspace: ', workspacePath, '\r\n', error?.join('\r\n\r\n') ?? '');
9595

96+
// For unittest in project-based mode, check if the error might be caused by nested project imports
97+
// This helps users understand that import errors from nested projects can be safely ignored
98+
// if those tests are covered by a different project with the correct environment.
99+
if (testProvider === 'unittest' && projectId) {
100+
const errorText = error?.join(' ') ?? '';
101+
const isImportError =
102+
errorText.includes('ModuleNotFoundError') ||
103+
errorText.includes('ImportError') ||
104+
errorText.includes('No module named');
105+
106+
if (isImportError) {
107+
const warningMessage =
108+
'--- ' +
109+
`[test-by-project] Import error during unittest discovery for project at ${workspacePath}. ` +
110+
'This may be caused by test files in nested project directories that require different dependencies. ' +
111+
'If these tests are discovered successfully by their own project (with the correct Python environment), ' +
112+
'this error can be safely ignored. To avoid this, consider excluding nested project paths from parent project discovery. ' +
113+
'---';
114+
traceWarn(warningMessage);
115+
}
116+
}
117+
96118
const errorNodeId = projectId
97119
? `${projectId}${PROJECT_ID_SEPARATOR}DiscoveryError:${workspacePath}`
98120
: `DiscoveryError:${workspacePath}`;

src/client/testing/testController/controller.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -794,7 +794,7 @@ export class PythonTestController implements ITestController, IExtensionSingleAc
794794
}
795795

796796
// Check if we're in project-based mode and should use project-specific execution
797-
if (this.projectRegistry.hasProjects(workspace.uri) && settings.testing.pytestEnabled) {
797+
if (this.projectRegistry.hasProjects(workspace.uri)) {
798798
const projects = this.projectRegistry.getProjectsArray(workspace.uri);
799799
await executeTestsForProjects(projects, testItems, runInstance, request, token, {
800800
projectRegistry: this.projectRegistry,

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ export class PytestTestDiscoveryAdapter implements ITestDiscoveryAdapter {
109109
// Execute using environment extension if available
110110
if (useEnvExtension()) {
111111
traceInfo(`Using environment extension for pytest discovery in workspace ${uri.fsPath}`);
112-
const pythonEnv = await getEnvironment(uri);
112+
const pythonEnv = project?.pythonEnvironment ?? (await getEnvironment(uri));
113113
if (!pythonEnv) {
114114
traceError(
115115
`Python environment not found for workspace ${uri.fsPath}. Cannot proceed with test discovery.`,

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ export class UnittestTestDiscoveryAdapter implements ITestDiscoveryAdapter {
9494
// Execute using environment extension if available
9595
if (useEnvExtension()) {
9696
traceInfo(`Using environment extension for unittest discovery in workspace ${uri.fsPath}`);
97-
const pythonEnv = await getEnvironment(uri);
97+
const pythonEnv = project?.pythonEnvironment ?? (await getEnvironment(uri));
9898
if (!pythonEnv) {
9999
traceError(
100100
`Python environment not found for workspace ${uri.fsPath}. Cannot proceed with test discovery.`,

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

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -48,18 +48,9 @@ export class UnittestTestExecutionAdapter implements ITestExecutionAdapter {
4848
runInstance: TestRun,
4949
executionFactory: IPythonExecutionFactory,
5050
debugLauncher?: ITestDebugLauncher,
51-
interpreter?: PythonEnvironment,
51+
_interpreter?: PythonEnvironment,
5252
project?: ProjectAdapter,
5353
): Promise<void> {
54-
// Note: project parameter is currently unused for unittest.
55-
// Project-based unittest execution will be implemented in a future PR.
56-
console.log(
57-
'interpreter, project parameters are currently unused in UnittestTestExecutionAdapter, they will be used in a future implementation of project-based unittest execution.:',
58-
{
59-
interpreter,
60-
project,
61-
},
62-
);
6354
// deferredTillServerClose awaits named pipe server close
6455
const deferredTillServerClose: Deferred<void> = utils.createTestingDeferred();
6556

@@ -189,6 +180,8 @@ export class UnittestTestExecutionAdapter implements ITestExecutionAdapter {
189180
testProvider: UNITTEST_PROVIDER,
190181
runTestIdsPort: testIdsFileName,
191182
pytestPort: resultNamedPipeName, // change this from pytest
183+
// Pass project for project-based debugging (Python path and session name derived from this)
184+
project: project?.pythonProject,
192185
};
193186
const sessionOptions: DebugSessionOptions = {
194187
testRun: runInstance,
@@ -207,7 +200,7 @@ export class UnittestTestExecutionAdapter implements ITestExecutionAdapter {
207200
sessionOptions,
208201
);
209202
} else if (useEnvExtension()) {
210-
const pythonEnv = await getEnvironment(uri);
203+
const pythonEnv = project?.pythonEnvironment ?? (await getEnvironment(uri));
211204
if (pythonEnv) {
212205
traceInfo(`Running unittest with arguments: ${args.join(' ')} for workspace ${uri.fsPath} \r\n`);
213206
const deferredTillExecClose = createDeferred();

src/test/testing/testController/unittest/testExecutionAdapter.unit.test.ts

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { traceInfo } from '../../../../client/logging';
2323
import { UnittestTestExecutionAdapter } from '../../../../client/testing/testController/unittest/testExecutionAdapter';
2424
import * as extapi from '../../../../client/envExt/api.internal';
2525
import { ProjectAdapter } from '../../../../client/testing/testController/common/projectAdapter';
26+
import { createMockProjectAdapter } from '../testMocks';
2627

2728
suite('Unittest test execution adapter', () => {
2829
let configService: IConfigurationService;
@@ -434,4 +435,147 @@ suite('Unittest test execution adapter', () => {
434435
typeMoq.Times.once(),
435436
);
436437
});
438+
439+
test('Debug mode with project should pass project.pythonProject to debug launcher', async () => {
440+
const deferred3 = createDeferred();
441+
utilsWriteTestIdsFileStub.callsFake(() => Promise.resolve('testIdPipe-mockName'));
442+
443+
debugLauncher
444+
.setup((dl) => dl.launchDebugger(typeMoq.It.isAny(), typeMoq.It.isAny(), typeMoq.It.isAny()))
445+
.returns(async (_opts, callback) => {
446+
traceInfo('stubs launch debugger');
447+
if (typeof callback === 'function') {
448+
deferred3.resolve();
449+
callback();
450+
}
451+
});
452+
453+
const testRun = typeMoq.Mock.ofType<TestRun>();
454+
testRun
455+
.setup((t) => t.token)
456+
.returns(
457+
() =>
458+
({
459+
onCancellationRequested: () => undefined,
460+
} as any),
461+
);
462+
463+
const projectPath = path.join('/', 'workspace', 'myproject');
464+
const mockProject = createMockProjectAdapter({
465+
projectPath,
466+
projectName: 'myproject (Python 3.11)',
467+
pythonPath: '/custom/python/path',
468+
testProvider: 'unittest',
469+
});
470+
471+
const uri = Uri.file(myTestPath);
472+
adapter = new UnittestTestExecutionAdapter(configService);
473+
adapter.runTests(
474+
uri,
475+
[],
476+
TestRunProfileKind.Debug,
477+
testRun.object,
478+
execFactory.object,
479+
debugLauncher.object,
480+
undefined,
481+
mockProject,
482+
);
483+
484+
await deferred3.promise;
485+
486+
debugLauncher.verify(
487+
(x) =>
488+
x.launchDebugger(
489+
typeMoq.It.is<LaunchOptions>((launchOptions) => {
490+
// Project should be passed for project-based debugging
491+
assert.ok(launchOptions.project, 'project should be defined');
492+
assert.equal(launchOptions.project?.name, 'myproject (Python 3.11)');
493+
assert.equal(launchOptions.project?.uri.fsPath, projectPath);
494+
return true;
495+
}),
496+
typeMoq.It.isAny(),
497+
typeMoq.It.isAny(),
498+
),
499+
typeMoq.Times.once(),
500+
);
501+
});
502+
503+
test('useEnvExtension mode with project should use project pythonEnvironment', async () => {
504+
// Enable the useEnvExtension path
505+
useEnvExtensionStub.returns(true);
506+
507+
utilsWriteTestIdsFileStub.callsFake(() => Promise.resolve('testIdPipe-mockName'));
508+
509+
// Store the deferredTillServerClose so we can resolve it
510+
let serverCloseDeferred: Deferred<void> | undefined;
511+
utilsStartRunResultNamedPipeStub.callsFake((_callback: unknown, deferred: Deferred<void>, _token: unknown) => {
512+
serverCloseDeferred = deferred;
513+
return Promise.resolve('runResultPipe-mockName');
514+
});
515+
516+
const projectPath = path.join('/', 'workspace', 'myproject');
517+
const mockProject = createMockProjectAdapter({
518+
projectPath,
519+
projectName: 'myproject (Python 3.11)',
520+
pythonPath: '/custom/python/path',
521+
testProvider: 'unittest',
522+
});
523+
524+
// Stub runInBackground to capture which environment was used
525+
const runInBackgroundStub = sinon.stub(extapi, 'runInBackground');
526+
const exitCallbacks: ((code: number, signal: string | null) => void)[] = [];
527+
// Promise that resolves when the production code registers its onExit handler
528+
const onExitRegistered = createDeferred<void>();
529+
const mockProc2 = {
530+
stdout: { on: sinon.stub() },
531+
stderr: { on: sinon.stub() },
532+
onExit: (cb: (code: number, signal: string | null) => void) => {
533+
exitCallbacks.push(cb);
534+
onExitRegistered.resolve();
535+
},
536+
kill: sinon.stub(),
537+
};
538+
runInBackgroundStub.callsFake(() => Promise.resolve(mockProc2 as any));
539+
540+
const testRun = typeMoq.Mock.ofType<TestRun>();
541+
testRun
542+
.setup((t) => t.token)
543+
.returns(
544+
() =>
545+
({
546+
onCancellationRequested: () => undefined,
547+
} as any),
548+
);
549+
550+
const uri = Uri.file(myTestPath);
551+
adapter = new UnittestTestExecutionAdapter(configService);
552+
const runPromise = adapter.runTests(
553+
uri,
554+
[],
555+
TestRunProfileKind.Run,
556+
testRun.object,
557+
execFactory.object,
558+
debugLauncher.object,
559+
undefined,
560+
mockProject,
561+
);
562+
563+
// Wait for production code to register its onExit handler
564+
await onExitRegistered.promise;
565+
566+
// Simulate process exit to complete the test
567+
exitCallbacks.forEach((cb) => cb(0, null));
568+
569+
// Resolve the server close deferred to allow the runTests to complete
570+
serverCloseDeferred?.resolve();
571+
572+
await runPromise;
573+
574+
// Verify runInBackground was called with the project's Python environment
575+
sinon.assert.calledOnce(runInBackgroundStub);
576+
const envArg = runInBackgroundStub.firstCall.args[0];
577+
// The environment should be the project's pythonEnvironment
578+
assert.ok(envArg, 'runInBackground should be called with an environment');
579+
assert.equal(envArg.execInfo?.run?.executable, '/custom/python/path');
580+
});
437581
});

0 commit comments

Comments
 (0)