Skip to content

Commit 3ce3da8

Browse files
authored
[FTR] Add test suite metrics tracking/output (#62515)
1 parent 53a0752 commit 3ce3da8

File tree

4 files changed

+348
-0
lines changed

4 files changed

+348
-0
lines changed

packages/kbn-test/src/functional_test_runner/functional_test_runner.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import {
3030
setupMocha,
3131
runTests,
3232
Config,
33+
SuiteTracker,
3334
} from './lib';
3435

3536
export class FunctionalTestRunner {
@@ -52,6 +53,8 @@ export class FunctionalTestRunner {
5253

5354
async run() {
5455
return await this._run(async (config, coreProviders) => {
56+
SuiteTracker.startTracking(this.lifecycle, this.configFile);
57+
5558
const providers = new ProviderCollection(this.log, [
5659
...coreProviders,
5760
...readProviderSpec('Service', config.get('services')),

packages/kbn-test/src/functional_test_runner/lib/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,4 @@ export { readConfigFile, Config } from './config';
2323
export { readProviderSpec, ProviderCollection, Provider } from './providers';
2424
export { runTests, setupMocha } from './mocha';
2525
export { FailureMetadata } from './failure_metadata';
26+
export { SuiteTracker } from './suite_tracker';
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
/*
2+
* Licensed to Elasticsearch B.V. under one or more contributor
3+
* license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright
5+
* ownership. Elasticsearch B.V. licenses this file to you under
6+
* the Apache License, Version 2.0 (the "License"); you may
7+
* not use this file except in compliance with the License.
8+
* You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
20+
import fs from 'fs';
21+
import { join, resolve } from 'path';
22+
23+
jest.mock('fs');
24+
jest.mock('@kbn/dev-utils', () => {
25+
return { REPO_ROOT: '/dev/null/root' };
26+
});
27+
28+
import { REPO_ROOT } from '@kbn/dev-utils';
29+
import { Lifecycle } from './lifecycle';
30+
import { SuiteTracker } from './suite_tracker';
31+
32+
const DEFAULT_TEST_METADATA_PATH = join(REPO_ROOT, 'target', 'test_metadata.json');
33+
const MOCK_CONFIG_PATH = join('test', 'config.js');
34+
const MOCK_TEST_PATH = join('test', 'apps', 'test.js');
35+
const ENVS_TO_RESET = ['TEST_METADATA_PATH'];
36+
37+
describe('SuiteTracker', () => {
38+
const originalEnvs: Record<string, string> = {};
39+
40+
beforeEach(() => {
41+
for (const env of ENVS_TO_RESET) {
42+
if (env in process.env) {
43+
originalEnvs[env] = process.env[env] || '';
44+
delete process.env[env];
45+
}
46+
}
47+
});
48+
49+
afterEach(() => {
50+
for (const env of ENVS_TO_RESET) {
51+
delete process.env[env];
52+
}
53+
54+
for (const env of Object.keys(originalEnvs)) {
55+
process.env[env] = originalEnvs[env];
56+
}
57+
58+
jest.resetAllMocks();
59+
});
60+
61+
let MOCKS: Record<string, object>;
62+
63+
const createMock = (overrides = {}) => {
64+
return {
65+
file: resolve(REPO_ROOT, MOCK_TEST_PATH),
66+
title: 'A Test',
67+
suiteTag: MOCK_TEST_PATH,
68+
...overrides,
69+
};
70+
};
71+
72+
const runLifecycleWithMocks = async (mocks: object[], fn: (objs: any) => any = () => {}) => {
73+
const lifecycle = new Lifecycle();
74+
const suiteTracker = SuiteTracker.startTracking(
75+
lifecycle,
76+
resolve(REPO_ROOT, MOCK_CONFIG_PATH)
77+
);
78+
79+
const ret = { lifecycle, suiteTracker };
80+
81+
for (const mock of mocks) {
82+
await lifecycle.beforeTestSuite.trigger(mock);
83+
}
84+
85+
if (fn) {
86+
fn(ret);
87+
}
88+
89+
for (const mock of mocks.reverse()) {
90+
await lifecycle.afterTestSuite.trigger(mock);
91+
}
92+
93+
return ret;
94+
};
95+
96+
beforeEach(() => {
97+
MOCKS = {
98+
WITH_TESTS: createMock({ tests: [{}] }), // i.e. a describe with tests in it
99+
WITHOUT_TESTS: createMock(), // i.e. a describe with only other describes in it
100+
};
101+
});
102+
103+
it('collects metadata for a single suite with multiple describe()s', async () => {
104+
const { suiteTracker } = await runLifecycleWithMocks([MOCKS.WITHOUT_TESTS, MOCKS.WITH_TESTS]);
105+
106+
const suites = suiteTracker.getAllFinishedSuites();
107+
expect(suites.length).toBe(1);
108+
const suite = suites[0];
109+
110+
expect(suite).toMatchObject({
111+
config: MOCK_CONFIG_PATH,
112+
file: MOCK_TEST_PATH,
113+
tag: MOCK_TEST_PATH,
114+
hasTests: true,
115+
success: true,
116+
});
117+
});
118+
119+
it('writes metadata to a file when cleanup is triggered', async () => {
120+
const { lifecycle, suiteTracker } = await runLifecycleWithMocks([MOCKS.WITH_TESTS]);
121+
await lifecycle.cleanup.trigger();
122+
123+
const suites = suiteTracker.getAllFinishedSuites();
124+
125+
const call = (fs.writeFileSync as jest.Mock).mock.calls[0];
126+
expect(call[0]).toEqual(DEFAULT_TEST_METADATA_PATH);
127+
expect(call[1]).toEqual(JSON.stringify(suites, null, 2));
128+
});
129+
130+
it('respects TEST_METADATA_PATH env var for metadata target override', async () => {
131+
process.env.TEST_METADATA_PATH = resolve(REPO_ROOT, '../fake-test-path');
132+
const { lifecycle } = await runLifecycleWithMocks([MOCKS.WITH_TESTS]);
133+
await lifecycle.cleanup.trigger();
134+
135+
expect((fs.writeFileSync as jest.Mock).mock.calls[0][0]).toEqual(
136+
process.env.TEST_METADATA_PATH
137+
);
138+
});
139+
140+
it('identifies suites with tests as leaf suites', async () => {
141+
const root = createMock({ title: 'root', file: join(REPO_ROOT, 'root.js') });
142+
const parent = createMock({ parent: root });
143+
const withTests = createMock({ parent, tests: [{}] });
144+
145+
const { suiteTracker } = await runLifecycleWithMocks([root, parent, withTests]);
146+
const suites = suiteTracker.getAllFinishedSuites();
147+
148+
const finishedRoot = suites.find(s => s.title === 'root');
149+
const finishedWithTests = suites.find(s => s.title !== 'root');
150+
151+
expect(finishedRoot).toBeTruthy();
152+
expect(finishedRoot?.hasTests).toBeFalsy();
153+
expect(finishedWithTests?.hasTests).toBe(true);
154+
});
155+
156+
describe('with a failing suite', () => {
157+
let root: any;
158+
let parent: any;
159+
let failed: any;
160+
161+
beforeEach(() => {
162+
root = createMock({ file: join(REPO_ROOT, 'root.js') });
163+
parent = createMock({ parent: root });
164+
failed = createMock({ parent, tests: [{}] });
165+
});
166+
167+
it('marks parent suites as not successful when a test fails', async () => {
168+
const { suiteTracker } = await runLifecycleWithMocks(
169+
[root, parent, failed],
170+
async ({ lifecycle }) => {
171+
await lifecycle.testFailure.trigger(Error('test'), { parent: failed });
172+
}
173+
);
174+
175+
const suites = suiteTracker.getAllFinishedSuites();
176+
expect(suites.length).toBe(2);
177+
for (const suite of suites) {
178+
expect(suite.success).toBeFalsy();
179+
}
180+
});
181+
182+
it('marks parent suites as not successful when a test hook fails', async () => {
183+
const { suiteTracker } = await runLifecycleWithMocks(
184+
[root, parent, failed],
185+
async ({ lifecycle }) => {
186+
await lifecycle.testHookFailure.trigger(Error('test'), { parent: failed });
187+
}
188+
);
189+
190+
const suites = suiteTracker.getAllFinishedSuites();
191+
expect(suites.length).toBe(2);
192+
for (const suite of suites) {
193+
expect(suite.success).toBeFalsy();
194+
}
195+
});
196+
});
197+
});
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
/*
2+
* Licensed to Elasticsearch B.V. under one or more contributor
3+
* license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright
5+
* ownership. Elasticsearch B.V. licenses this file to you under
6+
* the Apache License, Version 2.0 (the "License"); you may
7+
* not use this file except in compliance with the License.
8+
* You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
import fs from 'fs';
20+
import { dirname, relative, resolve } from 'path';
21+
22+
import { REPO_ROOT } from '@kbn/dev-utils';
23+
24+
import { Lifecycle } from './lifecycle';
25+
26+
export interface SuiteInProgress {
27+
startTime?: Date;
28+
endTime?: Date;
29+
success?: boolean;
30+
}
31+
32+
export interface SuiteWithMetadata {
33+
config: string;
34+
file: string;
35+
tag: string;
36+
title: string;
37+
startTime: Date;
38+
endTime: Date;
39+
duration: number;
40+
success: boolean;
41+
hasTests: boolean;
42+
}
43+
44+
const getTestMetadataPath = () => {
45+
return process.env.TEST_METADATA_PATH || resolve(REPO_ROOT, 'target', 'test_metadata.json');
46+
};
47+
48+
export class SuiteTracker {
49+
finishedSuitesByConfig: Record<string, Record<string, SuiteWithMetadata>> = {};
50+
inProgressSuites: Map<object, SuiteInProgress> = new Map<object, SuiteInProgress>();
51+
52+
static startTracking(lifecycle: Lifecycle, configPath: string): SuiteTracker {
53+
const suiteTracker = new SuiteTracker(lifecycle, configPath);
54+
return suiteTracker;
55+
}
56+
57+
getTracked(suite: object): SuiteInProgress {
58+
if (!this.inProgressSuites.has(suite)) {
59+
this.inProgressSuites.set(suite, { success: undefined } as SuiteInProgress);
60+
}
61+
return this.inProgressSuites.get(suite)!;
62+
}
63+
64+
constructor(lifecycle: Lifecycle, configPathAbsolute: string) {
65+
if (fs.existsSync(getTestMetadataPath())) {
66+
fs.unlinkSync(getTestMetadataPath());
67+
} else {
68+
fs.mkdirSync(dirname(getTestMetadataPath()), { recursive: true });
69+
}
70+
71+
const config = relative(REPO_ROOT, configPathAbsolute);
72+
73+
lifecycle.beforeTestSuite.add(suite => {
74+
const tracked = this.getTracked(suite);
75+
tracked.startTime = new Date();
76+
});
77+
78+
// If a test fails, we want to make sure all of the ancestors, all the way up to the root, get marked as failed
79+
// This information is not available on the mocha objects without traversing all descendants of a given node
80+
const handleFailure = (_: any, test: any) => {
81+
let parent = test.parent;
82+
83+
// Infinite loop protection, just in case
84+
for (let i = 0; i < 500 && parent; i++) {
85+
if (this.inProgressSuites.has(parent)) {
86+
this.getTracked(parent).success = false;
87+
}
88+
parent = parent.parent;
89+
}
90+
};
91+
92+
lifecycle.testFailure.add(handleFailure);
93+
lifecycle.testHookFailure.add(handleFailure);
94+
95+
lifecycle.afterTestSuite.add(suite => {
96+
const tracked = this.getTracked(suite);
97+
tracked.endTime = new Date();
98+
99+
// The suite ended without any children failing, so we can mark it as successful
100+
if (typeof tracked.success === 'undefined') {
101+
tracked.success = true;
102+
}
103+
104+
let duration = tracked.endTime.getTime() - (tracked.startTime || new Date()).getTime();
105+
duration = Math.floor(duration / 1000);
106+
107+
const file = relative(REPO_ROOT, suite.file);
108+
109+
this.finishedSuitesByConfig[config] = this.finishedSuitesByConfig[config] || {};
110+
111+
// This will get called multiple times for a test file that has multiple describes in it or similar
112+
// This is okay, because the last one that fires is always the root of the file, which is is the one we ultimately want
113+
this.finishedSuitesByConfig[config][file] = {
114+
...tracked,
115+
duration,
116+
config,
117+
file,
118+
tag: suite.suiteTag,
119+
title: suite.title,
120+
hasTests: !!(
121+
(suite.tests && suite.tests.length) ||
122+
// The below statement is so that `hasTests` will bubble up nested describes in the same file
123+
(this.finishedSuitesByConfig[config][file] &&
124+
this.finishedSuitesByConfig[config][file].hasTests)
125+
),
126+
} as SuiteWithMetadata;
127+
});
128+
129+
lifecycle.cleanup.add(() => {
130+
const suites = this.getAllFinishedSuites();
131+
132+
fs.writeFileSync(getTestMetadataPath(), JSON.stringify(suites, null, 2));
133+
});
134+
}
135+
136+
getAllFinishedSuites() {
137+
const flattened: SuiteWithMetadata[] = [];
138+
for (const byFile of Object.values(this.finishedSuitesByConfig)) {
139+
for (const suite of Object.values(byFile)) {
140+
flattened.push(suite);
141+
}
142+
}
143+
144+
flattened.sort((a, b) => b.duration - a.duration);
145+
return flattened;
146+
}
147+
}

0 commit comments

Comments
 (0)