diff --git a/docs/api/expect.md b/docs/api/expect.md
index 1103ee6a15cb..282e65984587 100644
--- a/docs/api/expect.md
+++ b/docs/api/expect.md
@@ -876,6 +876,30 @@ test('spy function', () => {
})
```
+## toHaveBeenCalledExactlyOnceWith 2.2.0 {#tohavebeencalledexactlyoncewith}
+
+- **Type**: `(...args: any[]) => Awaitable`
+
+This assertion checks if a function was called exactly once and with certain parameters. Requires a spy function to be passed to `expect`.
+
+```ts
+import { expect, test, vi } from 'vitest'
+
+const market = {
+ buy(subject: string, amount: number) {
+ // ...
+ },
+}
+
+test('spy function', () => {
+ const buySpy = vi.spyOn(market, 'buy')
+
+ market.buy('apples', 10)
+
+ expect(buySpy).toHaveBeenCalledExactlyOnceWith('apples', 10)
+})
+```
+
## toHaveBeenLastCalledWith
- **Type**: `(...args: any[]) => Awaitable`
diff --git a/packages/expect/src/jest-expect.ts b/packages/expect/src/jest-expect.ts
index c8cb304ce193..dc16c62dafa1 100644
--- a/packages/expect/src/jest-expect.ts
+++ b/packages/expect/src/jest-expect.ts
@@ -595,6 +595,27 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => {
throw new AssertionError(formatCalls(spy, msg, args))
}
})
+ def('toHaveBeenCalledExactlyOnceWith', function (...args) {
+ const spy = getSpy(this)
+ const spyName = spy.getMockName()
+ const callCount = spy.mock.calls.length
+ const hasCallWithArgs = spy.mock.calls.some(callArg =>
+ jestEquals(callArg, args, [...customTesters, iterableEquality]),
+ )
+ const pass = hasCallWithArgs && callCount === 1
+ const isNot = utils.flag(this, 'negate') as boolean
+
+ const msg = utils.getMessage(this, [
+ pass,
+ `expected "${spyName}" to be called once with arguments: #{exp}`,
+ `expected "${spyName}" to not be called once with arguments: #{exp}`,
+ args,
+ ])
+
+ if ((pass && isNot) || (!pass && !isNot)) {
+ throw new AssertionError(formatCalls(spy, msg, args))
+ }
+ })
def(
['toHaveBeenNthCalledWith', 'nthCalledWith'],
function (times: number, ...args: any[]) {
diff --git a/packages/expect/src/types.ts b/packages/expect/src/types.ts
index c1f033437980..e53b059e555e 100644
--- a/packages/expect/src/types.ts
+++ b/packages/expect/src/types.ts
@@ -635,6 +635,15 @@ export interface Assertion
*/
toHaveBeenCalledOnce: () => void
+ /**
+ * Ensure that a mock function is called with specific arguments and called
+ * exactly once.
+ *
+ * @example
+ * expect(mockFunc).toHaveBeenCalledExactlyOnceWith('arg1', 42);
+ */
+ toHaveBeenCalledExactlyOnceWith: (...args: E) => void
+
/**
* Checks that a value satisfies a custom matcher function.
*
diff --git a/test/core/test/jest-expect.test.ts b/test/core/test/jest-expect.test.ts
index 4d6bb3a0e8e7..79ffc4874b39 100644
--- a/test/core/test/jest-expect.test.ts
+++ b/test/core/test/jest-expect.test.ts
@@ -634,6 +634,70 @@ describe('toHaveBeenCalledWith', () => {
})
})
+describe('toHaveBeenCalledExactlyOnceWith', () => {
+ describe('negated', () => {
+ it('fails if called', () => {
+ const mock = vi.fn()
+ mock(3)
+
+ expect(() => {
+ expect(mock).not.toHaveBeenCalledExactlyOnceWith(3)
+ }).toThrow(/^expected "spy" to not be called once with arguments: \[ 3 \][^e]/)
+ })
+
+ it('passes if called multiple times with args', () => {
+ const mock = vi.fn()
+ mock(3)
+ mock(3)
+
+ expect(mock).not.toHaveBeenCalledExactlyOnceWith(3)
+ })
+
+ it('passes if not called', () => {
+ const mock = vi.fn()
+ expect(mock).not.toHaveBeenCalledExactlyOnceWith(3)
+ })
+
+ it('passes if called with a different argument', () => {
+ const mock = vi.fn()
+ mock(4)
+
+ expect(mock).not.toHaveBeenCalledExactlyOnceWith(3)
+ })
+ })
+
+ it('fails if not called or called too many times', () => {
+ const mock = vi.fn()
+
+ expect(() => {
+ expect(mock).toHaveBeenCalledExactlyOnceWith(3)
+ }).toThrow(/^expected "spy" to be called once with arguments: \[ 3 \][^e]/)
+
+ mock(3)
+ mock(3)
+
+ expect(() => {
+ expect(mock).toHaveBeenCalledExactlyOnceWith(3)
+ }).toThrow(/^expected "spy" to be called once with arguments: \[ 3 \][^e]/)
+ })
+
+ it('fails if called with wrong args', () => {
+ const mock = vi.fn()
+ mock(4)
+
+ expect(() => {
+ expect(mock).toHaveBeenCalledExactlyOnceWith(3)
+ }).toThrow(/^expected "spy" to be called once with arguments: \[ 3 \][^e]/)
+ })
+
+ it('passes if called exactly once with args', () => {
+ const mock = vi.fn()
+ mock(3)
+
+ expect(mock).toHaveBeenCalledExactlyOnceWith(3)
+ })
+})
+
describe('async expect', () => {
it('resolves', async () => {
await expect((async () => 'true')()).resolves.toBe('true')