Skip to content

Commit 6a12a25

Browse files
authored
Test discovery using Python pytest (microsoft#4795)
For microsoft#4035. This is a refactor of microsoft#4695.
1 parent eb3478a commit 6a12a25

26 files changed

+3485
-1215
lines changed

news/1 Enhancements/4795.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Use `Python` code for discovery of tests when using `pytest`.

src/client/telemetry/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ export enum EventName {
4343
UNITTEST_DISABLE = 'UNITTEST.DISABLE',
4444
UNITTEST_RUN = 'UNITTEST.RUN',
4545
UNITTEST_DISCOVER = 'UNITTEST.DISCOVER',
46+
UNITTEST_DISCOVER_WITH_PYCODE = 'UNITTEST.DISCOVER.WITH.PYTHONCODE',
4647
UNITTEST_CONFIGURE = 'UNITTEST.CONFIGURE',
4748
UNITTEST_CONFIGURING = 'UNITTEST.CONFIGURING',
4849
UNITTEST_VIEW_OUTPUT = 'UNITTEST.VIEW_OUTPUT',

src/client/telemetry/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,7 @@ export interface IEventNamePropertyMapping {
310310
[EventName.UNITTEST_CONFIGURING]: TestConfiguringTelemetry;
311311
[EventName.TERMINAL_CREATE]: TerminalTelemetry;
312312
[EventName.UNITTEST_DISCOVER]: TestDiscoverytTelemetry;
313+
[EventName.UNITTEST_DISCOVER_WITH_PYCODE]: never | undefined;
313314
[EventName.UNITTEST_RUN]: TestRunTelemetry;
314315
[EventName.UNITTEST_STOP]: never | undefined;
315316
[EventName.UNITTEST_DISABLE]: never | undefined;
Lines changed: 296 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,296 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
'use strict';
5+
6+
import { inject, injectable } from 'inversify';
7+
import * as path from 'path';
8+
import { Uri } from 'vscode';
9+
import { IWorkspaceService } from '../../../common/application/types';
10+
import { traceError } from '../../../common/logger';
11+
import { IFileSystem } from '../../../common/platform/types';
12+
import { TestDataItem } from '../../types';
13+
import { getParentFile, getParentSuite, getTestType } from '../testUtils';
14+
import { FlattenedTestFunction, FlattenedTestSuite, SubtestParent, TestFile, TestFolder, TestFunction, Tests, TestSuite, TestType } from '../types';
15+
import { DiscoveredTests, ITestDiscoveredTestParser, TestContainer, TestItem } from './types';
16+
17+
@injectable()
18+
export class TestDiscoveredTestParser implements ITestDiscoveredTestParser {
19+
constructor(@inject(IFileSystem) private readonly fs: IFileSystem,
20+
@inject(IWorkspaceService) private readonly workspaceService: IWorkspaceService) { }
21+
public parse(resource: Uri, discoveredTests: DiscoveredTests[]): Tests {
22+
const tests: Tests = {
23+
rootTestFolders: [],
24+
summary: { errors: 0, failures: 0, passed: 0, skipped: 0 },
25+
testFiles: [],
26+
testFolders: [],
27+
testFunctions: [],
28+
testSuites: []
29+
};
30+
31+
const workspace = this.workspaceService.getWorkspaceFolder(resource);
32+
if (!workspace) {
33+
traceError('Resource does not belong to any workspace folder');
34+
return tests;
35+
}
36+
37+
// If the root is the workspace folder, then ignore that.
38+
for (const data of discoveredTests) {
39+
const rootFolder = {
40+
name: data.root, folders: [], time: 0,
41+
testFiles: [], resource: resource, nameToRun: data.rootid
42+
};
43+
tests.rootTestFolders.push(rootFolder);
44+
tests.testFolders.push(rootFolder);
45+
this.buildChildren(rootFolder, rootFolder, data, tests);
46+
this.fixRootFolders(workspace.uri, data, tests);
47+
}
48+
49+
return tests;
50+
}
51+
/**
52+
* Users workspace folder is not to be treated as the root.
53+
* All root folders are relative to the worspace folder.
54+
* @protected
55+
* @param {Uri} workspaceFolder
56+
* @param {DiscoveredTests} discoveredTests
57+
* @param {Tests} tests
58+
* @returns {void}
59+
* @memberof TestDiscoveredTestParser
60+
*/
61+
protected fixRootFolders(workspaceFolder: Uri, discoveredTests: DiscoveredTests, tests: Tests): void {
62+
const isWorkspaceFolderTheRoot = this.fs.arePathsSame(discoveredTests.root, workspaceFolder.fsPath);
63+
if (!isWorkspaceFolderTheRoot) {
64+
return;
65+
}
66+
const indexToRemove = tests.rootTestFolders.findIndex(folder => folder.name === discoveredTests.root);
67+
const rootFolder = tests.rootTestFolders.splice(indexToRemove, 1)[0];
68+
tests.rootTestFolders.push(...rootFolder.folders);
69+
}
70+
/**
71+
* Not the best solution to use `case statements`, but it keeps the code simple and easy to read in one place.
72+
* Could go with separate classes for each type and use stratergies, but that just ends up a class for
73+
* 10 lines of code. Hopefully this is more readable and maintainable than having multiple classes for
74+
* the simple processing of the children.
75+
*
76+
* @protected
77+
* @param {TestFolder} rootFolder
78+
* @param {TestDataItem} parent
79+
* @param {DiscoveredTests} discoveredTests
80+
* @param {Tests} tests
81+
* @memberof TestsDiscovery
82+
*/
83+
protected buildChildren(rootFolder: TestFolder, parent: TestDataItem, discoveredTests: DiscoveredTests, tests: Tests) {
84+
const parentType = getTestType(parent);
85+
switch (parentType) {
86+
case TestType.testFolder: {
87+
this.processFolder(rootFolder, parent as TestFolder, discoveredTests, tests);
88+
break;
89+
}
90+
case TestType.testFile: {
91+
this.processFile(rootFolder, parent as TestFile, discoveredTests, tests);
92+
break;
93+
}
94+
case TestType.testSuite: {
95+
this.processSuite(rootFolder, parent as TestSuite, discoveredTests, tests);
96+
break;
97+
}
98+
default:
99+
break;
100+
}
101+
}
102+
/**
103+
* Process the children of a folder.
104+
* A folder can only contain other folders and files.
105+
* Hence limit processing to those items.
106+
*
107+
* @protected
108+
* @param {TestFolder} rootFolder
109+
* @param {TestFolder} parentFolder
110+
* @param {DiscoveredTests} discoveredTests
111+
* @param {Tests} tests
112+
* @memberof TestDiscoveredTestParser
113+
*/
114+
protected processFolder(rootFolder: TestFolder, parentFolder: TestFolder, discoveredTests: DiscoveredTests, tests: Tests) {
115+
const folders = discoveredTests.parents
116+
.filter(child => child.kind === 'folder' && child.parentid === parentFolder.nameToRun)
117+
.map(folder => createTestFolder(rootFolder, folder));
118+
119+
const files = discoveredTests.parents
120+
.filter(child => child.kind === 'file' && child.parentid === parentFolder.nameToRun)
121+
.map(file => createTestFile(rootFolder, file));
122+
123+
parentFolder.folders.push(...folders);
124+
parentFolder.testFiles.push(...files);
125+
tests.testFolders.push(...folders);
126+
tests.testFiles.push(...files);
127+
[...folders, ...files].forEach(item => this.buildChildren(rootFolder, item, discoveredTests, tests));
128+
}
129+
/**
130+
* Process the children of a file.
131+
* A file can only contain suites, functions and paramerterized functions.
132+
* Hence limit processing just to those items.
133+
*
134+
* @protected
135+
* @param {TestFolder} rootFolder
136+
* @param {TestFile} parentFile
137+
* @param {DiscoveredTests} discoveredTests
138+
* @param {Tests} tests
139+
* @memberof TestDiscoveredTestParser
140+
*/
141+
protected processFile(rootFolder: TestFolder, parentFile: TestFile, discoveredTests: DiscoveredTests, tests: Tests) {
142+
const suites = discoveredTests.parents
143+
.filter(child => child.kind === 'suite' && child.parentid === parentFile.nameToRun)
144+
.map(suite => createTestSuite(parentFile, rootFolder.resource, suite));
145+
146+
const functions = discoveredTests.tests
147+
.filter(func => func.parentid === parentFile.nameToRun)
148+
.map(func => createTestFunction(rootFolder, func));
149+
150+
parentFile.suites.push(...suites);
151+
parentFile.functions.push(...functions);
152+
tests.testSuites.push(...suites.map(suite => createFlattenedSuite(tests, suite)));
153+
tests.testFunctions.push(...functions.map(func => createFlattenedFunction(tests, func)));
154+
suites.forEach(item => this.buildChildren(rootFolder, item, discoveredTests, tests));
155+
156+
const parameterizedFunctions = discoveredTests.parents
157+
.filter(child => child.kind === 'function' && child.parentid === parentFile.nameToRun)
158+
.map(func => createParameterizedTestFunction(rootFolder, func));
159+
parameterizedFunctions.forEach(func => this.processParameterizedFunction(rootFolder, parentFile, func, discoveredTests, tests));
160+
}
161+
/**
162+
* Process the children of a suite.
163+
* A suite can only contain suites, functions and paramerterized functions.
164+
* Hence limit processing just to those items.
165+
*
166+
* @protected
167+
* @param {TestFolder} rootFolder
168+
* @param {TestSuite} parentSuite
169+
* @param {DiscoveredTests} discoveredTests
170+
* @param {Tests} tests
171+
* @memberof TestDiscoveredTestParser
172+
*/
173+
protected processSuite(rootFolder: TestFolder, parentSuite: TestSuite, discoveredTests: DiscoveredTests, tests: Tests) {
174+
const suites = discoveredTests.parents
175+
.filter(child => child.kind === 'suite' && child.parentid === parentSuite.nameToRun)
176+
.map(suite => createTestSuite(parentSuite, rootFolder.resource, suite));
177+
178+
const functions = discoveredTests.tests
179+
.filter(func => func.parentid === parentSuite.nameToRun)
180+
.map(func => createTestFunction(rootFolder, func));
181+
182+
parentSuite.suites.push(...suites);
183+
parentSuite.functions.push(...functions);
184+
tests.testSuites.push(...suites.map(suite => createFlattenedSuite(tests, suite)));
185+
tests.testFunctions.push(...functions.map(func => createFlattenedFunction(tests, func)));
186+
suites.forEach(item => this.buildChildren(rootFolder, item, discoveredTests, tests));
187+
188+
const parameterizedFunctions = discoveredTests.parents
189+
.filter(child => child.kind === 'function' && child.parentid === parentSuite.nameToRun)
190+
.map(func => createParameterizedTestFunction(rootFolder, func));
191+
parameterizedFunctions.forEach(func => this.processParameterizedFunction(rootFolder, parentSuite, func, discoveredTests, tests));
192+
}
193+
/**
194+
* Process the children of a parameterized function.
195+
* A parameterized function can only contain functions (in tests).
196+
* Hence limit processing just to those items.
197+
*
198+
* @protected
199+
* @param {TestFolder} rootFolder
200+
* @param {TestFunction} parentFunction
201+
* @param {DiscoveredTests} discoveredTests
202+
* @param {Tests} tests
203+
* @returns
204+
* @memberof TestDiscoveredTestParser
205+
*/
206+
protected processParameterizedFunction(rootFolder: TestFolder, parent: TestFile | TestSuite, parentFunction: SubtestParent, discoveredTests: DiscoveredTests, tests: Tests) {
207+
if (!parentFunction.asSuite) {
208+
return;
209+
}
210+
const functions = discoveredTests.tests
211+
.filter(func => func.parentid === parentFunction.nameToRun)
212+
.map(func => createTestFunction(rootFolder, func));
213+
functions.map(func => func.subtestParent = parentFunction);
214+
parentFunction.asSuite.functions.push(...functions);
215+
parent.functions.push(...functions);
216+
tests.testFunctions.push(...functions.map(func => createFlattenedParameterizedFunction(tests, func, parent)));
217+
}
218+
}
219+
220+
function createTestFolder(root: TestFolder, item: TestContainer): TestFolder {
221+
return {
222+
name: item.name, nameToRun: item.id, resource: root.resource, time: 0, folders: [], testFiles: []
223+
};
224+
}
225+
function createTestFile(root: TestFolder, item: TestContainer): TestFile {
226+
const fullyQualifiedName = path.isAbsolute(item.id) ? item.id : path.resolve(root.name, item.id);
227+
return {
228+
fullPath: fullyQualifiedName, functions: [], name: item.name,
229+
nameToRun: item.id, resource: root.resource, suites: [], time: 0, xmlName: createXmlName(item.id)
230+
};
231+
}
232+
function createTestSuite(parentSuiteFile: TestFile | TestSuite, resource: Uri, item: TestContainer): TestSuite {
233+
const suite = {
234+
functions: [], name: item.name, nameToRun: item.id, resource: resource,
235+
suites: [], time: 0, xmlName: '', isInstance: false, isUnitTest: false
236+
};
237+
suite.xmlName = `${parentSuiteFile.xmlName}.${item.name}`;
238+
return suite;
239+
}
240+
function createFlattenedSuite(tests: Tests, suite: TestSuite): FlattenedTestSuite {
241+
const parentFile = getParentFile(tests, suite);
242+
return {
243+
parentTestFile: parentFile, testSuite: suite, xmlClassName: parentFile.xmlName
244+
};
245+
}
246+
function createFlattenedParameterizedFunction(tests: Tests, func: TestFunction, parent: TestFile | TestSuite): FlattenedTestFunction {
247+
const type = getTestType(parent);
248+
const parentFile = (type && type === TestType.testSuite) ? getParentFile(tests, func) : parent as TestFile;
249+
const parentSuite = (type && type === TestType.testSuite) ? parent as TestSuite : undefined;
250+
return {
251+
parentTestFile: parentFile, parentTestSuite: parentSuite,
252+
xmlClassName: parentSuite ? parentSuite.xmlName : parentFile.xmlName, testFunction: func
253+
};
254+
}
255+
function createFlattenedFunction(tests: Tests, func: TestFunction): FlattenedTestFunction {
256+
const parent = getParentFile(tests, func);
257+
const type = parent ? getTestType(parent) : undefined;
258+
const parentFile = (type && type === TestType.testSuite) ? getParentFile(tests, func) : parent as TestFile;
259+
const parentSuite = getParentSuite(tests, func);
260+
return {
261+
parentTestFile: parentFile, parentTestSuite: parentSuite,
262+
xmlClassName: parentSuite ? parentSuite.xmlName : parentFile.xmlName, testFunction: func
263+
};
264+
}
265+
function createParameterizedTestFunction(root: TestFolder, item: TestContainer): SubtestParent {
266+
const suite: TestSuite = {
267+
functions: [], isInstance: false, isUnitTest: false,
268+
name: item.name, nameToRun: item.id, resource: root.resource,
269+
time: 0, suites: [], xmlName: ''
270+
};
271+
return {
272+
asSuite: suite, name: item.name, nameToRun: item.id, time: 0
273+
};
274+
}
275+
function createTestFunction(root: TestFolder, item: TestItem): TestFunction {
276+
return {
277+
name: item.name, nameToRun: item.id, resource: root.resource,
278+
time: 0, file: item.source.substr(0, item.source.lastIndexOf(':'))
279+
};
280+
}
281+
/**
282+
* Creates something known as an Xml Name, used to identify items
283+
* from an xunit test result.
284+
* Once we have the test runner done in Python, this can be discarded.
285+
* @param {string} fileId
286+
* @returns
287+
*/
288+
function createXmlName(fileId: string) {
289+
let name = path.join(path.dirname(fileId), path.basename(fileId, path.extname(fileId)));
290+
name = name.replace(/\\/g, '.').replace(/\//g, '.');
291+
// Remove leading . & / & \
292+
while (name.startsWith('.') || name.startsWith('/') || name.startsWith('\\')) {
293+
name = name.substring(1);
294+
}
295+
return name;
296+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
'use strict';
5+
6+
import { inject, injectable } from 'inversify';
7+
import * as path from 'path';
8+
import { traceError } from '../../../common/logger';
9+
import { ExecutionFactoryCreateWithEnvironmentOptions, ExecutionResult, IPythonExecutionFactory, SpawnOptions } from '../../../common/process/types';
10+
import { EXTENSION_ROOT_DIR } from '../../../constants';
11+
import { captureTelemetry } from '../../../telemetry';
12+
import { EventName } from '../../../telemetry/constants';
13+
import { ITestDiscoveryService, TestDiscoveryOptions, Tests } from '../types';
14+
import { DiscoveredTests, ITestDiscoveredTestParser } from './types';
15+
16+
const DISCOVERY_FILE = path.join(EXTENSION_ROOT_DIR, 'pythonFiles', 'testing_tools', 'run_adapter.py');
17+
18+
@injectable()
19+
export class TestsDiscoveryService implements ITestDiscoveryService {
20+
constructor(@inject(IPythonExecutionFactory) private readonly execFactory: IPythonExecutionFactory,
21+
@inject(ITestDiscoveredTestParser) private readonly parser: ITestDiscoveredTestParser) { }
22+
@captureTelemetry(EventName.UNITTEST_DISCOVER_WITH_PYCODE, undefined, true)
23+
public async discoverTests(options: TestDiscoveryOptions): Promise<Tests> {
24+
let output: ExecutionResult<string> | undefined;
25+
try {
26+
output = await this.exec(options);
27+
const discoveredTests = JSON.parse(output.stdout) as DiscoveredTests[];
28+
return this.parser.parse(options.workspaceFolder, discoveredTests);
29+
} catch (ex) {
30+
if (output) {
31+
traceError('Failed to parse discovered Test', new Error(output.stdout));
32+
}
33+
traceError('Failed to parse discovered Test', ex);
34+
throw ex;
35+
}
36+
}
37+
public async exec(options: TestDiscoveryOptions): Promise<ExecutionResult<string>> {
38+
const creationOptions: ExecutionFactoryCreateWithEnvironmentOptions = {
39+
allowEnvironmentFetchExceptions: false,
40+
resource: options.workspaceFolder
41+
};
42+
const execService = await this.execFactory.createActivatedEnvironment(creationOptions);
43+
const spawnOptions: SpawnOptions = {
44+
token: options.token,
45+
cwd: options.cwd,
46+
throwOnStdErr: true
47+
};
48+
return execService.exec([DISCOVERY_FILE, ...options.args], spawnOptions);
49+
}
50+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
'use strict';
5+
6+
import { Uri } from 'vscode';
7+
import { Tests } from '../types';
8+
9+
export type TestContainer = {
10+
id: string;
11+
kind: 'file' | 'folder' | 'suite' | 'function';
12+
name: string;
13+
parentid: string;
14+
};
15+
export type TestItem = {
16+
id: string;
17+
name: string;
18+
source: string;
19+
parentid: string;
20+
};
21+
export type DiscoveredTests = {
22+
rootid: string;
23+
root: string;
24+
parents: TestContainer[];
25+
tests: TestItem[];
26+
};
27+
28+
export const ITestDiscoveredTestParser = Symbol('ITestDiscoveredTestParser');
29+
export interface ITestDiscoveredTestParser {
30+
parse(resource: Uri, discoveredTests: DiscoveredTests[]): Tests;
31+
}

0 commit comments

Comments
 (0)