Skip to content

Commit

Permalink
feat: add expect.poll utility (#5708)
Browse files Browse the repository at this point in the history
  • Loading branch information
sheremet-va authored May 14, 2024
1 parent 3ccbcc4 commit e2e0ff4
Show file tree
Hide file tree
Showing 12 changed files with 252 additions and 28 deletions.
2 changes: 0 additions & 2 deletions docs/.vitepress/components.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,6 @@ declare module 'vue' {
HomePage: typeof import('./components/HomePage.vue')['default']
ListItem: typeof import('./components/ListItem.vue')['default']
NonProjectOption: typeof import('./components/NonProjectOption.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
Version: typeof import('./components/Version.vue')['default']
}
}
41 changes: 41 additions & 0 deletions docs/api/expect.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,47 @@ test('expect.soft test', () => {
`expect.soft` can only be used inside the [`test`](/api/#test) function.
:::

## poll

- **Type:** `ExpectStatic & (actual: () => any, options: { interval, timeout, message }) => Assertions`

`expect.poll` reruns the _assertion_ until it is succeeded. You can configure how many times Vitest should rerun the `expect.poll` callback by setting `interval` and `timeout` options.

If an error is thrown inside the `expect.poll` callback, Vitest will retry again until the timeout runs out.

```ts twoslash
function asyncInjectElement() {
// example function
}

// ---cut---
import { expect, test } from 'vitest'

test('element exists', async () => {
asyncInjectElement()

await expect.poll(() => document.querySelector('.element')).toBeTruthy()
})
```

::: warning
`expect.poll` makes every assertion asynchronous, so do not forget to await it otherwise you might get unhandled promise rejections.

`expect.poll` doesn't work with several matchers:

- Snapshot matchers are not supported because they will always succeed. If your condition is flaky, consider using [`vi.waitFor`](/api/vi#vi-waitfor) instead to resolve it first:

```ts
import { expect, vi } from 'vitest'

const flakyValue = await vi.waitFor(() => getFlakyValue())
expect(flakyValue).toMatchSnapshot()
```

- `.resolves` and `.rejects` are not supported. `expect.poll` already awaits the condition if it's asynchronous.
- `toThrow` and its aliases are not supported because the `expect.poll` condition is always resolved before the matcher gets the value
:::

## not

Using `not` will negate the assertion. For example, this code asserts that an `input` value is not equal to `2`. If it's equal, the assertion will throw an error, and the test will fail.
Expand Down
13 changes: 13 additions & 0 deletions packages/expect/src/jest-expect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -722,13 +722,23 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => {
return this.be.satisfy(matcher, message)
})

// @ts-expect-error @internal
def('withContext', function (this: any, context: Record<string, any>) {
for (const key in context)
utils.flag(this, key, context[key])
return this
})

utils.addProperty(chai.Assertion.prototype, 'resolves', function __VITEST_RESOLVES__(this: any) {
const error = new Error('resolves')
utils.flag(this, 'promise', 'resolves')
utils.flag(this, 'error', error)
const test: Test = utils.flag(this, 'vitest-test')
const obj = utils.flag(this, 'object')

if (utils.flag(this, 'poll'))
throw new SyntaxError(`expect.poll() is not supported in combination with .resolves`)

if (typeof obj?.then !== 'function')
throw new TypeError(`You must provide a Promise to expect() when using .resolves, not '${typeof obj}'.`)

Expand Down Expand Up @@ -772,6 +782,9 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => {
const obj = utils.flag(this, 'object')
const wrapper = typeof obj === 'function' ? obj() : obj // for jest compat

if (utils.flag(this, 'poll'))
throw new SyntaxError(`expect.poll() is not supported in combination with .rejects`)

if (typeof wrapper?.then !== 'function')
throw new TypeError(`You must provide a Promise to expect() when using .rejects, not '${typeof wrapper}'.`)

Expand Down
2 changes: 2 additions & 0 deletions packages/expect/src/jest-extend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ function getMatcherState(assertion: Chai.AssertionStatic & Chai.Assertion, expec
equals,
// needed for built-in jest-snapshots, but we don't use it
suppressedErrors: [],
soft: util.flag(assertion, 'soft') as boolean | undefined,
poll: util.flag(assertion, 'poll') as boolean | undefined,
}

return {
Expand Down
12 changes: 5 additions & 7 deletions packages/expect/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ export interface MatcherState {
subsetEquality: Tester
}
soft?: boolean
poll?: boolean
}

export interface SyncExpectationResult {
Expand All @@ -91,12 +92,7 @@ export type MatchersObject<T extends MatcherState = MatcherState> = Record<strin

export interface ExpectStatic extends Chai.ExpectStatic, AsymmetricMatchersContaining {
<T>(actual: T, message?: string): Assertion<T>
unreachable: (message?: string) => never
soft: <T>(actual: T, message?: string) => Assertion<T>
extend: (expects: MatchersObject) => void
addEqualityTesters: (testers: Array<Tester>) => void
assertions: (expected: number) => void
hasAssertions: () => void
anything: () => any
any: (constructor: unknown) => any
getState: () => MatcherState
Expand Down Expand Up @@ -175,13 +171,15 @@ type Promisify<O> = {
: O[K]
}

export type PromisifyAssertion<T> = Promisify<Assertion<T>>

export interface Assertion<T = any> extends VitestAssertion<Chai.Assertion, T>, JestAssertion<T> {
toBeTypeOf: (expected: 'bigint' | 'boolean' | 'function' | 'number' | 'object' | 'string' | 'symbol' | 'undefined') => void
toHaveBeenCalledOnce: () => void
toSatisfy: <E>(matcher: (value: E) => boolean, message?: string) => void

resolves: Promisify<Assertion<T>>
rejects: Promisify<Assertion<T>>
resolves: PromisifyAssertion<T>
rejects: PromisifyAssertion<T>
}

declare global {
Expand Down
15 changes: 4 additions & 11 deletions packages/expect/src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import { processError } from '@vitest/utils/error'
import type { Test } from '@vitest/runner/types'
import { GLOBAL_EXPECT } from './constants'
import { getState } from './state'
import type { Assertion, MatcherState } from './types'
import type { Assertion } from './types'

export function recordAsyncExpect(test: any, promise: Promise<any> | PromiseLike<any>) {
// record promise for test, that resolves before test ends
Expand All @@ -25,16 +23,11 @@ export function recordAsyncExpect(test: any, promise: Promise<any> | PromiseLike

export function wrapSoft(utils: Chai.ChaiUtils, fn: (this: Chai.AssertionStatic & Assertion, ...args: any[]) => void) {
return function (this: Chai.AssertionStatic & Assertion, ...args: any[]) {
const test: Test = utils.flag(this, 'vitest-test')

// @ts-expect-error local is untyped
const state: MatcherState = test?.context._local
? test.context.expect.getState()
: getState((globalThis as any)[GLOBAL_EXPECT])

if (!state.soft)
if (!utils.flag(this, 'soft'))
return fn.apply(this, args)

const test: Test = utils.flag(this, 'vitest-test')

if (!test)
throw new Error('expect.soft() can only be used inside a test')

Expand Down
12 changes: 6 additions & 6 deletions packages/vitest/src/integrations/chai/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,12 @@ import type { Assertion, ExpectStatic } from '@vitest/expect'
import type { MatcherState } from '../../types/chai'
import { getTestName } from '../../utils/tasks'
import { getCurrentEnvironment, getWorkerState } from '../../utils/global'
import { createExpectPoll } from './poll'

export function createExpect(test?: TaskPopulated) {
const expect = ((value: any, message?: string): Assertion => {
const { assertionCalls } = getState(expect)
setState({ assertionCalls: assertionCalls + 1, soft: false }, expect)
setState({ assertionCalls: assertionCalls + 1 }, expect)
const assert = chai.expect(value, message) as unknown as Assertion
const _test = test || getCurrentTest()
if (_test)
Expand Down Expand Up @@ -51,13 +52,12 @@ export function createExpect(test?: TaskPopulated) {
addCustomEqualityTesters(customTesters)

expect.soft = (...args) => {
const assert = expect(...args)
expect.setState({
soft: true,
})
return assert
// @ts-expect-error private soft access
return expect(...args).withContext({ soft: true }) as Assertion
}

expect.poll = createExpectPoll(expect)

expect.unreachable = (message?: string) => {
chai.assert.fail(`expected${message ? ` "${message}" ` : ' '}not to be reached`)
}
Expand Down
80 changes: 80 additions & 0 deletions packages/vitest/src/integrations/chai/poll.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import * as chai from 'chai'
import type { ExpectStatic } from '@vitest/expect'
import { getSafeTimers } from '@vitest/utils'

// these matchers are not supported because they don't make sense with poll
const unsupported = [
// .poll is meant to retry matchers until they succeed, and
// snapshots will always succeed as long as the poll method doesn't thow an error
// in this case using the `vi.waitFor` method is more appropriate
'matchSnapshot',
'toMatchSnapshot',
'toMatchInlineSnapshot',
'toThrowErrorMatchingSnapshot',
'toThrowErrorMatchingInlineSnapshot',
// toThrow will never succeed because we call the poll callback until it doesn't throw
'throws',
'Throw',
'throw',
'toThrow',
'toThrowError',
// these are not supported because you can call them without `.poll`,
// we throw an error inside the rejects/resolves methods to prevent this
// rejects,
// resolves
]

export function createExpectPoll(expect: ExpectStatic): ExpectStatic['poll'] {
return function poll(fn, options = {}) {
const { interval = 50, timeout = 1000, message } = options
// @ts-expect-error private poll access
const assertion = expect(null, message).withContext({ poll: true }) as Assertion
const proxy: any = new Proxy(assertion, {
get(target, key, receiver) {
const result = Reflect.get(target, key, receiver)

if (typeof result !== 'function')
return result instanceof chai.Assertion ? proxy : result

if (key === 'assert')
return result

if (typeof key === 'string' && unsupported.includes(key))
throw new SyntaxError(`expect.poll() is not supported in combination with .${key}(). Use vi.waitFor() if your assertion condition is unstable.`)

return function (this: any, ...args: any[]) {
const STACK_TRACE_ERROR = new Error('STACK_TRACE_ERROR')
return new Promise((resolve, reject) => {
let intervalId: any
let lastError: any
const { setTimeout, clearTimeout } = getSafeTimers()
const timeoutId = setTimeout(() => {
clearTimeout(intervalId)
reject(copyStackTrace(new Error(`Matcher did not succeed in ${timeout}ms`, { cause: lastError }), STACK_TRACE_ERROR))
}, timeout)
const check = async () => {
try {
chai.util.flag(this, 'object', await fn())
resolve(await result.call(this, ...args))
clearTimeout(intervalId)
clearTimeout(timeoutId)
}
catch (err) {
lastError = err
intervalId = setTimeout(check, interval)
}
}
check()
})
}
},
})
return proxy
}
}

function copyStackTrace(target: Error, source: Error) {
if (source.stack !== undefined)
target.stack = source.stack.replace(source.message, target.message)
return target
}
2 changes: 2 additions & 0 deletions packages/vitest/src/node/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,8 @@ const skipErrorProperties = new Set([
'stackStr',
'type',
'showDiff',
'ok',
'operator',
'diff',
'codeFrame',
'actual',
Expand Down
14 changes: 13 additions & 1 deletion packages/vitest/src/types/global.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { Plugin as PrettyFormatPlugin } from 'pretty-format'
import type { SnapshotState } from '@vitest/snapshot'
import type { ExpectStatic } from '@vitest/expect'
import type { ExpectStatic, PromisifyAssertion, Tester } from '@vitest/expect'
import type { UserConsoleLog } from './general'
import type { VitestEnvironment } from './config'
import type { BenchmarkResult } from './benchmark'
Expand Down Expand Up @@ -33,7 +33,19 @@ declare module '@vitest/expect' {
snapshotState: SnapshotState
}

interface ExpectPollOptions {
interval?: number
timeout?: number
message?: string
}

interface ExpectStatic {
unreachable: (message?: string) => never
soft: <T>(actual: T, message?: string) => Assertion<T>
poll: <T>(actual: () => T, options?: ExpectPollOptions) => PromisifyAssertion<Awaited<T>>
addEqualityTesters: (testers: Array<Tester>) => void
assertions: (expected: number) => void
hasAssertions: () => void
addSnapshotSerializer: (plugin: PrettyFormatPlugin) => void
}

Expand Down
Loading

0 comments on commit e2e0ff4

Please sign in to comment.