diff --git a/docs/src/test-api/class-test.md b/docs/src/test-api/class-test.md index 4992d168c67dc..e40e266dddecc 100644 --- a/docs/src/test-api/class-test.md +++ b/docs/src/test-api/class-test.md @@ -85,6 +85,25 @@ You can also add annotations during runtime by manipulating [`property: TestInfo Learn more about [test annotations](../test-annotations.md). +**Repeat** + +You can repeat an individual test multiple times by providing the `repeat` property in the test details. + +This is useful when investigating flaky tests, validating test stability, or stress-testing specific scenarios without repeating the entire test suite. + +Each repetition is treated as a separate test run and is reported independently. + +```js +import { test, expect } from '@playwright/test'; + +test('repeated test', { + repeat: 3, +}, async ({ page }) => { + await page.goto('https://playwright.dev/'); + // ... +}); +``` + ### param: Test.(call).title * since: v1.10 - `title` <[string]> diff --git a/packages/playwright/src/common/testType.ts b/packages/playwright/src/common/testType.ts index 9911efe09c78d..d615ce65df169 100644 --- a/packages/playwright/src/common/testType.ts +++ b/packages/playwright/src/common/testType.ts @@ -108,18 +108,33 @@ export class TestTypeImpl { } const validatedDetails = validateTestDetails(details, location); - const test = new TestCase(title, body, this, location); - test._requireFile = suite._requireFile; - test.annotations.push(...validatedDetails.annotations); - test._tags.push(...validatedDetails.tags); - suite._addTest(test); - - if (type === 'only' || type === 'fail.only') - test._only = true; - if (type === 'skip' || type === 'fixme' || type === 'fail') - test.annotations.push({ type, location }); - else if (type === 'fail.only') - test.annotations.push({ type: 'fail', location }); + const _this = this; + function createTestInstance(title: string) { + if (!suite) + return; + + const test = new TestCase(title, body, _this, location); + test._requireFile = suite._requireFile; + test.annotations.push(...validatedDetails.annotations); + test._tags.push(...validatedDetails.tags); + suite._addTest(test); + + if (type === 'only' || type === 'fail.only') + test._only = true; + if (type === 'skip' || type === 'fixme' || type === 'fail') + test.annotations.push({ type, location }); + else if (type === 'fail.only') + test.annotations.push({ type: 'fail', location }); + + } + + // If repeat is specified, create multiple test instances with modified titles. + if (validatedDetails.repeat && validatedDetails.repeat > 1) { + for (let i = 0; i < validatedDetails.repeat; i++) + createTestInstance(`${title} (${i + 1}/${validatedDetails.repeat})`); + } else { + createTestInstance(title); + } } private _describe(type: 'default' | 'only' | 'serial' | 'serial.only' | 'parallel' | 'parallel.only' | 'skip' | 'fixme', location: Location, titleOrFn: string | Function, fnOrDetails?: TestDetails | Function, fn?: Function) { diff --git a/packages/playwright/src/common/validators.ts b/packages/playwright/src/common/validators.ts index 0dd13523657b1..beefb9f8c2783 100644 --- a/packages/playwright/src/common/validators.ts +++ b/packages/playwright/src/common/validators.ts @@ -38,6 +38,7 @@ const testDetailsSchema: JsonSchema = { { type: 'array', items: { type: 'string', pattern: '^@', patternError: "Tag must start with '@'" } }, ] }, + repeat: { type: 'number', pattern: '^[1-9][0-9]*$', patternError: 'repeat must be a positive integer' }, annotation: { oneOf: [ testAnnotationSchema, @@ -51,6 +52,7 @@ type ValidTestDetails = { tags: string[]; annotations: (TestDetailsAnnotation & { location: Location })[]; location: Location; + repeat?: number; }; export function validateTestDetails(details: unknown, location: Location): ValidTestDetails { @@ -65,9 +67,12 @@ export function validateTestDetails(details: unknown, location: Location): Valid const annotation = obj.annotation; const annotations: TestDetailsAnnotation[] = annotation === undefined ? [] : Array.isArray(annotation) ? annotation : [annotation as TestDetailsAnnotation]; + const repeat = typeof obj.repeat === 'number' ? obj.repeat : undefined; + return { annotations: annotations.map(a => ({ ...a, location })), tags, location, + repeat, }; } diff --git a/packages/playwright/src/transform/esmLoaderSync.ts b/packages/playwright/src/transform/esmLoaderSync.ts index 18c9c93af8361..378ec8196923b 100644 --- a/packages/playwright/src/transform/esmLoaderSync.ts +++ b/packages/playwright/src/transform/esmLoaderSync.ts @@ -32,7 +32,7 @@ export function resolve(specifier: string, context: { parentURL?: string, condit // - the ESM resolver wants a file:// URL and, on Windows, mistakes an absolute path's // drive letter for a URL scheme ("Received protocol 'c:'"). // The `import` condition is present only for ESM resolution, so use it to pick the form. - specifier = context.conditions?.includes('import') ? url.pathToFileURL(resolved).toString() : resolved; + specifier = context.conditions?.includes?.('import') ? url.pathToFileURL(resolved).toString() : resolved; } } const result = nextResolve(specifier, context); diff --git a/packages/playwright/types/test.d.ts b/packages/playwright/types/test.d.ts index c2037da8f806b..43f713cfadd68 100644 --- a/packages/playwright/types/test.d.ts +++ b/packages/playwright/types/test.d.ts @@ -2784,6 +2784,27 @@ export interface TestType { * [testInfo.annotations](https://playwright.dev/docs/api/class-testinfo#test-info-annotations). * * Learn more about [test annotations](https://playwright.dev/docs/test-annotations). + * + * **Repeat** + * + * You can repeat an individual test multiple times by providing the `repeat` property in the test details. + * + * This is useful when investigating flaky tests, validating test stability, or stress-testing specific scenarios + * without repeating the entire test suite. + * + * Each repetition is treated as a separate test run and is reported independently. + * + * ```js + * import { test, expect } from '@playwright/test'; + * + * test('repeated test', { + * repeat: 3, + * }, async ({ page }) => { + * await page.goto('https://playwright.dev/'); + * // ... + * }); + * ``` + * * @param title Test title. * @param details Additional test details. * @param body Test body that takes one or two arguments: an object with fixtures and optional @@ -2861,6 +2882,27 @@ export interface TestType { * [testInfo.annotations](https://playwright.dev/docs/api/class-testinfo#test-info-annotations). * * Learn more about [test annotations](https://playwright.dev/docs/test-annotations). + * + * **Repeat** + * + * You can repeat an individual test multiple times by providing the `repeat` property in the test details. + * + * This is useful when investigating flaky tests, validating test stability, or stress-testing specific scenarios + * without repeating the entire test suite. + * + * Each repetition is treated as a separate test run and is reported independently. + * + * ```js + * import { test, expect } from '@playwright/test'; + * + * test('repeated test', { + * repeat: 3, + * }, async ({ page }) => { + * await page.goto('https://playwright.dev/'); + * // ... + * }); + * ``` + * * @param title Test title. * @param details Additional test details. * @param body Test body that takes one or two arguments: an object with fixtures and optional diff --git a/tests/playwright-test/test-repeat.spec.ts b/tests/playwright-test/test-repeat.spec.ts new file mode 100644 index 0000000000000..a879c32facc92 --- /dev/null +++ b/tests/playwright-test/test-repeat.spec.ts @@ -0,0 +1,60 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * 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('should create test n times', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'reporter.ts': ` + export default class Reporter { + onBegin(config, suite) { + const visit = suite => { + for (const test of suite.tests || []) + console.log('\\n%%title=' + test.title + ', tags=' + test.tags.join(',')); + for (const child of suite.suites || []) + visit(child); + }; + visit(suite); + } + onError(error) { + console.log(error); + } + } + `, + 'playwright.config.ts': ` + module.exports = { + reporter: './reporter', + tag: '@global', + }; + `, + 'stdio.spec.js': ` + import { test, expect } from '@playwright/test'; + test('no-repeat', () => { + }); + test('repeat-equal-1', { tag: '@foo', repeat: 1 }, () => { + }); + test('repeat-equal-2', { tag: ['@foo', '@bar'], repeat: 2 }, () => { + }); + ` + }); + expect(result.exitCode).toBe(0); + expect(result.outputLines).toEqual([ + `title=no-repeat, tags=@global`, + `title=repeat-equal-1, tags=@global,@foo`, + `title=repeat-equal-2 (1/2), tags=@global,@foo,@bar`, + `title=repeat-equal-2 (2/2), tags=@global,@foo,@bar`, + ]); +});