Skip to content

Commit 7c8f0ba

Browse files
authoredApr 15, 2023
feat: add repeat method to tests (#2652)
1 parent 31f835f commit 7c8f0ba

File tree

8 files changed

+158
-61
lines changed

8 files changed

+158
-61
lines changed
 

‎docs/api/index.md

+12
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,20 @@ type Awaitable<T> = T | PromiseLike<T>
1111
type TestFunction = () => Awaitable<void>
1212

1313
interface TestOptions {
14+
/**
15+
* Will fail the test if it takes too long to execute
16+
*/
1417
timeout?: number
18+
/**
19+
* Will retry the test specific number of times if it fails
20+
*/
1521
retry?: number
22+
/**
23+
* Will repeat the same test several times even if it fails each time
24+
* If you have "retry" option and it fails, it will use every retry in each cycle
25+
* Useful for debugging random failings
26+
*/
27+
repeats?: number
1628
}
1729
```
1830

‎packages/runner/src/run.ts

+67-59
Original file line numberDiff line numberDiff line change
@@ -125,55 +125,63 @@ export async function runTest(test: Test, runner: VitestRunner) {
125125

126126
setCurrentTest(test)
127127

128-
const retry = test.retry || 1
129-
for (let retryCount = 0; retryCount < retry; retryCount++) {
130-
let beforeEachCleanups: HookCleanupCallback[] = []
131-
try {
132-
await runner.onBeforeTryTest?.(test, retryCount)
128+
const repeats = typeof test.repeats === 'number' ? test.repeats : 1
133129

134-
beforeEachCleanups = await callSuiteHook(test.suite, test, 'beforeEach', runner, [test.context, test.suite])
130+
for (let repeatCount = 0; repeatCount < repeats; repeatCount++) {
131+
const retry = test.retry || 1
135132

136-
test.result.retryCount = retryCount
133+
for (let retryCount = 0; retryCount < retry; retryCount++) {
134+
let beforeEachCleanups: HookCleanupCallback[] = []
135+
try {
136+
await runner.onBeforeTryTest?.(test, { retry: retryCount, repeats: repeatCount })
137137

138-
if (runner.runTest) {
139-
await runner.runTest(test)
140-
}
141-
else {
142-
const fn = getFn(test)
143-
if (!fn)
144-
throw new Error('Test function is not found. Did you add it using `setFn`?')
145-
await fn()
146-
}
138+
test.result.retryCount = retryCount
139+
test.result.repeatCount = repeatCount
147140

148-
// some async expect will be added to this array, in case user forget to await theme
149-
if (test.promises) {
150-
const result = await Promise.allSettled(test.promises)
151-
const errors = result.map(r => r.status === 'rejected' ? r.reason : undefined).filter(Boolean)
152-
if (errors.length)
153-
throw errors
154-
}
141+
beforeEachCleanups = await callSuiteHook(test.suite, test, 'beforeEach', runner, [test.context, test.suite])
155142

156-
await runner.onAfterTryTest?.(test, retryCount)
143+
if (runner.runTest) {
144+
await runner.runTest(test)
145+
}
146+
else {
147+
const fn = getFn(test)
148+
if (!fn)
149+
throw new Error('Test function is not found. Did you add it using `setFn`?')
150+
await fn()
151+
}
157152

158-
test.result.state = 'pass'
159-
}
160-
catch (e) {
161-
failTask(test.result, e)
162-
}
153+
// some async expect will be added to this array, in case user forget to await theme
154+
if (test.promises) {
155+
const result = await Promise.allSettled(test.promises)
156+
const errors = result.map(r => r.status === 'rejected' ? r.reason : undefined).filter(Boolean)
157+
if (errors.length)
158+
throw errors
159+
}
163160

164-
try {
165-
await callSuiteHook(test.suite, test, 'afterEach', runner, [test.context, test.suite])
166-
await callCleanupHooks(beforeEachCleanups)
167-
}
168-
catch (e) {
169-
failTask(test.result, e)
170-
}
161+
await runner.onAfterTryTest?.(test, { retry: retryCount, repeats: repeatCount })
171162

172-
if (test.result.state === 'pass')
173-
break
163+
if (!test.repeats)
164+
test.result.state = 'pass'
165+
else if (test.repeats && retry === retryCount)
166+
test.result.state = 'pass'
167+
}
168+
catch (e) {
169+
failTask(test.result, e)
170+
}
174171

175-
// update retry info
176-
updateTask(test, runner)
172+
try {
173+
await callSuiteHook(test.suite, test, 'afterEach', runner, [test.context, test.suite])
174+
await callCleanupHooks(beforeEachCleanups)
175+
}
176+
catch (e) {
177+
failTask(test.result, e)
178+
}
179+
180+
if (test.result.state === 'pass')
181+
break
182+
// update retry info
183+
updateTask(test, runner)
184+
}
177185
}
178186

179187
if (test.result.state === 'fail')
@@ -291,30 +299,30 @@ export async function runSuite(suite: Suite, runner: VitestRunner) {
291299
catch (e) {
292300
failTask(suite.result, e)
293301
}
294-
}
295302

296-
suite.result.duration = now() - start
297-
298-
if (suite.mode === 'run') {
299-
if (!hasTests(suite)) {
300-
suite.result.state = 'fail'
301-
if (!suite.result.error) {
302-
const error = processError(new Error(`No test found in suite ${suite.name}`))
303-
suite.result.error = error
304-
suite.result.errors = [error]
303+
if (suite.mode === 'run') {
304+
if (!hasTests(suite)) {
305+
suite.result.state = 'fail'
306+
if (!suite.result.error) {
307+
const error = processError(new Error(`No test found in suite ${suite.name}`))
308+
suite.result.error = error
309+
suite.result.errors = [error]
310+
}
311+
}
312+
else if (hasFailed(suite)) {
313+
suite.result.state = 'fail'
314+
}
315+
else {
316+
suite.result.state = 'pass'
305317
}
306318
}
307-
else if (hasFailed(suite)) {
308-
suite.result.state = 'fail'
309-
}
310-
else {
311-
suite.result.state = 'pass'
312-
}
313-
}
314319

315-
await runner.onAfterRunSuite?.(suite)
320+
updateTask(suite, runner)
316321

317-
updateTask(suite, runner)
322+
suite.result.duration = now() - start
323+
324+
await runner.onAfterRunSuite?.(suite)
325+
}
318326
}
319327

320328
async function runSuiteChild(c: Task, runner: VitestRunner) {

‎packages/runner/src/suite.ts

+13
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,14 @@ function createSuiteCollector(name: string, factory: SuiteFactory = () => { }, m
6565
if (typeof options === 'number')
6666
options = { timeout: options }
6767

68+
// inherit repeats and retry from suite
69+
if (typeof suiteOptions === 'object') {
70+
options = {
71+
...suiteOptions,
72+
...options,
73+
}
74+
}
75+
6876
const test: Test = {
6977
id: '',
7078
type: 'test',
@@ -73,6 +81,7 @@ function createSuiteCollector(name: string, factory: SuiteFactory = () => { }, m
7381
suite: undefined!,
7482
fails: this.fails,
7583
retry: options?.retry,
84+
repeats: options?.repeats,
7685
} as Omit<Test, 'context'> as Test
7786

7887
if (this.concurrent || concurrent)
@@ -124,6 +133,9 @@ function createSuiteCollector(name: string, factory: SuiteFactory = () => { }, m
124133
}
125134

126135
function initSuite() {
136+
if (typeof suiteOptions === 'number')
137+
suiteOptions = { timeout: suiteOptions }
138+
127139
suite = {
128140
id: '',
129141
type: 'suite',
@@ -132,6 +144,7 @@ function createSuiteCollector(name: string, factory: SuiteFactory = () => { }, m
132144
shuffle,
133145
tasks: [],
134146
}
147+
135148
setHooks(suite, createSuiteHooks())
136149
}
137150

‎packages/runner/src/types/runner.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -44,15 +44,15 @@ export interface VitestRunner {
4444
/**
4545
* Called before actually running the test function. Already has "result" with "state" and "startTime".
4646
*/
47-
onBeforeTryTest?(test: Test, retryCount: number): unknown
47+
onBeforeTryTest?(test: Test, options: { retry: number; repeats: number }): unknown
4848
/**
4949
* Called after result and state are set.
5050
*/
5151
onAfterRunTest?(test: Test): unknown
5252
/**
5353
* Called right after running the test function. Doesn't have new state yet. Will not be called, if the test function throws.
5454
*/
55-
onAfterTryTest?(test: Test, retryCount: number): unknown
55+
onAfterTryTest?(test: Test, options: { retry: number; repeats: number }): unknown
5656

5757
/**
5858
* Called before running a single suite. Doesn't have "result" yet.

‎packages/runner/src/types/tasks.ts

+8
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export interface TaskBase {
1616
result?: TaskResult
1717
retry?: number
1818
meta?: any
19+
repeats?: number
1920
}
2021

2122
export interface TaskCustom extends TaskBase {
@@ -35,6 +36,7 @@ export interface TaskResult {
3536
htmlError?: string
3637
hooks?: Partial<Record<keyof SuiteHooks, TaskState>>
3738
retryCount?: number
39+
repeatCount?: number
3840
}
3941

4042
export type TaskResultPack = [id: string, result: TaskResult | undefined]
@@ -165,6 +167,12 @@ export interface TestOptions {
165167
* @default 1
166168
*/
167169
retry?: number
170+
/**
171+
* How many times the test will repeat.
172+
*
173+
* @default 5
174+
*/
175+
repeats?: number
168176
}
169177

170178
export type TestAPI<ExtraContext = {}> = ChainableTestAPI<ExtraContext> & {

‎packages/vitest/src/node/reporters/renderers/listRenderer.ts

+3
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,9 @@ export function renderTree(tasks: Task[], options: ListRendererOptions, level =
111111
if (task.mode === 'skip' || task.mode === 'todo')
112112
suffix += ` ${c.dim(c.gray('[skipped]'))}`
113113

114+
if (task.type === 'test' && task.result?.repeatCount && task.result.repeatCount > 1)
115+
suffix += c.yellow(` (repeat x${task.result.repeatCount})`)
116+
114117
if (task.result?.duration != null) {
115118
if (task.result.duration > DURATION_LONG)
116119
suffix += c.yellow(` ${Math.round(task.result.duration)}${c.dim('ms')}`)

‎test/core/test/repeats.test.ts

+50
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { afterAll, describe, expect, test } from 'vitest'
2+
3+
const testNumbers: number[] = []
4+
5+
describe('testing it/test', () => {
6+
const result = [1, 1, 1, 1, 1, 2, 2, 2]
7+
8+
test('test 1', () => {
9+
testNumbers.push(1)
10+
}, { repeats: 5 })
11+
12+
test('test 2', () => {
13+
testNumbers.push(2)
14+
}, { repeats: 3 })
15+
16+
test.fails('test 3', () => {
17+
testNumbers.push(3)
18+
expect(testNumbers).toStrictEqual(result)
19+
}, { repeats: 1 })
20+
21+
afterAll(() => {
22+
result.push(3)
23+
expect(testNumbers).toStrictEqual(result)
24+
})
25+
})
26+
27+
const describeNumbers: number[] = []
28+
29+
describe('testing describe', () => {
30+
test('test 1', () => {
31+
describeNumbers.push(1)
32+
})
33+
}, { repeats: 3 })
34+
35+
afterAll(() => {
36+
expect(describeNumbers).toStrictEqual([1, 1, 1])
37+
})
38+
39+
const retryNumbers: number[] = []
40+
41+
describe('testing repeats with retry', () => {
42+
const result = [1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
43+
test('test 1', () => {
44+
retryNumbers.push(1)
45+
}, { repeats: 5, retry: 2 })
46+
47+
afterAll(() => {
48+
expect(retryNumbers).toStrictEqual(result)
49+
})
50+
})

‎test/reporters/tests/__snapshots__/html.test.ts.snap

+3
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ exports[`html reporter > resolves to "failing" status for test file "json-fail"
8484
"afterEach": "pass",
8585
"beforeEach": "pass",
8686
},
87+
"repeatCount": 0,
8788
"retryCount": 0,
8889
"startTime": 0,
8990
"state": "fail",
@@ -155,6 +156,7 @@ exports[`html reporter > resolves to "passing" status for test file "all-passing
155156
"afterEach": "pass",
156157
"beforeEach": "pass",
157158
},
159+
"repeatCount": 0,
158160
"retryCount": 0,
159161
"startTime": 0,
160162
"state": "pass",
@@ -201,6 +203,7 @@ exports[`html reporter > resolves to "passing" status for test file "all-passing
201203
"afterEach": "pass",
202204
"beforeEach": "pass",
203205
},
206+
"repeatCount": 0,
204207
"retryCount": 0,
205208
"startTime": 0,
206209
"state": "pass",

0 commit comments

Comments
 (0)
Please sign in to comment.