diff --git a/packages/vitest/src/index.ts b/packages/vitest/src/index.ts index d2e58bc5c068..c267d1e9accc 100644 --- a/packages/vitest/src/index.ts +++ b/packages/vitest/src/index.ts @@ -24,73 +24,87 @@ declare module 'vite' { } } +interface AsymmetricMatchersContaining { + stringContaining(expected: string): void + objectContaining(expected: any): ObjectContaining + arrayContaining(expected: unknown[]): ArrayContaining + stringMatching(expected: string | RegExp): StringMatching +} + declare global { namespace Chai { - interface ExpectStatic { + interface ExpectStatic extends AsymmetricMatchersContaining { extend(expects: MatchersObject): void - stringContaining(expected: string): void + assertions(expected: number): void + hasAssertions(): void anything(): Anything - objectContaining(expected: any): ObjectContaining any(constructor: unknown): Any - arrayContaining(expected: any): ArrayContaining - stringMatching(expected: RegExp): StringMatching - assertions(expected: number): void + not: AsymmetricMatchersContaining } - interface Assertion { - // Chai - chaiEqual(expected: any): void - + interface JestAssertions { // Snapshot - toMatchSnapshot(message?: string): Assertion - toMatchInlineSnapshot(snapshot?: string, message?: string): Assertion - matchSnapshot(message?: string): Assertion + toMatchSnapshot(message?: string): T + toMatchInlineSnapshot(snapshot?: string, message?: string): T + matchSnapshot(message?: string): T // Jest compact - toEqual(expected: any): void - toStrictEqual(expected: any): void - toBe(expected: any): void - toMatch(expected: string | RegExp): void - toMatchObject(expected: any): void - toContain(item: any): void - toContainEqual(item: any): void - toBeTruthy(): void - toBeFalsy(): void - toBeGreaterThan(num: number): void - toBeGreaterThanOrEqual(num: number): void - toBeLessThan(num: number): void - toBeLessThanOrEqual(num: number): void - toBeNaN(): void - toBeUndefined(): void - toBeNull(): void - toBeDefined(): void - toBeInstanceOf(c: any): void - toBeCalledTimes(n: number): void - toHaveLength(l: number): void - toHaveProperty(p: string, value?: any): void - toBeCloseTo(number: number, numDigits?: number): void - toHaveBeenCalledTimes(n: number): void - toHaveBeenCalledOnce(): void - toHaveBeenCalled(): void - toBeCalled(): void - toHaveBeenCalledWith(...args: any[]): void - toBeCalledWith(...args: any[]): void - toHaveBeenNthCalledWith(n: number, ...args: any[]): void - nthCalledWith(n: number, ...args: any[]): void - toHaveBeenLastCalledWith(...args: any[]): void - lastCalledWith(...args: any[]): void - toThrow(expected?: string | RegExp): void - toThrowError(expected?: string | RegExp): void - toReturn(): void - toHaveReturned(): void - toReturnTimes(times: number): void - toHaveReturnedTimes(times: number): void - toReturnWith(value: any): void - toHaveReturnedWith(value: any): void - toHaveLastReturnedWith(value: any): void - lastReturnedWith(value: any): void - toHaveNthReturnedWith(nthCall: number, value: any): void - nthReturnedWith(nthCall: number, value: any): void + toEqual(expected: any): T + toStrictEqual(expected: any): T + toBe(expected: any): T + toMatch(expected: string | RegExp): T + toMatchObject(expected: any): T + toContain(item: any): T + toContainEqual(item: any): T + toBeTruthy(): T + toBeFalsy(): T + toBeGreaterThan(num: number): T + toBeGreaterThanOrEqual(num: number): T + toBeLessThan(num: number): T + toBeLessThanOrEqual(num: number): T + toBeNaN(): T + toBeUndefined(): T + toBeNull(): T + toBeDefined(): T + toBeInstanceOf(c: any): T + toBeCalledTimes(n: number): T + toHaveLength(l: number): T + toHaveProperty(p: string, value?: any): T + toBeCloseTo(number: number, numDigits?: number): T + toHaveBeenCalledTimes(n: number): T + toHaveBeenCalledOnce(): T + toHaveBeenCalled(): T + toBeCalled(): T + toHaveBeenCalledWith(...args: any[]): T + toBeCalledWith(...args: any[]): T + toHaveBeenNthCalledWith(n: number, ...args: any[]): T + nthCalledWith(n: number, ...args: any[]): T + toHaveBeenLastCalledWith(...args: any[]): T + lastCalledWith(...args: any[]): T + toThrow(expected?: string | RegExp): T + toThrowError(expected?: string | RegExp): T + toReturn(): T + toHaveReturned(): T + toReturnTimes(times: number): T + toHaveReturnedTimes(times: number): T + toReturnWith(value: any): T + toHaveReturnedWith(value: any): T + toHaveLastReturnedWith(value: any): T + lastReturnedWith(value: any): T + toHaveNthReturnedWith(nthCall: number, value: any): T + nthReturnedWith(nthCall: number, value: any): T + } + + type Promisify = { + [K in keyof O]: O[K] extends (...args: infer A) => infer R ? O extends R ? Promisify : (...args: A) => Promise : O[K] + } + + interface Assertion extends JestAssertions { + resolves: Promisify + rejects: Promisify + + // Chai + chaiEqual(expected: any): void } } } diff --git a/packages/vitest/src/integrations/chai/jest-asymmetric-matchers.ts b/packages/vitest/src/integrations/chai/jest-asymmetric-matchers.ts index 83918f9f5e13..8e445315aae6 100644 --- a/packages/vitest/src/integrations/chai/jest-asymmetric-matchers.ts +++ b/packages/vitest/src/integrations/chai/jest-asymmetric-matchers.ts @@ -260,47 +260,44 @@ export class StringMatching extends AsymmetricMatcher { export const JestAsymmetricMatchers: ChaiPlugin = (chai, utils) => { utils.addMethod( chai.expect, - 'stringContaining', - (expected: string) => new StringContaining(expected), + 'anything', + () => new Anything(), ) utils.addMethod( chai.expect, - 'anything', - () => { - return new Anything() - }, + 'any', + (expected: unknown) => new Any(expected), ) utils.addMethod( chai.expect, - 'objectContaining', - (expected: any) => { - return new ObjectContaining(expected) - }, + 'stringContaining', + (expected: string) => new StringContaining(expected), ) utils.addMethod( chai.expect, - 'any', - (expected: unknown) => { - return new Any(expected) - }, + 'objectContaining', + (expected: any) => new ObjectContaining(expected), ) utils.addMethod( chai.expect, 'arrayContaining', - (expected: any) => { - return new ArrayContaining(expected) - }, + (expected: any) => new ArrayContaining(expected), ) utils.addMethod( chai.expect, 'stringMatching', - (expected: any) => { - return new StringMatching(expected) - }, + (expected: any) => new StringMatching(expected), ) + + chai.expect.not = { + stringContaining: (expected: string) => new StringContaining(expected, true), + objectContaining: (expected: any) => new ObjectContaining(expected, true), + arrayContaining: (expected: unknown[]) => new ArrayContaining(expected, true), + stringMatching: (expected: string | RegExp) => new StringMatching(expected, true), + } } diff --git a/packages/vitest/src/integrations/chai/jest-expect.ts b/packages/vitest/src/integrations/chai/jest-expect.ts index b2d59cc87a09..8016085ade04 100644 --- a/packages/vitest/src/integrations/chai/jest-expect.ts +++ b/packages/vitest/src/integrations/chai/jest-expect.ts @@ -4,6 +4,8 @@ import { arrayBufferEquality, equals as asymmetricEquals, hasAsymmetric, iterabl type MatcherState = { assertionCalls: number + isExpectingAssertions: boolean + isExpectingAssertionsError: Error | null expectedAssertionsNumber: number | null expectedAssertionsNumberError: Error | null } @@ -12,6 +14,8 @@ const MATCHERS_OBJECT = Symbol.for('matchers-object') if (!Object.prototype.hasOwnProperty.call(global, MATCHERS_OBJECT)) { const defaultState: Partial = { assertionCalls: 0, + isExpectingAssertions: false, + isExpectingAssertionsError: null, expectedAssertionsNumber: null, expectedAssertionsNumberError: null, } @@ -373,6 +377,61 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => { ) }) + utils.addProperty(chai.Assertion.prototype, 'resolves', function(this: any) { + utils.flag(this, 'promise', 'resolves') + const obj = utils.flag(this, 'object') + const proxy: any = new Proxy(this, { + get: (target, key, reciever) => { + const result = Reflect.get(target, key, reciever) + + if (typeof result !== 'function') + return result instanceof chai.Assertion ? proxy : result + + return async(...args: any[]) => { + return obj.then( + (value: any) => { + utils.flag(this, 'object', value) + return result.call(this, ...args) + }, + (err: any) => { + throw new Error(`promise rejected ${err} instead of resolving`) + }, + ) + } + }, + }) + + return proxy + }) + + utils.addProperty(chai.Assertion.prototype, 'rejects', function(this: any) { + utils.flag(this, 'promise', 'rejects') + const obj = utils.flag(this, 'object') + const wrapper = typeof obj === 'function' ? obj() : obj + const proxy: any = new Proxy(this, { + get: (target, key, reciever) => { + const result = Reflect.get(target, key, reciever) + + if (typeof result !== 'function') + return result instanceof chai.Assertion ? proxy : result + + return async(...args: any[]) => { + return wrapper.then( + (value: any) => { + throw new Error(`promise resolved ${value} instead of rejecting`) + }, + (err: any) => { + utils.flag(this, 'object', err) + return result.call(this, ...args) + }, + ) + } + }, + }) + + return proxy + }) + utils.addMethod( chai.expect, 'assertions', @@ -387,4 +446,19 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => { }) }, ) + + utils.addMethod( + chai.expect, + 'hasAssertions', + function hasAssertions() { + const error = new Error('expected any number of assertion, but got none') + if (Error.captureStackTrace) + Error.captureStackTrace(error, hasAssertions) + + setState({ + isExpectingAssertions: true, + isExpectingAssertionsError: error, + }) + }, + ) } diff --git a/packages/vitest/src/integrations/chai/jest-extend.ts b/packages/vitest/src/integrations/chai/jest-extend.ts index 32c469766f2a..e3dbe6ff0a3b 100644 --- a/packages/vitest/src/integrations/chai/jest-extend.ts +++ b/packages/vitest/src/integrations/chai/jest-extend.ts @@ -1,4 +1,5 @@ import chai, { util } from 'chai' +import { getState } from './jest-expect' import * as matcherUtils from './jest-matcher-utils' @@ -20,6 +21,7 @@ const isAsyncFunction = (fn: unknown) => const getMatcherState = (assertion: Chai.AssertionStatic & Chai.Assertion) => { const actual = assertion._obj const isNot = util.flag(assertion, 'negate') as boolean + const promise = util.flag(assertion, 'promise') || '' const jestUtils = { ...matcherUtils, iterableEquality, @@ -29,10 +31,9 @@ const getMatcherState = (assertion: Chai.AssertionStatic & Chai.Assertion) => { const matcherState: MatcherState = { isNot, utils: jestUtils, - // global assertionCalls? needed for built-in jest function, but we don't use it - assertionCalls: 0, - promise: '', + promise, equals, + ...getState(), // needed for built-in jest-snapshots, but we don't use it suppressedErrors: [], } diff --git a/packages/vitest/src/integrations/chai/types.ts b/packages/vitest/src/integrations/chai/types.ts index 783c70a492b5..bb33d5a5c527 100644 --- a/packages/vitest/src/integrations/chai/types.ts +++ b/packages/vitest/src/integrations/chai/types.ts @@ -28,9 +28,9 @@ export type MatcherState = { ) => boolean expand?: boolean expectedAssertionsNumber?: number | null - expectedAssertionsNumberError?: Error + expectedAssertionsNumberError?: Error | null isExpectingAssertions?: boolean - isExpectingAssertionsError?: Error + isExpectingAssertionsError?: Error | null isNot: boolean promise: string suppressedErrors: Array diff --git a/packages/vitest/src/runtime/run.ts b/packages/vitest/src/runtime/run.ts index ce700e4ffd57..55b62888bc78 100644 --- a/packages/vitest/src/runtime/run.ts +++ b/packages/vitest/src/runtime/run.ts @@ -60,11 +60,19 @@ export async function runTest(test: Test) { try { await callSuiteHook(test.suite, 'beforeEach', [test, test.suite]) - setState({ assertionCalls: 0, expectedAssertionsNumber: null, expectedAssertionsNumberError: null }) + setState({ + assertionCalls: 0, + isExpectingAssertions: false, + isExpectingAssertionsError: null, + expectedAssertionsNumber: null, + expectedAssertionsNumberError: null, + }) await getFn(test)() - const { assertionCalls, expectedAssertionsNumber, expectedAssertionsNumberError } = getState() + const { assertionCalls, expectedAssertionsNumber, expectedAssertionsNumberError, isExpectingAssertions, isExpectingAssertionsError } = getState() if (expectedAssertionsNumber !== null && assertionCalls !== expectedAssertionsNumber) throw expectedAssertionsNumberError + if (isExpectingAssertions === true && assertionCalls === 0) + throw isExpectingAssertionsError test.result.state = 'pass' } diff --git a/test/core/test/jest-expect.test.ts b/test/core/test/jest-expect.test.ts index 574265101cb7..ad487c2989f4 100644 --- a/test/core/test/jest-expect.test.ts +++ b/test/core/test/jest-expect.test.ts @@ -70,6 +70,13 @@ describe('jest-expect', () => { // expect(new Set(['bar'])).not.toEqual(new Set([expect.stringContaining('zoo')])) }) + it('asymmetric matchers negate', () => { + expect('bar').toEqual(expect.not.stringContaining('zoo')) + expect('bar').toEqual(expect.not.stringMatching(/zoo/)) + expect({ bar: 'zoo' }).toEqual(expect.not.objectContaining({ zoo: 'bar' })) + expect(['Bob', 'Eve']).toEqual(expect.not.arrayContaining(['Steve'])) + }) + it('asymmetric matchers (chai style)', () => { expect({ foo: 'bar' }).equal({ foo: expect.stringContaining('ba') }) expect('bar').equal(expect.stringContaining('ba')) @@ -124,6 +131,20 @@ describe('jest-expect', () => { expect(1).toBe(1) }) + it.fails('has assertions', () => { + expect.hasAssertions() + }) + + it('has assertions', () => { + expect(1).toBe(1) + expect.hasAssertions() + }) + + it('has assertions with different order', () => { + expect.hasAssertions() + expect(1).toBe(1) + }) + // https://jestjs.io/docs/expect#tostrictequalvalue class LaCroix { @@ -260,4 +281,31 @@ describe('.toStrictEqual()', () => { }) }) +describe('async expect', () => { + it('resolves', async() => { + await expect((async() => 'true')()).resolves.toBe('true') + await expect((async() => 'true')()).resolves.not.toBe('true22') + }) + + it.fails('failed to resolve', async() => { + await expect((async() => { + throw new Error('err') + })()).resolves.toBe('true') + }) + + it('rejects', async() => { + await expect((async() => { + throw new Error('err') + })()).rejects.toStrictEqual(new Error('err')) + + await expect((async() => { + throw new Error('err') + })()).rejects.not.toStrictEqual(new Error('fake err')) + }) + + it.fails('failed to reject', async() => { + await expect((async() => 'test')()).rejects.toBe('test') + }) +}) + it('timeout', () => new Promise(resolve => setTimeout(resolve, 500)))