Skip to content

Commit ac9752a

Browse files
authored
Ensure subtest run update parent results (#17578)
* Support for subtest run results. * Add news * Ensure engine version supports testtags * Don't use proposed constructor
1 parent 33329f3 commit ac9752a

File tree

9 files changed

+192
-30
lines changed

9 files changed

+192
-30
lines changed

news/2 Fixes/17561.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Ensures test node is updated when `unittest` sub-tests are used.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@
3737
"theme": "dark"
3838
},
3939
"engines": {
40-
"vscode": "^1.59.0"
40+
"vscode": "^1.61.0"
4141
},
4242
"keywords": [
4343
"python",

pythonFiles/visualstudio_py_testlauncher.py

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,13 @@ def addUnexpectedSuccess(self, test):
168168
super(VsTestResult, self).addUnexpectedSuccess(test)
169169
self.sendResult(test, "passed-unexpected")
170170

171-
def sendResult(self, test, outcome, trace=None):
171+
def addSubTest(self, test, subtest, err):
172+
super(VsTestResult, self).addSubTest(test, subtest, err)
173+
self.sendResult(
174+
test, "subtest-passed" if err is None else "subtest-failed", err, subtest
175+
)
176+
177+
def sendResult(self, test, outcome, trace=None, subtest=None):
172178
if _channel is not None:
173179
tb = None
174180
message = None
@@ -179,13 +185,16 @@ def sendResult(self, test, outcome, trace=None):
179185
formatted = formatted[1:]
180186
tb = "".join(formatted)
181187
message = str(trace[1])
182-
_channel.send_event(
183-
name="result",
184-
outcome=outcome,
185-
traceback=tb,
186-
message=message,
187-
test=test.id(),
188-
)
188+
189+
result = {
190+
"outcome": outcome,
191+
"traceback": tb,
192+
"message": message,
193+
"test": test.id(),
194+
}
195+
if subtest is not None:
196+
result["subtest"] = subtest.id()
197+
_channel.send_event("result", **result)
189198

190199

191200
def stopTests():

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

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ import {
2525
TestDataKinds,
2626
} from './types';
2727

28+
// Todo: Use `TestTag` when the proposed API gets into stable.
29+
export const RunTestTag = { id: 'python-run' };
30+
export const DebugTestTag = { id: 'python-debug' };
31+
2832
export function removeItemByIdFromChildren(
2933
idToRawData: Map<string, TestData>,
3034
item: TestItem,
@@ -43,6 +47,7 @@ export function createErrorTestItem(
4347
const testItem = testController.createTestItem(options.id, options.label);
4448
testItem.canResolveChildren = false;
4549
testItem.error = options.error;
50+
testItem.tags = [RunTestTag, DebugTestTag];
4651
return testItem;
4752
}
4853

@@ -58,6 +63,7 @@ export function createWorkspaceRootTestItem(
5863
rawId: options.rawId ?? options.id,
5964
kind: TestDataKinds.Workspace,
6065
});
66+
testItem.tags = [RunTestTag, DebugTestTag];
6167
return testItem;
6268
}
6369

@@ -125,6 +131,7 @@ function createFolderOrFileTestItem(
125131
kind: TestDataKinds.FolderOrFile,
126132
parentId,
127133
});
134+
testItem.tags = [RunTestTag, DebugTestTag];
128135
return testItem;
129136
}
130137

@@ -151,6 +158,7 @@ function updateFolderOrFileTestItem(
151158
kind: TestDataKinds.FolderOrFile,
152159
parentId,
153160
});
161+
item.tags = [RunTestTag, DebugTestTag];
154162
}
155163

156164
function createCollectionTestItem(
@@ -184,6 +192,7 @@ function createCollectionTestItem(
184192
kind: TestDataKinds.Collection,
185193
parentId,
186194
});
195+
testItem.tags = [RunTestTag, DebugTestTag];
187196
return testItem;
188197
}
189198

@@ -213,6 +222,7 @@ function updateCollectionTestItem(
213222
kind: TestDataKinds.Collection,
214223
parentId,
215224
});
225+
item.tags = [RunTestTag, DebugTestTag];
216226
}
217227

218228
function createTestCaseItem(
@@ -249,6 +259,7 @@ function createTestCaseItem(
249259
kind: TestDataKinds.Case,
250260
parentId,
251261
});
262+
testItem.tags = [RunTestTag, DebugTestTag];
252263
return testItem;
253264
}
254265

@@ -279,6 +290,7 @@ function updateTestCaseItem(
279290
kind: TestDataKinds.Case,
280291
parentId,
281292
});
293+
item.tags = [RunTestTag, DebugTestTag];
282294
}
283295

284296
function updateTestItemFromRawDataInternal(
@@ -475,7 +487,7 @@ export function getUri(node: TestItem): Uri | undefined {
475487
}
476488

477489
export function getTestCaseNodes(testNode: TestItem, collection: TestItem[] = []): TestItem[] {
478-
if (!testNode.canResolveChildren) {
490+
if (!testNode.canResolveChildren && testNode.tags.length > 0) {
479491
collection.push(testNode);
480492
}
481493

@@ -551,3 +563,9 @@ export function checkForFailedTests(resultMap: Map<string, TestResultState>): bo
551563
) !== undefined
552564
);
553565
}
566+
567+
export function clearAllChildren(testNode: TestItem): void {
568+
const ids: string[] = [];
569+
testNode.children.forEach((c) => ids.push(c.id));
570+
ids.forEach(testNode.children.delete);
571+
}

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

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,12 +61,22 @@ export const ITestFrameworkController = Symbol('ITestFrameworkController');
6161
export interface ITestFrameworkController {
6262
resolveChildren(testController: TestController, item: TestItem, token?: CancellationToken): Promise<void>;
6363
refreshTestData(testController: TestController, resource?: Uri, token?: CancellationToken): Promise<void>;
64-
runTests(testRun: ITestRun, workspace: WorkspaceFolder, token: CancellationToken): Promise<void>;
64+
runTests(
65+
testRun: ITestRun,
66+
workspace: WorkspaceFolder,
67+
token: CancellationToken,
68+
testController?: TestController,
69+
): Promise<void>;
6570
}
6671

6772
export const ITestsRunner = Symbol('ITestsRunner');
6873
export interface ITestsRunner {
69-
runTests(testRun: ITestRun, options: TestRunOptions, idToRawData: Map<string, TestData>): Promise<void>;
74+
runTests(
75+
testRun: ITestRun,
76+
options: TestRunOptions,
77+
idToRawData: Map<string, TestData>,
78+
testController?: TestController,
79+
): Promise<void>;
7080
}
7181

7282
export type TestRunOptions = {

src/client/testing/testController/controller.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import { DelayedTrigger, IDelayedTrigger } from '../../common/utils/delayTrigger
2323
import { sendTelemetryEvent } from '../../telemetry';
2424
import { EventName } from '../../telemetry/constants';
2525
import { PYTEST_PROVIDER, UNITTEST_PROVIDER } from '../common/constants';
26-
import { getNodeByUri } from './common/testItemUtilities';
26+
import { DebugTestTag, getNodeByUri, RunTestTag } from './common/testItemUtilities';
2727
import { ITestController, ITestFrameworkController, TestRefreshOptions } from './common/types';
2828

2929
@injectable()
@@ -74,12 +74,19 @@ export class PythonTestController implements ITestController {
7474
this.refreshData = delayTrigger;
7575

7676
this.disposables.push(
77-
this.testController.createRunProfile('Run Tests', TestRunProfileKind.Run, this.runTests.bind(this), true),
77+
this.testController.createRunProfile(
78+
'Run Tests',
79+
TestRunProfileKind.Run,
80+
this.runTests.bind(this),
81+
true,
82+
RunTestTag,
83+
),
7884
this.testController.createRunProfile(
7985
'Debug Tests',
8086
TestRunProfileKind.Debug,
8187
this.runTests.bind(this),
8288
true,
89+
DebugTestTag,
8390
),
8491
);
8592
this.testController.resolveHandler = this.resolveChildren.bind(this);
@@ -244,6 +251,7 @@ export class PythonTestController implements ITestController {
244251
},
245252
workspace,
246253
token,
254+
this.testController,
247255
);
248256
}
249257
}

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

Lines changed: 84 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,15 @@
22
// Licensed under the MIT License.
33

44
import { injectable, inject, named } from 'inversify';
5-
import { Location, TestItem, TestMessage, TestRun, TestRunProfileKind } from 'vscode';
5+
import { Location, TestController, TestItem, TestMessage, TestRun, TestRunProfileKind } from 'vscode';
66
import { traceError, traceInfo } from '../../../common/logger';
77
import * as internalScripts from '../../../common/process/internal/scripts';
88
import { IOutputChannel } from '../../../common/types';
99
import { noop } from '../../../common/utils/misc';
1010
import { UNITTEST_PROVIDER } from '../../common/constants';
1111
import { ITestRunner, ITestDebugLauncher, IUnitTestSocketServer, LaunchOptions, Options } from '../../common/types';
1212
import { TEST_OUTPUT_CHANNEL } from '../../constants';
13-
import { getTestCaseNodes } from '../common/testItemUtilities';
13+
import { clearAllChildren, getTestCaseNodes } from '../common/testItemUtilities';
1414
import { ITestRun, ITestsRunner, TestData, TestRunInstanceOptions, TestRunOptions } from '../common/types';
1515
import { fixLogLines } from '../common/utils';
1616
import { getTestRunArgs } from './arguments';
@@ -20,6 +20,7 @@ interface ITestData {
2020
message: string;
2121
outcome: string;
2222
traceback: string;
23+
subtest?: string;
2324
}
2425

2526
@injectable()
@@ -35,6 +36,7 @@ export class UnittestRunner implements ITestsRunner {
3536
testRun: ITestRun,
3637
options: TestRunOptions,
3738
idToRawData: Map<string, TestData>,
39+
testController?: TestController,
3840
): Promise<void> {
3941
const runOptions: TestRunInstanceOptions = {
4042
...options,
@@ -43,7 +45,7 @@ export class UnittestRunner implements ITestsRunner {
4345
};
4446

4547
try {
46-
await this.runTest(testRun.includes, testRun.runInstance, runOptions, idToRawData);
48+
await this.runTest(testRun.includes, testRun.runInstance, runOptions, idToRawData, testController);
4749
} catch (ex) {
4850
testRun.runInstance.appendOutput(`Error while running tests:\r\n${ex}\r\n\r\n`);
4951
}
@@ -54,6 +56,7 @@ export class UnittestRunner implements ITestsRunner {
5456
runInstance: TestRun,
5557
options: TestRunInstanceOptions,
5658
idToRawData: Map<string, TestData>,
59+
testController?: TestController,
5760
): Promise<void> {
5861
runInstance.appendOutput(`Running tests (unittest): ${testNodes.map((t) => t.id).join(' ; ')}\r\n`);
5962
const testCaseNodes: TestItem[] = [];
@@ -77,12 +80,13 @@ export class UnittestRunner implements ITestsRunner {
7780
const tested: string[] = [];
7881

7982
const counts = {
80-
total: testCaseNodes.length,
83+
total: 0,
8184
passed: 0,
8285
skipped: 0,
8386
errored: 0,
8487
failed: 0,
8588
};
89+
const subTestStats: Map<string, { passed: number; failed: number }> = new Map();
8690

8791
let failFast = false;
8892
let stopTesting = false;
@@ -98,6 +102,7 @@ export class UnittestRunner implements ITestsRunner {
98102
const testCase = testCaseNodes.find((node) => idToRawData.get(node.id)?.runId === data.test);
99103
const rawTestCase = idToRawData.get(testCase?.id ?? '');
100104
if (testCase && rawTestCase) {
105+
counts.total += 1;
101106
tested.push(rawTestCase.runId);
102107

103108
if (data.outcome === 'passed' || data.outcome === 'failed-expected') {
@@ -147,6 +152,70 @@ export class UnittestRunner implements ITestsRunner {
147152
runInstance.skipped(testCase);
148153
runInstance.appendOutput(fixLogLines(text));
149154
counts.skipped += 1;
155+
} else if (data.outcome === 'subtest-passed') {
156+
const sub = subTestStats.get(data.test);
157+
if (sub) {
158+
sub.passed += 1;
159+
} else {
160+
counts.passed += 1;
161+
subTestStats.set(data.test, { passed: 1, failed: 0 });
162+
runInstance.appendOutput(fixLogLines(`${rawTestCase.rawId} [subtests]:\r\n`));
163+
164+
// We are seeing the first subtest for this node. Clear all other nodes under it
165+
// because we have no way to detect these at discovery, they can always be different
166+
// for each run.
167+
clearAllChildren(testCase);
168+
}
169+
if (data.subtest) {
170+
runInstance.appendOutput(fixLogLines(`${data.subtest} Passed\r\n`));
171+
172+
// This is a runtime only node for unittest subtest, since they can only be detected
173+
// at runtime. So, create a fresh one for each result.
174+
const subtest = testController?.createTestItem(data.subtest, data.subtest);
175+
if (subtest) {
176+
testCase.children.add(subtest);
177+
runInstance.started(subtest);
178+
runInstance.passed(subtest);
179+
}
180+
}
181+
} else if (data.outcome === 'subtest-failed') {
182+
const sub = subTestStats.get(data.test);
183+
if (sub) {
184+
sub.failed += 1;
185+
} else {
186+
counts.failed += 1;
187+
subTestStats.set(data.test, { passed: 0, failed: 1 });
188+
189+
runInstance.appendOutput(fixLogLines(`${rawTestCase.rawId} [subtests]:\r\n`));
190+
191+
// We are seeing the first subtest for this node. Clear all other nodes under it
192+
// because we have no way to detect these at discovery, they can always be different
193+
// for each run.
194+
clearAllChildren(testCase);
195+
}
196+
197+
if (data.subtest) {
198+
runInstance.appendOutput(fixLogLines(`${data.subtest} Failed\r\n`));
199+
const traceback = data.traceback
200+
? data.traceback.splitLines({ trim: false, removeEmptyEntries: true }).join('\r\n')
201+
: '';
202+
const text = `${data.subtest} Failed: ${data.message ?? data.outcome}\r\n${traceback}\r\n`;
203+
runInstance.appendOutput(fixLogLines(text));
204+
205+
// This is a runtime only node for unittest subtest, since they can only be detected
206+
// at runtime. So, create a fresh one for each result.
207+
const subtest = testController?.createTestItem(data.subtest, data.subtest);
208+
if (subtest) {
209+
testCase.children.add(subtest);
210+
runInstance.started(subtest);
211+
const message = new TestMessage(text);
212+
if (testCase.uri && testCase.range) {
213+
message.location = new Location(testCase.uri, testCase.range);
214+
}
215+
216+
runInstance.failed(subtest, message);
217+
}
218+
}
150219
} else {
151220
const text = `Unknown outcome type for test ${rawTestCase.rawId}: ${data.outcome}`;
152221
runInstance.appendOutput(fixLogLines(text));
@@ -233,7 +302,17 @@ export class UnittestRunner implements ITestsRunner {
233302
runInstance.appendOutput(`Total number of tests passed: ${counts.passed}\r\n`);
234303
runInstance.appendOutput(`Total number of tests failed: ${counts.failed}\r\n`);
235304
runInstance.appendOutput(`Total number of tests failed with errors: ${counts.errored}\r\n`);
236-
runInstance.appendOutput(`Total number of tests skipped: ${counts.skipped}\r\n`);
305+
runInstance.appendOutput(`Total number of tests skipped: ${counts.skipped}\r\n\r\n`);
306+
307+
if (subTestStats.size > 0) {
308+
runInstance.appendOutput('Sub-test stats: \r\n');
309+
}
310+
311+
subTestStats.forEach((v, k) => {
312+
runInstance.appendOutput(
313+
`Sub-tests for [${k}]: Total=${v.passed + v.failed} Passed=${v.passed} Failed=${v.failed}\r\n\r\n`,
314+
);
315+
});
237316

238317
if (failFast) {
239318
runInstance.appendOutput(

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -240,7 +240,12 @@ export class UnittestController implements ITestFrameworkController {
240240
return Promise.resolve();
241241
}
242242

243-
public runTests(testRun: ITestRun, workspace: WorkspaceFolder, token: CancellationToken): Promise<void> {
243+
public runTests(
244+
testRun: ITestRun,
245+
workspace: WorkspaceFolder,
246+
token: CancellationToken,
247+
testController?: TestController,
248+
): Promise<void> {
244249
const settings = this.configService.getSettings(workspace.uri);
245250
return this.runner.runTests(
246251
testRun,
@@ -254,6 +259,7 @@ export class UnittestController implements ITestFrameworkController {
254259
args: settings.testing.unittestArgs,
255260
},
256261
this.idToRawData,
262+
testController,
257263
);
258264
}
259265
}

0 commit comments

Comments
 (0)