Skip to content
Open
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
21 changes: 16 additions & 5 deletions src/client/testing/testController/common/projectTestExecution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { TestProjectRegistry } from './testProjectRegistry';
import { getProjectId } from './projectUtils';
import { getEnvExtApi, useEnvExtension } from '../../../envExt/api.internal';
import { isParentPath } from '../../../pythonEnvironments/common/externalDependencies';
import { expandExcludeSet } from './testItemUtilities';

/** Dependencies for project-based test execution. */
export interface ProjectExecutionDependencies {
Expand Down Expand Up @@ -53,6 +54,9 @@ export async function executeTestsForProjects(
setupCoverageForProjects(request, projects);
}

// Excluded items and their descendants, built once for all projects
const excludeSet = expandExcludeSet(request.exclude);

// Execute tests for each project in parallel
// For debug mode, multiple debug sessions will be launched in parallel
// Each execution respects cancellation via runInstance.token
Expand All @@ -76,7 +80,7 @@ export async function executeTestsForProjects(
let failed = false;
let failureCategory: UnitTestRunFailureCategory | undefined;
try {
await executeTestsForProject(project, items, runInstance, request, deps);
await executeTestsForProject(project, items, runInstance, request, deps, excludeSet);
} catch (error) {
failed = true;
failureCategory = token.isCancellationRequested ? 'cancelled' : 'unknown';
Expand Down Expand Up @@ -235,13 +239,14 @@ export async function executeTestsForProject(
runInstance: TestRun,
request: TestRunRequest,
deps: ProjectExecutionDependencies,
excludeSet?: Set<TestItem>,
): Promise<void> {
const processedTestItemIds = new Set<string>();
const uniqueTestCaseIds = new Set<string>();

// Mark items as started and collect test IDs (deduplicated to handle overlapping selections)
for (const item of testItems) {
const testCaseNodes = getTestCaseNodesRecursive(item);
const testCaseNodes = getTestCaseNodesRecursive(item, excludeSet);
for (const node of testCaseNodes) {
if (processedTestItemIds.has(node.id)) {
continue;
Expand Down Expand Up @@ -277,16 +282,22 @@ export async function executeTestsForProject(
);
}

/** Recursively gets all leaf test case nodes from a test item tree. */
export function getTestCaseNodesRecursive(item: TestItem): TestItem[] {
/**
* Recursively gets all leaf test case nodes from a test item tree,
* skipping items in `excludeSet` (built by `expandExcludeSet`).
*/
export function getTestCaseNodesRecursive(item: TestItem, excludeSet?: Set<TestItem>): TestItem[] {
const results: TestItem[] = [];
if (excludeSet?.has(item)) {
return results;
}
if (item.children.size === 0) {
// This is a leaf node (test case)
results.push(item);
} else {
// Recursively get children
item.children.forEach((child) => {
results.push(...getTestCaseNodesRecursive(child));
results.push(...getTestCaseNodesRecursive(child, excludeSet));
});
}
return results;
Expand Down
32 changes: 30 additions & 2 deletions src/client/testing/testController/common/testItemUtilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -498,14 +498,42 @@ export async function updateTestItemFromRawData(
item.busy = false;
}

export function getTestCaseNodes(testNode: TestItem, collection: TestItem[] = []): TestItem[] {
/**
* Builds the set of test items excluded from a run: the items in `TestRunRequest.exclude`
* plus all of their descendants, so a single set lookup tells whether a node is excluded.
*/
export function expandExcludeSet(excludes: readonly TestItem[] | undefined): Set<TestItem> | undefined {
if (!excludes || excludes.length === 0) {
return undefined;
}
const expanded = new Set<TestItem>();
const add = (item: TestItem) => {
if (expanded.has(item)) {
return;
}
expanded.add(item);
item.children.forEach(add);
};
excludes.forEach(add);
return expanded;
}

export function getTestCaseNodes(
testNode: TestItem,
collection: TestItem[] = [],
excludeSet?: Set<TestItem>,
): TestItem[] {
if (excludeSet?.has(testNode)) {
return collection;
}

if (!testNode.canResolveChildren && testNode.tags.length > 0) {
collection.push(testNode);
}

testNode.children.forEach((c) => {
if (testNode.canResolveChildren) {
getTestCaseNodes(c, collection);
getTestCaseNodes(c, collection, excludeSet);
} else {
collection.push(testNode);
}
Expand Down
1 change: 1 addition & 0 deletions src/client/testing/testController/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1052,6 +1052,7 @@ export class PythonTestController implements ITestController, IExtensionSingleAc
request.profile?.kind,
this.debugLauncher,
await this.interpreterService.getActiveInterpreter(workspace.uri),
request.exclude,
);
} catch (ex) {
failed = true;
Expand Down
16 changes: 12 additions & 4 deletions src/client/testing/testController/workspaceTestAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,11 @@ import { CancellationToken, TestController, TestItem, TestRun, TestRunProfileKin
import { createDeferred, Deferred } from '../../common/utils/async';
import { Testing } from '../../common/utils/localize';
import { StopWatch } from '../../common/utils/stopWatch';
import { traceError } from '../../logging';
import { traceError, traceVerbose } from '../../logging';
import { sendTelemetryEvent } from '../../telemetry';
import { EventName } from '../../telemetry/constants';
import { TestProvider } from '../types';
import { createErrorTestItem, getTestCaseNodes } from './common/testItemUtilities';
import { createErrorTestItem, expandExcludeSet, getTestCaseNodes } from './common/testItemUtilities';
import { ITestDiscoveryAdapter, ITestExecutionAdapter, ITestResultResolver } from './common/types';
import { IPythonExecutionFactory } from '../../common/process/types';
import { ITestDebugLauncher } from '../common/types';
Expand Down Expand Up @@ -51,6 +51,7 @@ export class WorkspaceTestAdapter {
profileKind?: boolean | TestRunProfileKind,
debugLauncher?: ITestDebugLauncher,
interpreter?: PythonEnvironment,
excludes?: readonly TestItem[],
project?: ProjectAdapter,
): Promise<void> {
if (this.executing) {
Expand All @@ -63,10 +64,11 @@ export class WorkspaceTestAdapter {

const testCaseNodes: TestItem[] = [];
const testCaseIdsSet = new Set<string>();
const excludeSet = expandExcludeSet(excludes);
try {
// first fetch all the individual test Items that we necessarily want
// first fetch all the individual test Items that we necessarily want, minus the excluded ones
includes.forEach((t) => {
const nodes = getTestCaseNodes(t);
const nodes = getTestCaseNodes(t, [], excludeSet);
testCaseNodes.push(...nodes);
});
// iterate through testItems nodes and fetch their unittest runID to pass in as argument
Expand All @@ -78,6 +80,12 @@ export class WorkspaceTestAdapter {
}
});
const testCaseIds = Array.from(testCaseIdsSet);
if (excludeSet && testCaseIds.length === 0) {
// Everything was excluded. Don't start a run: an empty ID list makes pytest run all tests.
traceVerbose('All requested tests were excluded, nothing to run.');
deferred.resolve();
return deferred.promise;
}
if (executionFactory === undefined) {
throw new Error('Execution factory is required for test execution');
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
groupTestItemsByProject,
setupCoverageForProjects,
} from '../../../../client/testing/testController/common/projectTestExecution';
import { expandExcludeSet } from '../../../../client/testing/testController/common/testItemUtilities';
import * as telemetry from '../../../../client/telemetry';
import { EventName } from '../../../../client/telemetry/constants';
import * as envExtApi from '../../../../client/envExt/api.internal';
Expand Down Expand Up @@ -340,6 +341,21 @@ suite('Project Test Execution', () => {
expect(result).to.have.length(3);
expect(new Set(result)).to.deep.equal(new Set([leaf1, leaf2, leaf3]));
});

test('should skip excluded items and their descendants', () => {
// Mock - file with two classes, one class excluded
const leaf1 = createMockTestItem('test1', '/test.py');
const leaf2 = createMockTestItem('test2', '/test.py');
const class1 = createMockTestItem('Class1', '/test.py', [leaf1]);
const class2 = createMockTestItem('Class2', '/test.py', [leaf2]);
const fileItem = createMockTestItem('test_file.py', '/test.py', [class1, class2]);

// Run
const result = getTestCaseNodesRecursive(fileItem, expandExcludeSet([class2]));

// Assert
expect(result).to.deep.equal([leaf1]);
});
});

// ===== executeTestsForProject Tests =====
Expand Down Expand Up @@ -439,6 +455,90 @@ suite('Project Test Execution', () => {
const passedTestIds = project.executionAdapterStub.firstCall.args[1] as string[];
expect(passedTestIds).to.have.length(2);
});

test('should not run test items in the exclude set', async () => {
// Mock - class containing two test methods, one excluded
const project = createMockProjectAdapter({ projectPath: '/workspace/proj', projectName: 'proj' });
const leaf1 = createMockTestItem('test1', '/workspace/proj/test.py');
const leaf2 = createMockTestItem('test2', '/workspace/proj/test.py');
const classItem = createMockTestItem('TestClass', '/workspace/proj/test.py', [leaf1, leaf2]);
project.resultResolver.vsIdToRunId.set('test1', 'runId1');
project.resultResolver.vsIdToRunId.set('test2', 'runId2');
const runMock = createMockTestRun();
const request = { profile: { kind: TestRunProfileKind.Run } } as TestRunRequest;
const deps = createMockDependencies();

// Run
await executeTestsForProject(
project,
[classItem],
runMock.object,
request,
deps,
expandExcludeSet([leaf2]),
);

// Assert - only leaf1 is started and executed
runMock.verify((r) => r.started(leaf1), typemoq.Times.once());
runMock.verify((r) => r.started(leaf2), typemoq.Times.never());
const passedTestIds = project.executionAdapterStub.firstCall.args[1] as string[];
expect(passedTestIds).to.deep.equal(['runId1']);
});

test('should not run anything when the whole selection is excluded', async () => {
// Mock - the requested class is itself excluded
const project = createMockProjectAdapter({ projectPath: '/workspace/proj', projectName: 'proj' });
const leaf1 = createMockTestItem('test1', '/workspace/proj/test.py');
const leaf2 = createMockTestItem('test2', '/workspace/proj/test.py');
const classItem = createMockTestItem('TestClass', '/workspace/proj/test.py', [leaf1, leaf2]);
project.resultResolver.vsIdToRunId.set('test1', 'runId1');
project.resultResolver.vsIdToRunId.set('test2', 'runId2');
const runMock = createMockTestRun();
const request = { profile: { kind: TestRunProfileKind.Run } } as TestRunRequest;
const deps = createMockDependencies();

// Run
await executeTestsForProject(
project,
[classItem],
runMock.object,
request,
deps,
expandExcludeSet([classItem]),
);

// Assert - nothing is started and the adapter is never called (an empty ID list would run all tests)
runMock.verify((r) => r.started(typemoq.It.isAny()), typemoq.Times.never());
expect(project.executionAdapterStub.called).to.be.false;
});

test('should still deduplicate test IDs when exclusions are used', async () => {
// Mock - the class and one of its methods are both selected, the other method is excluded
const project = createMockProjectAdapter({ projectPath: '/workspace/proj', projectName: 'proj' });
const leaf1 = createMockTestItem('test1', '/workspace/proj/test.py');
const leaf2 = createMockTestItem('test2', '/workspace/proj/test.py');
const classItem = createMockTestItem('TestClass', '/workspace/proj/test.py', [leaf1, leaf2]);
project.resultResolver.vsIdToRunId.set('test1', 'runId1');
project.resultResolver.vsIdToRunId.set('test2', 'runId2');
const runMock = createMockTestRun();
const request = { profile: { kind: TestRunProfileKind.Run } } as TestRunRequest;
const deps = createMockDependencies();

// Run
await executeTestsForProject(
project,
[classItem, leaf1],
runMock.object,
request,
deps,
expandExcludeSet([leaf2]),
);

// Assert - leaf1 is started and sent once
runMock.verify((r) => r.started(leaf1), typemoq.Times.once());
const passedTestIds = project.executionAdapterStub.firstCall.args[1] as string[];
expect(passedTestIds).to.deep.equal(['runId1']);
});
});

// ===== executeTestsForProjects Tests =====
Expand Down Expand Up @@ -623,6 +723,58 @@ suite('Project Test Execution', () => {
const telemetryProps = runDoneCalls[0].args[2];
expect(telemetryProps.debugging).to.be.true;
});

test('should respect request.exclude when executing tests', async () => {
// Mock - project with two test items, one excluded via request.exclude
const project = createMockProjectAdapter({ projectPath: '/workspace/proj', projectName: 'proj' });
const item1 = createMockTestItem('test1', '/workspace/proj/test.py');
const item2 = createMockTestItem('test2', '/workspace/proj/test.py');
project.resultResolver.vsIdToRunId.set('test1', 'runId1');
project.resultResolver.vsIdToRunId.set('test2', 'runId2');
const runMock = createMockTestRun();
const token = new CancellationTokenSource().token;
const request = ({
profile: { kind: TestRunProfileKind.Run },
exclude: [item2],
} as unknown) as TestRunRequest;
const deps = createMockDependencies();

// Run
await executeTestsForProjects([project], [item1, item2], runMock.object, request, token, deps);

// Assert - only item1 is executed
runMock.verify((r) => r.started(item1), typemoq.Times.once());
runMock.verify((r) => r.started(item2), typemoq.Times.never());
const passedTestIds = project.executionAdapterStub.firstCall.args[1] as string[];
expect(passedTestIds).to.deep.equal(['runId1']);
});

test('should only skip the project whose tests are all excluded', async () => {
// Mock - two projects with one test each, the test in proj1 is excluded
const proj1 = createMockProjectAdapter({ projectPath: '/workspace/proj1', projectName: 'proj1' });
const proj2 = createMockProjectAdapter({ projectPath: '/workspace/proj2', projectName: 'proj2' });
const item1 = createMockTestItem('test1', '/workspace/proj1/test.py');
const item2 = createMockTestItem('test2', '/workspace/proj2/test.py');
proj1.resultResolver.vsIdToRunId.set('test1', 'runId1');
proj2.resultResolver.vsIdToRunId.set('test2', 'runId2');
const runMock = createMockTestRun();
const token = new CancellationTokenSource().token;
const request = ({
profile: { kind: TestRunProfileKind.Run },
exclude: [item1],
} as unknown) as TestRunRequest;
const deps = createMockDependencies();

// Run
await executeTestsForProjects([proj1, proj2], [item1, item2], runMock.object, request, token, deps);

// Assert - proj1 is not run at all, proj2 runs item2
runMock.verify((r) => r.started(item1), typemoq.Times.never());
runMock.verify((r) => r.started(item2), typemoq.Times.once());
expect(proj1.executionAdapterStub.called).to.be.false;
expect(proj2.executionAdapterStub.calledOnce).to.be.true;
expect(proj2.executionAdapterStub.firstCall.args[1]).to.deep.equal(['runId2']);
});
});

// ===== setupCoverageForProjects Tests =====
Expand Down
Loading