diff --git a/docs/api/expect.md b/docs/api/expect.md index a511cbb36ee5..c4bbbd2e016c 100644 --- a/docs/api/expect.md +++ b/docs/api/expect.md @@ -876,6 +876,44 @@ test('spy function', () => { }) ``` +## toHaveBeenCalledBefore 2.2.0 {#tohavebeencalledbefore} + +- **Type**: `(mock: MockInstance, failIfNoFirstInvocation?: boolean) => Awaitable` + +This assertion checks if a `Mock` was called before another `Mock`. + +```ts +test('calls mock1 before mock2', () => { + const mock1 = vi.fn() + const mock2 = vi.fn() + + mock1() + mock2() + mock1() + + expect(mock1).toHaveBeenCalledBefore(mock2) +}) +``` + +## toHaveBeenCalledAfter 2.2.0 {#tohavebeencalledafter} + +- **Type**: `(mock: MockInstance, failIfNoFirstInvocation?: boolean) => Awaitable` + +This assertion checks if a `Mock` was called after another `Mock`. + +```ts +test('calls mock1 after mock2', () => { + const mock1 = vi.fn() + const mock2 = vi.fn() + + mock2() + mock1() + mock2() + + expect(mock1).toHaveBeenCalledAfter(mock2) +}) +``` + ## toHaveBeenCalledExactlyOnceWith 2.2.0 {#tohavebeencalledexactlyoncewith} - **Type**: `(...args: any[]) => Awaitable` diff --git a/packages/expect/src/jest-expect.ts b/packages/expect/src/jest-expect.ts index 6616e6f9d23a..c15f277edd9e 100644 --- a/packages/expect/src/jest-expect.ts +++ b/packages/expect/src/jest-expect.ts @@ -656,6 +656,74 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => { ) }, ) + + /** + * Used for `toHaveBeenCalledBefore` and `toHaveBeenCalledAfter` to determine if the expected spy was called before the result spy. + */ + function isSpyCalledBeforeAnotherSpy(beforeSpy: MockInstance, afterSpy: MockInstance, failIfNoFirstInvocation: number): boolean { + const beforeInvocationCallOrder = beforeSpy.mock.invocationCallOrder + + const afterInvocationCallOrder = afterSpy.mock.invocationCallOrder + + if (beforeInvocationCallOrder.length === 0) { + return !failIfNoFirstInvocation + } + + if (afterInvocationCallOrder.length === 0) { + return false + } + + return beforeInvocationCallOrder[0] < afterInvocationCallOrder[0] + } + + def( + ['toHaveBeenCalledBefore'], + function (resultSpy: MockInstance, failIfNoFirstInvocation = true) { + const expectSpy = getSpy(this) + + if (!isMockFunction(resultSpy)) { + throw new TypeError( + `${utils.inspect(resultSpy)} is not a spy or a call to a spy`, + ) + } + + this.assert( + isSpyCalledBeforeAnotherSpy( + expectSpy, + resultSpy, + failIfNoFirstInvocation, + ), + `expected "${expectSpy.getMockName()}" to have been called before "${resultSpy.getMockName()}"`, + `expected "${expectSpy.getMockName()}" to not have been called before "${resultSpy.getMockName()}"`, + resultSpy, + expectSpy, + ) + }, + ) + def( + ['toHaveBeenCalledAfter'], + function (resultSpy: MockInstance, failIfNoFirstInvocation = true) { + const expectSpy = getSpy(this) + + if (!isMockFunction(resultSpy)) { + throw new TypeError( + `${utils.inspect(resultSpy)} is not a spy or a call to a spy`, + ) + } + + this.assert( + isSpyCalledBeforeAnotherSpy( + resultSpy, + expectSpy, + failIfNoFirstInvocation, + ), + `expected "${expectSpy.getMockName()}" to have been called after "${resultSpy.getMockName()}"`, + `expected "${expectSpy.getMockName()}" to not have been called after "${resultSpy.getMockName()}"`, + resultSpy, + expectSpy, + ) + }, + ) def( ['toThrow', 'toThrowError'], function (expected?: string | Constructable | RegExp | Error) { diff --git a/packages/expect/src/types.ts b/packages/expect/src/types.ts index e53b059e555e..5e1a76a897bd 100644 --- a/packages/expect/src/types.ts +++ b/packages/expect/src/types.ts @@ -6,6 +6,7 @@ * */ +import type { MockInstance } from '@vitest/spy' import type { Constructable } from '@vitest/utils' import type { Formatter } from 'tinyrainbow' import type { diff, getMatcherUtils, stringify } from './jest-matcher-utils' @@ -655,6 +656,38 @@ export interface Assertion */ toSatisfy: (matcher: (value: E) => boolean, message?: string) => void + /** + * This assertion checks if a `Mock` was called before another `Mock`. + * @param mock - A mock function created by `vi.spyOn` or `vi.fn` + * @param failIfNoFirstInvocation - Fail if the first mock was never called + * @example + * const mock1 = vi.fn() + * const mock2 = vi.fn() + * + * mock1() + * mock2() + * mock1() + * + * expect(mock1).toHaveBeenCalledBefore(mock2) + */ + toHaveBeenCalledBefore: (mock: MockInstance, failIfNoFirstInvocation?: boolean) => void + + /** + * This assertion checks if a `Mock` was called after another `Mock`. + * @param mock - A mock function created by `vi.spyOn` or `vi.fn` + * @param failIfNoFirstInvocation - Fail if the first mock was never called + * @example + * const mock1 = vi.fn() + * const mock2 = vi.fn() + * + * mock2() + * mock1() + * mock2() + * + * expect(mock1).toHaveBeenCalledAfter(mock2) + */ + toHaveBeenCalledAfter: (mock: MockInstance, failIfNoFirstInvocation?: boolean) => void + /** * Checks that a promise resolves successfully at least once. * diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e4a2cedd5393..5cbc1225559a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6007,8 +6007,8 @@ packages: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} - get-east-asian-width@1.2.0: - resolution: {integrity: sha512-2nk+7SIVb14QrgXFHcm84tD4bKQz0RxPuMT8Ag5KPOq7J5fEmAg0UbXdTOSHqNuHSU28k55qnceesxXRZGzKWA==} + get-east-asian-width@1.3.0: + resolution: {integrity: sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ==} engines: {node: '>=18'} get-intrinsic@1.2.4: @@ -6144,8 +6144,8 @@ packages: graphemer@1.4.0: resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} - graphql@16.8.1: - resolution: {integrity: sha512-59LZHPdGZVh695Ud9lRzPBVTtlX9ZCV150Er2W43ro37wVof0ctenSaskPPjN7lVTIN8mSZt8PHUNKZuNQUuxw==} + graphql@16.9.0: + resolution: {integrity: sha512-GGTKBX4SD7Wdb8mqeDLni2oaRGYQWjWHGKPQ24ZMnUtKfcsVoiv4uX8+LJr1K6U5VW2Lu1BwJnj7uiori0YtRw==} engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} gtoken@7.0.1: @@ -8393,8 +8393,8 @@ packages: streamx@2.15.1: resolution: {integrity: sha512-fQMzy2O/Q47rgwErk/eGeLu/roaFWV0jVsogDmrszM9uIw8L5OA+t+V93MgYlufNptfjmYR1tOMWhei/Eh7TQA==} - streamx@2.18.0: - resolution: {integrity: sha512-LLUC1TWdjVdn1weXGcSxyTR3T4+acB6tVGXT95y0nGbca4t4o/ng1wKAGTljm9VicuCVLvRlqFYXYy5GwgM7sQ==} + streamx@2.20.1: + resolution: {integrity: sha512-uTa0mU6WUC65iUvzKH4X9hEdvSW7rbPxPtwfWiLMSj3qTdQbAiUboZTxauKfpFuGIGa1C2BYijZ7wgdUXICJhA==} strict-event-emitter@0.5.1: resolution: {integrity: sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==} @@ -13079,7 +13079,7 @@ snapshots: bare-stream@2.1.3: dependencies: - streamx: 2.18.0 + streamx: 2.20.1 optional: true base64-js@1.5.1: {} @@ -14950,7 +14950,7 @@ snapshots: get-caller-file@2.0.5: {} - get-east-asian-width@1.2.0: {} + get-east-asian-width@1.3.0: {} get-intrinsic@1.2.4: dependencies: @@ -15141,7 +15141,7 @@ snapshots: graphemer@1.4.0: {} - graphql@16.8.1: {} + graphql@16.9.0: {} gtoken@7.0.1: dependencies: @@ -16464,7 +16464,7 @@ snapshots: '@types/cookie': 0.6.0 '@types/statuses': 2.0.5 chalk: 4.1.2 - graphql: 16.8.1 + graphql: 16.9.0 headers-polyfill: 4.0.3 is-node-process: 1.2.0 outvariant: 1.4.3 @@ -17726,7 +17726,7 @@ snapshots: fast-fifo: 1.3.2 queue-tick: 1.0.1 - streamx@2.18.0: + streamx@2.20.1: dependencies: fast-fifo: 1.3.2 queue-tick: 1.0.1 @@ -17754,7 +17754,7 @@ snapshots: string-width@7.0.0: dependencies: emoji-regex: 10.3.0 - get-east-asian-width: 1.2.0 + get-east-asian-width: 1.3.0 strip-ansi: 7.1.0 string.prototype.matchall@4.0.11: diff --git a/test/core/test/jest-expect.test.ts b/test/core/test/jest-expect.test.ts index 79ffc4874b39..4e230a1cff2d 100644 --- a/test/core/test/jest-expect.test.ts +++ b/test/core/test/jest-expect.test.ts @@ -698,6 +698,158 @@ describe('toHaveBeenCalledExactlyOnceWith', () => { }) }) +describe('toHaveBeenCalledBefore', () => { + it('success if expect mock is called before result mock', () => { + const expectMock = vi.fn() + const resultMock = vi.fn() + + expectMock() + resultMock() + + expect(expectMock).toHaveBeenCalledBefore(resultMock) + }) + + it('throws if expect is not a spy', () => { + expect(() => { + expect(1).toHaveBeenCalledBefore(vi.fn()) + }).toThrow(/^1 is not a spy or a call to a spy/) + }) + + it('throws if result is not a spy', () => { + expect(() => { + expect(vi.fn()).toHaveBeenCalledBefore(1 as any) + }).toThrow(/^1 is not a spy or a call to a spy/) + }) + + it('throws if expect mock is called after result mock', () => { + const expectMock = vi.fn() + const resultMock = vi.fn() + + resultMock() + expectMock() + + expect(() => { + expect(expectMock).toHaveBeenCalledBefore(resultMock) + }).toThrow(/^expected "spy" to have been called before "spy"/) + }) + + it('throws with correct mock name if failed', () => { + const mock1 = vi.fn().mockName('mock1') + const mock2 = vi.fn().mockName('mock2') + + mock2() + mock1() + + expect(() => { + expect(mock1).toHaveBeenCalledBefore(mock2) + }).toThrow(/^expected "mock1" to have been called before "mock2"/) + }) + + it('fails if expect mock is not called', () => { + const resultMock = vi.fn() + + resultMock() + + expect(() => { + expect(vi.fn()).toHaveBeenCalledBefore(resultMock) + }).toThrow(/^expected "spy" to have been called before "spy"/) + }) + + it('not fails if expect mock is not called with option `failIfNoFirstInvocation` set to false', () => { + const resultMock = vi.fn() + + resultMock() + + expect(vi.fn()).toHaveBeenCalledBefore(resultMock, false) + }) + + it('fails if result mock is not called', () => { + const expectMock = vi.fn() + + expectMock() + + expect(() => { + expect(expectMock).toHaveBeenCalledBefore(vi.fn()) + }).toThrow(/^expected "spy" to have been called before "spy"/) + }) +}) + +describe('toHaveBeenCalledAfter', () => { + it('success if expect mock is called after result mock', () => { + const resultMock = vi.fn() + const expectMock = vi.fn() + + resultMock() + expectMock() + + expect(expectMock).toHaveBeenCalledAfter(resultMock) + }) + + it('throws if expect is not a spy', () => { + expect(() => { + expect(1).toHaveBeenCalledAfter(vi.fn()) + }).toThrow(/^1 is not a spy or a call to a spy/) + }) + + it('throws if result is not a spy', () => { + expect(() => { + expect(vi.fn()).toHaveBeenCalledAfter(1 as any) + }).toThrow(/^1 is not a spy or a call to a spy/) + }) + + it('throws if expect mock is called before result mock', () => { + const resultMock = vi.fn() + const expectMock = vi.fn() + + expectMock() + resultMock() + + expect(() => { + expect(expectMock).toHaveBeenCalledAfter(resultMock) + }).toThrow(/^expected "spy" to have been called after "spy"/) + }) + + it('throws with correct mock name if failed', () => { + const mock1 = vi.fn().mockName('mock1') + const mock2 = vi.fn().mockName('mock2') + + mock1() + mock2() + + expect(() => { + expect(mock1).toHaveBeenCalledAfter(mock2) + }).toThrow(/^expected "mock1" to have been called after "mock2"/) + }) + + it('fails if result mock is not called', () => { + const expectMock = vi.fn() + + expectMock() + + expect(() => { + expect(expectMock).toHaveBeenCalledAfter(vi.fn()) + }).toThrow(/^expected "spy" to have been called after "spy"/) + }) + + it('not fails if result mock is not called with option `failIfNoFirstInvocation` set to false', () => { + const expectMock = vi.fn() + + expectMock() + + expect(expectMock).toHaveBeenCalledAfter(vi.fn(), false) + }) + + it('fails if expect mock is not called', () => { + const resultMock = vi.fn() + + resultMock() + + expect(() => { + expect(vi.fn()).toHaveBeenCalledAfter(resultMock) + }).toThrow(/^expected "spy" to have been called after "spy"/) + }) +}) + describe('async expect', () => { it('resolves', async () => { await expect((async () => 'true')()).resolves.toBe('true')