feat(test runner): implement test.describe.serial (#8132)
This commit is contained in:
parent
64da74fba8
commit
a5e0965087
|
|
@ -245,6 +245,82 @@ A callback that is run immediately when calling [`method: Test.describe.only`].
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## method: Test.describe.serial
|
||||||
|
|
||||||
|
Declares a group of tests that should always be run serially. If one of the tests fails, all subsequent tests are skipped. All tests in a group are retried together.
|
||||||
|
|
||||||
|
:::note
|
||||||
|
Using serial is not recommended. It is usually better to make your tests isolated, so they can be run independently.
|
||||||
|
:::
|
||||||
|
|
||||||
|
```js js-flavor=js
|
||||||
|
test.describe.serial('group', () => {
|
||||||
|
test('runs first', async ({ page }) => {
|
||||||
|
});
|
||||||
|
test('runs second', async ({ page }) => {
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
```js js-flavor=ts
|
||||||
|
test.describe.serial('group', () => {
|
||||||
|
test('runs first', async ({ page }) => {
|
||||||
|
});
|
||||||
|
test('runs second', async ({ page }) => {
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### param: Test.describe.serial.title
|
||||||
|
- `title` <[string]>
|
||||||
|
|
||||||
|
Group title.
|
||||||
|
|
||||||
|
### param: Test.describe.serial.callback
|
||||||
|
- `callback` <[function]>
|
||||||
|
|
||||||
|
A callback that is run immediately when calling [`method: Test.describe.serial`]. Any tests added in this callback will belong to the group.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## method: Test.describe.serial.only
|
||||||
|
|
||||||
|
Declares a focused group of tests that should always be run serially. If one of the tests fails, all subsequent tests are skipped. All tests in a group are retried together. If there are some focused tests or suites, all of them will be run but nothing else.
|
||||||
|
|
||||||
|
:::note
|
||||||
|
Using serial is not recommended. It is usually better to make your tests isolated, so they can be run independently.
|
||||||
|
:::
|
||||||
|
|
||||||
|
```js js-flavor=js
|
||||||
|
test.describe.serial.only('group', () => {
|
||||||
|
test('runs first', async ({ page }) => {
|
||||||
|
});
|
||||||
|
test('runs second', async ({ page }) => {
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
```js js-flavor=ts
|
||||||
|
test.describe.serial.only('group', () => {
|
||||||
|
test('runs first', async ({ page }) => {
|
||||||
|
});
|
||||||
|
test('runs second', async ({ page }) => {
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### param: Test.describe.serial.only.title
|
||||||
|
- `title` <[string]>
|
||||||
|
|
||||||
|
Group title.
|
||||||
|
|
||||||
|
### param: Test.describe.serial.only.callback
|
||||||
|
- `callback` <[function]>
|
||||||
|
|
||||||
|
A callback that is run immediately when calling [`method: Test.describe.serial.only`]. Any tests added in this callback will belong to the group.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## property: Test.expect
|
## property: Test.expect
|
||||||
- type: <[Object]>
|
- type: <[Object]>
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ import path from 'path';
|
||||||
import { EventEmitter } from 'events';
|
import { EventEmitter } from 'events';
|
||||||
import { RunPayload, TestBeginPayload, TestEndPayload, DonePayload, TestOutputPayload, WorkerInitParams, StepBeginPayload, StepEndPayload } from './ipc';
|
import { RunPayload, TestBeginPayload, TestEndPayload, DonePayload, TestOutputPayload, WorkerInitParams, StepBeginPayload, StepEndPayload } from './ipc';
|
||||||
import type { TestResult, Reporter, TestStep } from '../../types/testReporter';
|
import type { TestResult, Reporter, TestStep } from '../../types/testReporter';
|
||||||
import { TestCase } from './test';
|
import { Suite, TestCase } from './test';
|
||||||
import { Loader } from './loader';
|
import { Loader } from './loader';
|
||||||
|
|
||||||
export type TestGroup = {
|
export type TestGroup = {
|
||||||
|
|
@ -125,7 +125,7 @@ export class Dispatcher {
|
||||||
// When worker encounters error, we will stop it and create a new one.
|
// When worker encounters error, we will stop it and create a new one.
|
||||||
worker.stop();
|
worker.stop();
|
||||||
|
|
||||||
const failedTestIds = new Set<string>();
|
const retryCandidates = new Set<string>();
|
||||||
|
|
||||||
// In case of fatal error, report first remaining test as failing with this error,
|
// In case of fatal error, report first remaining test as failing with this error,
|
||||||
// and all others as skipped.
|
// and all others as skipped.
|
||||||
|
|
@ -141,7 +141,7 @@ export class Dispatcher {
|
||||||
result.error = params.fatalError;
|
result.error = params.fatalError;
|
||||||
result.status = first ? 'failed' : 'skipped';
|
result.status = first ? 'failed' : 'skipped';
|
||||||
this._reportTestEnd(test, result);
|
this._reportTestEnd(test, result);
|
||||||
failedTestIds.add(test._id);
|
retryCandidates.add(test._id);
|
||||||
first = false;
|
first = false;
|
||||||
}
|
}
|
||||||
if (first) {
|
if (first) {
|
||||||
|
|
@ -154,16 +154,50 @@ export class Dispatcher {
|
||||||
// except for possible retries.
|
// except for possible retries.
|
||||||
remaining = [];
|
remaining = [];
|
||||||
}
|
}
|
||||||
if (params.failedTestId)
|
|
||||||
failedTestIds.add(params.failedTestId);
|
if (params.failedTestId) {
|
||||||
|
retryCandidates.add(params.failedTestId);
|
||||||
|
|
||||||
|
let outermostSerialSuite: Suite | undefined;
|
||||||
|
for (let parent = this._testById.get(params.failedTestId)!.test.parent; parent; parent = parent.parent) {
|
||||||
|
if (parent._serial)
|
||||||
|
outermostSerialSuite = parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (outermostSerialSuite) {
|
||||||
|
// Failed test belongs to a serial suite. We should skip all future tests
|
||||||
|
// from the same serial suite.
|
||||||
|
remaining = remaining.filter(test => {
|
||||||
|
let parent = test.parent;
|
||||||
|
while (parent && parent !== outermostSerialSuite)
|
||||||
|
parent = parent.parent;
|
||||||
|
|
||||||
|
// Does not belong to the same serial suite, keep it.
|
||||||
|
if (!parent)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
// Emulate a "skipped" run, and drop this test from remaining.
|
||||||
|
const { result } = this._testById.get(test._id)!;
|
||||||
|
this._reporter.onTestBegin?.(test, result);
|
||||||
|
result.status = 'skipped';
|
||||||
|
this._reportTestEnd(test, result);
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add all tests from the same serial suite for possible retry.
|
||||||
|
// These will only be retried together, because they have the same
|
||||||
|
// "retries" setting and the same number of previous runs.
|
||||||
|
outermostSerialSuite.allTests().forEach(test => retryCandidates.add(test._id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Only retry expected failures, not passes and only if the test failed.
|
// Only retry expected failures, not passes and only if the test failed.
|
||||||
for (const testId of failedTestIds) {
|
for (const testId of retryCandidates) {
|
||||||
const pair = this._testById.get(testId)!;
|
const pair = this._testById.get(testId)!;
|
||||||
if (!this._isStopped && pair.test.expectedStatus === 'passed' && pair.test.results.length < pair.test.retries + 1) {
|
if (!this._isStopped && pair.test.expectedStatus === 'passed' && pair.test.results.length < pair.test.retries + 1) {
|
||||||
pair.result = pair.test._appendTestResult();
|
pair.result = pair.test._appendTestResult();
|
||||||
pair.steps = new Map();
|
pair.steps = new Map();
|
||||||
remaining.unshift(pair.test);
|
remaining.push(pair.test);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -149,7 +149,7 @@ export function formatFailure(config: FullConfig, test: TestCase, index?: number
|
||||||
const resultTokens = formatResultFailure(test, result, ' ');
|
const resultTokens = formatResultFailure(test, result, ' ');
|
||||||
if (!resultTokens.length)
|
if (!resultTokens.length)
|
||||||
continue;
|
continue;
|
||||||
const statusSuffix = result.status === 'passed' ? ' -- passed unexpectedly' : '';
|
const statusSuffix = (result.status === 'passed' && test.expectedStatus === 'failed') ? ' -- passed unexpectedly' : '';
|
||||||
if (result.retry) {
|
if (result.retry) {
|
||||||
tokens.push('');
|
tokens.push('');
|
||||||
tokens.push(colors.gray(pad(` Retry #${result.retry}${statusSuffix}`, '-')));
|
tokens.push(colors.gray(pad(` Retry #${result.retry}${statusSuffix}`, '-')));
|
||||||
|
|
@ -185,7 +185,7 @@ export function formatTestTitle(config: FullConfig, test: TestCase): string {
|
||||||
|
|
||||||
function formatTestHeader(config: FullConfig, test: TestCase, indent: string, index?: number): string {
|
function formatTestHeader(config: FullConfig, test: TestCase, indent: string, index?: number): string {
|
||||||
const title = formatTestTitle(config, test);
|
const title = formatTestTitle(config, test);
|
||||||
const passedUnexpectedlySuffix = test.results[0].status === 'passed' ? ' -- passed unexpectedly' : '';
|
const passedUnexpectedlySuffix = (test.results[0].status === 'passed' && test.expectedStatus === 'failed') ? ' -- passed unexpectedly' : '';
|
||||||
const header = `${indent}${index ? index + ') ' : ''}${title}${passedUnexpectedlySuffix}`;
|
const header = `${indent}${index ? index + ') ' : ''}${title}${passedUnexpectedlySuffix}`;
|
||||||
return colors.red(pad(header, '='));
|
return colors.red(pad(header, '='));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,7 @@ export class Suite extends Base implements reporterTypes.Suite {
|
||||||
_timeout: number | undefined;
|
_timeout: number | undefined;
|
||||||
_annotations: Annotations = [];
|
_annotations: Annotations = [];
|
||||||
_modifiers: Modifier[] = [];
|
_modifiers: Modifier[] = [];
|
||||||
|
_serial = false;
|
||||||
|
|
||||||
_addTest(test: TestCase) {
|
_addTest(test: TestCase) {
|
||||||
test.parent = this;
|
test.parent = this;
|
||||||
|
|
@ -109,6 +110,7 @@ export class Suite extends Base implements reporterTypes.Suite {
|
||||||
suite._annotations = this._annotations.slice();
|
suite._annotations = this._annotations.slice();
|
||||||
suite._modifiers = this._modifiers.slice();
|
suite._modifiers = this._modifiers.slice();
|
||||||
suite._isDescribe = this._isDescribe;
|
suite._isDescribe = this._isDescribe;
|
||||||
|
suite._serial = this._serial;
|
||||||
return suite;
|
return suite;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -143,11 +145,12 @@ export class TestCase extends Base implements reporterTypes.TestCase {
|
||||||
}
|
}
|
||||||
|
|
||||||
outcome(): 'skipped' | 'expected' | 'unexpected' | 'flaky' {
|
outcome(): 'skipped' | 'expected' | 'unexpected' | 'flaky' {
|
||||||
if (!this.results.length || this.results[0].status === 'skipped')
|
const nonSkipped = this.results.filter(result => result.status !== 'skipped');
|
||||||
|
if (!nonSkipped.length)
|
||||||
return 'skipped';
|
return 'skipped';
|
||||||
if (this.results.length === 1 && this.expectedStatus === this.results[0].status)
|
if (nonSkipped.every(result => result.status === this.expectedStatus))
|
||||||
return 'expected';
|
return 'expected';
|
||||||
if (this.results.some(result => result.status === this.expectedStatus))
|
if (nonSkipped.some(result => result.status === this.expectedStatus))
|
||||||
return 'flaky';
|
return 'flaky';
|
||||||
return 'unexpected';
|
return 'unexpected';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,8 @@ export class TestTypeImpl {
|
||||||
test.only = wrapFunctionWithLocation(this._createTest.bind(this, 'only'));
|
test.only = wrapFunctionWithLocation(this._createTest.bind(this, 'only'));
|
||||||
test.describe = wrapFunctionWithLocation(this._describe.bind(this, 'default'));
|
test.describe = wrapFunctionWithLocation(this._describe.bind(this, 'default'));
|
||||||
test.describe.only = wrapFunctionWithLocation(this._describe.bind(this, 'only'));
|
test.describe.only = wrapFunctionWithLocation(this._describe.bind(this, 'only'));
|
||||||
|
test.describe.serial = wrapFunctionWithLocation(this._describe.bind(this, 'serial'));
|
||||||
|
test.describe.serial.only = wrapFunctionWithLocation(this._describe.bind(this, 'serial.only'));
|
||||||
test.beforeEach = wrapFunctionWithLocation(this._hook.bind(this, 'beforeEach'));
|
test.beforeEach = wrapFunctionWithLocation(this._hook.bind(this, 'beforeEach'));
|
||||||
test.afterEach = wrapFunctionWithLocation(this._hook.bind(this, 'afterEach'));
|
test.afterEach = wrapFunctionWithLocation(this._hook.bind(this, 'afterEach'));
|
||||||
test.beforeAll = wrapFunctionWithLocation(this._hook.bind(this, 'beforeAll'));
|
test.beforeAll = wrapFunctionWithLocation(this._hook.bind(this, 'beforeAll'));
|
||||||
|
|
@ -75,7 +77,7 @@ export class TestTypeImpl {
|
||||||
test.expectedStatus = 'skipped';
|
test.expectedStatus = 'skipped';
|
||||||
}
|
}
|
||||||
|
|
||||||
private _describe(type: 'default' | 'only', location: Location, title: string, fn: Function) {
|
private _describe(type: 'default' | 'only' | 'serial' | 'serial.only', location: Location, title: string, fn: Function) {
|
||||||
throwIfRunningInsideJest();
|
throwIfRunningInsideJest();
|
||||||
const suite = currentlyLoadingFileSuite();
|
const suite = currentlyLoadingFileSuite();
|
||||||
if (!suite)
|
if (!suite)
|
||||||
|
|
@ -96,8 +98,10 @@ export class TestTypeImpl {
|
||||||
child.location = location;
|
child.location = location;
|
||||||
suite._addSuite(child);
|
suite._addSuite(child);
|
||||||
|
|
||||||
if (type === 'only')
|
if (type === 'only' || type === 'serial.only')
|
||||||
child._only = true;
|
child._only = true;
|
||||||
|
if (type === 'serial' || type === 'serial.only')
|
||||||
|
child._serial = true;
|
||||||
|
|
||||||
setCurrentlyLoadingFileSuite(child);
|
setCurrentlyLoadingFileSuite(child);
|
||||||
fn();
|
fn();
|
||||||
|
|
|
||||||
131
tests/playwright-test/test-serial.spec.ts
Normal file
131
tests/playwright-test/test-serial.spec.ts
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
/**
|
||||||
|
* Copyright Microsoft Corporation. All rights reserved.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { test, expect } from './playwright-test-fixtures';
|
||||||
|
|
||||||
|
test('test.describe.serial should work', async ({ runInlineTest }) => {
|
||||||
|
const result = await runInlineTest({
|
||||||
|
'a.test.ts': `
|
||||||
|
const { test } = pwt;
|
||||||
|
test.describe.serial('serial suite', () => {
|
||||||
|
test('test1', async ({}) => {
|
||||||
|
console.log('\\n%%test1');
|
||||||
|
});
|
||||||
|
test('test2', async ({}) => {
|
||||||
|
console.log('\\n%%test2');
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('inner suite', () => {
|
||||||
|
test('test3', async ({}) => {
|
||||||
|
console.log('\\n%%test3');
|
||||||
|
expect(1).toBe(2);
|
||||||
|
});
|
||||||
|
test('test4', async ({}) => {
|
||||||
|
console.log('\\n%%test4');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test5', async ({}) => {
|
||||||
|
console.log('\\n%%test5');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
expect(result.exitCode).toBe(1);
|
||||||
|
expect(result.passed).toBe(2);
|
||||||
|
expect(result.failed).toBe(1);
|
||||||
|
expect(result.skipped).toBe(2);
|
||||||
|
expect(result.output.split('\n').filter(line => line.startsWith('%%'))).toEqual([
|
||||||
|
'%%test1',
|
||||||
|
'%%test2',
|
||||||
|
'%%test3',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test.describe.serial should work with retry', async ({ runInlineTest }) => {
|
||||||
|
const result = await runInlineTest({
|
||||||
|
'a.test.ts': `
|
||||||
|
const { test } = pwt;
|
||||||
|
test.describe.serial('serial suite', () => {
|
||||||
|
test('test1', async ({}) => {
|
||||||
|
console.log('\\n%%test1');
|
||||||
|
});
|
||||||
|
test('test2', async ({}) => {
|
||||||
|
console.log('\\n%%test2');
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('inner suite', () => {
|
||||||
|
test('test3', async ({}, testInfo) => {
|
||||||
|
console.log('\\n%%test3');
|
||||||
|
expect(testInfo.retry).toBe(1);
|
||||||
|
});
|
||||||
|
test('test4', async ({}) => {
|
||||||
|
console.log('\\n%%test4');
|
||||||
|
expect(1).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test5', async ({}) => {
|
||||||
|
console.log('\\n%%test5');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
`,
|
||||||
|
}, { retries: 1 });
|
||||||
|
expect(result.exitCode).toBe(1);
|
||||||
|
expect(result.passed).toBe(2);
|
||||||
|
expect(result.flaky).toBe(1);
|
||||||
|
expect(result.failed).toBe(1);
|
||||||
|
expect(result.skipped).toBe(1);
|
||||||
|
expect(result.output.split('\n').filter(line => line.startsWith('%%'))).toEqual([
|
||||||
|
'%%test1',
|
||||||
|
'%%test2',
|
||||||
|
'%%test3',
|
||||||
|
'%%test1',
|
||||||
|
'%%test2',
|
||||||
|
'%%test3',
|
||||||
|
'%%test4',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test.describe.serial.only should work', async ({ runInlineTest }) => {
|
||||||
|
const result = await runInlineTest({
|
||||||
|
'a.test.ts': `
|
||||||
|
const { test } = pwt;
|
||||||
|
test('test1', async ({}) => {
|
||||||
|
console.log('\\n%%test1');
|
||||||
|
});
|
||||||
|
test.describe.serial.only('serial suite', () => {
|
||||||
|
test('test2', async ({}) => {
|
||||||
|
console.log('\\n%%test2');
|
||||||
|
});
|
||||||
|
test('test3', async ({}) => {
|
||||||
|
console.log('\\n%%test3');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
test('test4', async ({}) => {
|
||||||
|
console.log('\\n%%test4');
|
||||||
|
});
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
expect(result.exitCode).toBe(0);
|
||||||
|
expect(result.passed).toBe(2);
|
||||||
|
expect(result.failed).toBe(0);
|
||||||
|
expect(result.skipped).toBe(0);
|
||||||
|
expect(result.output.split('\n').filter(line => line.startsWith('%%'))).toEqual([
|
||||||
|
'%%test2',
|
||||||
|
'%%test3',
|
||||||
|
]);
|
||||||
|
});
|
||||||
3
types/test.d.ts
vendored
3
types/test.d.ts
vendored
|
|
@ -1527,6 +1527,9 @@ export interface TestType<TestArgs extends KeyValue, WorkerArgs extends KeyValue
|
||||||
*/
|
*/
|
||||||
describe: SuiteFunction & {
|
describe: SuiteFunction & {
|
||||||
only: SuiteFunction;
|
only: SuiteFunction;
|
||||||
|
serial: SuiteFunction & {
|
||||||
|
only: SuiteFunction;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
/**
|
/**
|
||||||
* Skips a test or a group of tests.
|
* Skips a test or a group of tests.
|
||||||
|
|
|
||||||
3
utils/generate_types/overrides-test.d.ts
vendored
3
utils/generate_types/overrides-test.d.ts
vendored
|
|
@ -223,6 +223,9 @@ export interface TestType<TestArgs extends KeyValue, WorkerArgs extends KeyValue
|
||||||
only: TestFunction<TestArgs & WorkerArgs>;
|
only: TestFunction<TestArgs & WorkerArgs>;
|
||||||
describe: SuiteFunction & {
|
describe: SuiteFunction & {
|
||||||
only: SuiteFunction;
|
only: SuiteFunction;
|
||||||
|
serial: SuiteFunction & {
|
||||||
|
only: SuiteFunction;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
skip(): void;
|
skip(): void;
|
||||||
skip(condition: boolean): void;
|
skip(condition: boolean): void;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue