Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add expect.poll utility #5708

Merged
merged 20 commits into from
May 14, 2024
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions docs/.vitepress/components.d.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
export {}

/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
Contributors: typeof import('./components/Contributors.vue')['default']
Expand All @@ -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
75 changes: 75 additions & 0 deletions packages/vitest/src/integrations/chai/poll.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
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
const STACK_TRACE_ERROR = new Error('STACK_TRACE_ERROR')
// @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 (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[]) {
return new Promise((resolve, reject) => {
let intervalId: any
let lastError: any
const { setTimeout } = getSafeTimers()
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())
AriPerkkio marked this conversation as resolved.
Show resolved Hide resolved
resolve(await result.call(this, ...args))
}
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
63 changes: 63 additions & 0 deletions test/core/test/expect-poll.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { expect, test, vi } from 'vitest'

// TODO: add custom matcher test

test('simple usage', async () => {
await expect.poll(() => false).toBe(false)
await expect.poll(() => false).not.toBe(true)
// .resolves allowed after .poll
await expect(Promise.resolve(1)).resolves.toBe(1)

await expect(async () => {
await expect.poll(() => Promise.resolve(1)).resolves.toBe(1)
}).rejects.toThrowError('expect.poll() is not supported in combination with .resolves')
await expect(async () => {
await expect.poll(() => Promise.reject(new Error('empty'))).rejects.toThrowError('empty')
}).rejects.toThrowError('expect.poll() is not supported in combination with .rejects')

const unsupported = [
'matchSnapshot',
'toMatchSnapshot',
'toMatchInlineSnapshot',
'throws',
'Throw',
'throw',
'toThrow',
'toThrowError',
'toThrowErrorMatchingSnapshot',
'toThrowErrorMatchingInlineSnapshot',
] as const

for (const key of unsupported) {
await expect(async () => {
await expect.poll(() => Promise.resolve(1))[key as 'matchSnapshot']()
}).rejects.toThrowError(`expect.poll() is not supported in combination with .${key}(). Use vi.waitFor() if your assertion condition is unstable.`)
}
})

test('timeout', async () => {
await expect(async () => {
await expect.poll(() => false, { timeout: 100 }).toBe(true)
}).rejects.toThrowError('Matcher did not succeed in 100ms')
})

test('interval', async () => {
const fn = vi.fn(() => true)
await expect(async () => {
// using big values because CI can be slow
await expect.poll(fn, { interval: 100, timeout: 500 }).toBe(false)
}).rejects.toThrowError()
// CI can be unstable, but there should be always at least 5 calls
expect(fn.mock.calls.length >= 5).toBe(true)
})

test('fake timers don\'t break it', async () => {
const now = Date.now()
vi.useFakeTimers()
await expect(async () => {
await expect.poll(() => false, { timeout: 100 }).toBe(true)
}).rejects.toThrowError('Matcher did not succeed in 100ms')
vi.useRealTimers()
const diff = Date.now() - now
expect(diff >= 100).toBe(true)
})
Loading