How to verify a mock was called

Pass the code a vi.fn(), run it, then assert expect(gateway.charge).toHaveBeenCalledWith(payload). The weaker toHaveBeenCalled() passes on the run below even though the amount left in euros and the gateway was charged in dollars. Only the argument assertion prints a diff naming the field that differs.

Why check this

Run this when the only observable effect of the code is an outbound call: a payment gateway, an email transport, an analytics sink, a queue publisher. Nothing comes back to assert on, so the double is the whole test.

The failure it prevents is a silent contract break. The module below hardcodes currency: 'USD' while the order was quoted in EUR. A suite that asserts the gateway was called stays green through that, through a customer email appearing in the payload, and through calls arriving in the wrong order. Each needs its own assertion.

Prerequisites

mkdir mockdemo && cd mockdemo
npm init -y > /dev/null
npm pkg set type=module
npm install -D vitest@5
mkdir src test
cat > src/checkout.js <<'EOF'
export async function placeOrder(order, gateway) {
  const payload = {
    id: order.id,
    amount: order.totalCents,
    currency: 'USD',
    customer: { id: order.customerId, email: order.email },
  };
  return gateway.charge(payload);
}

export function trackEvents(events, sink) {
  const frame = { type: '', id: 0 };
  for (const event of events) {
    frame.type = event.type;
    frame.id = event.id;
    sink(frame);
  }
}
EOF

Steps

  1. Step 1.

    Assert only that the collaborator ran, and watch a wrong currency pass.

    cat > test/called.test.js <<'EOF'
    import { describe, it, expect, vi } from 'vitest';
    import { placeOrder } from '../src/checkout.js';
    
    const order = { id: 'A-1', totalCents: 900, currency: 'EUR', customerId: 7, email: 'k@example.com' };
    
    describe('placeOrder', () => {
      it('calls the gateway', async () => {
        const gateway = { charge: vi.fn().mockResolvedValue({ ok: true }) };
        await placeOrder(order, gateway);
        expect(gateway.charge).toHaveBeenCalled();
        expect(gateway.charge).toHaveBeenCalledTimes(1);
      });
    });
    EOF
    
    npx vitest run test/called.test.js
    
     RUN  v5.0.0 C:/…/mockdemo
    
    
    Test Files  1 passed (1)
        Tests  1 passed (1)
     Start at  11:02:39
     Duration  237ms (transform 49%, import 34%, tests 10%, worker 8%)

    Green. The gateway was charged in the wrong currency and this test cannot see it.

  2. Step 2.

    Assert the argument instead, and read the diff.

    cat > test/args.test.js <<'EOF'
    import { describe, it, expect, vi } from 'vitest';
    import { placeOrder } from '../src/checkout.js';
    
    const order = { id: 'A-1', totalCents: 900, currency: 'EUR', customerId: 7, email: 'k@example.com' };
    
    describe('placeOrder', () => {
      it('charges the order currency', async () => {
        const gateway = { charge: vi.fn().mockResolvedValue({ ok: true }) };
        await placeOrder(order, gateway);
        expect(gateway.charge).toHaveBeenCalledWith({
          id: 'A-1',
          amount: 900,
          currency: 'EUR',
          customer: { id: 7, email: 'k@example.com' },
        });
      });
    
      it('passes a loose shape check', async () => {
        const gateway = { charge: vi.fn().mockResolvedValue({ ok: true }) };
        await placeOrder(order, gateway);
        expect(gateway.charge).toHaveBeenCalledWith(expect.objectContaining({ amount: 900 }));
      });
    });
    EOF
    
    npx vitest run test/args.test.js
    
     FAIL  test/args.test.js > placeOrder > charges the order currency
    AssertionError: expected "vi.fn()" to be called with arguments: [ { id: 'A-1', amount: 900, …(2) } ]
    
    Received:
    
    1st vi.fn() call:
    
    @@ -1,9 +1,9 @@
    [
      {
        "amount": 900,
    -     "currency": "EUR",
    +     "currency": "USD",
        "customer": {
          "email": "k@example.com",
          "id": 7,
        },
        "id": "A-1",
    
    
    Number of calls: 1
    
    Test Files  1 failed (1)
        Tests  1 failed | 1 passed (2)

    Three things to read. The diff sorts keys alphabetically, so its order is not the order your code wrote them in. Number of calls: 1 is how you tell a wrong argument from a second unexpected call. The header truncates the expected object at two fields, so the diff body is the only full record.

    The second test in that file passed. It asserts toHaveBeenCalledWith(expect.objectContaining({ amount: 900 })), which ignores every other field, including the currency and the customer email.

  3. Step 3.

    Assert the order of the calls with toHaveBeenNthCalledWith.

    cat > test/order.test.js <<'EOF'
    import { describe, it, expect, vi } from 'vitest';
    import { trackEvents } from '../src/checkout.js';
    
    const events = [
      { type: 'view', id: 1 },
      { type: 'add', id: 2 },
      { type: 'buy', id: 3 },
    ];
    
    describe('trackEvents', () => {
      it('sends three frames', () => {
        const sink = vi.fn();
        trackEvents(events, sink);
        expect(sink).toHaveBeenCalledTimes(3);
        expect(sink).toHaveBeenCalledWith({ type: 'buy', id: 3 });
      });
    
      it('sends the view frame first', () => {
        const sink = vi.fn();
        trackEvents(events, sink);
        expect(sink).toHaveBeenNthCalledWith(1, { type: 'view', id: 1 });
      });
    });
    EOF
    
    npx vitest run test/order.test.js
    
     ❯ test/order.test.js (2 tests | 1 failed) 19ms
     ❯ trackEvents (2)
       × sends the view frame first 12ms
    
    FAIL  test/order.test.js > trackEvents > sends the view frame first
    AssertionError: expected 1st "vi.fn()" call to have been called with [ { type: 'view', id: 1 } ]
    
    - Expected
    + Received
    
    [
      {
    -     "id": 1,
    -     "type": "view",
    +     "id": 3,
    +     "type": "buy",
      },
    ]

    The first call is reported as the third event. The other test in the file passed both toHaveBeenCalledTimes(3) and toHaveBeenCalledWith({ type: 'buy', id: 3 }), so the count is right and the order looks wrong.

  4. Step 4.

    Print the recorded calls before believing the diff.

    cat > test/inspect.test.js <<'EOF'
    import { describe, it, expect, vi } from 'vitest';
    import { trackEvents } from '../src/checkout.js';
    
    const events = [
      { type: 'view', id: 1 },
      { type: 'add', id: 2 },
      { type: 'buy', id: 3 },
    ];
    
    describe('trackEvents', () => {
      it('records what each call received', () => {
        const sink = vi.fn();
        trackEvents(events, sink);
        console.log(JSON.stringify(sink.mock.calls));
        console.log('same object in every call:', sink.mock.calls[0][0] === sink.mock.calls[2][0]);
      });
    
      it('copies each frame before asserting', () => {
        const seen = [];
        const sink = vi.fn((frame) => seen.push({ ...frame }));
        trackEvents(events, sink);
        expect(seen[0]).toEqual({ type: 'view', id: 1 });
        expect(seen).toHaveLength(3);
      });
    });
    EOF
    
    npx vitest run test/inspect.test.js --reporter=verbose
    
    stdout | test/inspect.test.js > trackEvents > records what each call received
    [[{"type":"buy","id":3}],[{"type":"buy","id":3}],[{"type":"buy","id":3}]]
    same object in every call: true
    
    ✓ test/inspect.test.js > trackEvents > records what each call received 3ms
    ✓ test/inspect.test.js > trackEvents > copies each frame before asserting 2ms

    mock.calls stores references, not copies. trackEvents reuses one frame object, so all three entries are that object holding its final state. The code sent the events in the right order and the recording cannot show it. The second test proves the point: a double written as vi.fn((frame) => seen.push({ ...frame })) copies each argument at call time, and seen[0] is the view frame.

    The default reporter printed neither console.log line. Passing files hide their stdout until you add --reporter=verbose.

  5. Step 5.

    Pair every negative assertion with a positive one on the same double.

    cat > test/wiring.test.js <<'EOF'
    import { describe, it, expect, vi } from 'vitest';
    import { placeOrder } from '../src/checkout.js';
    
    const order = { id: 'A-1', totalCents: 900, currency: 'EUR', customerId: 7, email: 'k@example.com' };
    
    describe('placeOrder', () => {
      it('writes no audit line for a free order', async () => {
        const gateway = { charge: vi.fn().mockResolvedValue({ ok: true }) };
        const audit = vi.fn().mockName('audit');
        await placeOrder({ ...order, totalCents: 0 }, gateway);
        expect(audit).not.toHaveBeenCalled();
      });
    
      it('writes an audit line for a paid order', async () => {
        const gateway = { charge: vi.fn().mockResolvedValue({ ok: true }) };
        const audit = vi.fn().mockName('audit');
        await placeOrder(order, gateway);
        expect(audit).toHaveBeenCalled();
      });
    });
    EOF
    
    npx vitest run test/wiring.test.js
    
     ❯ test/wiring.test.js (2 tests | 1 failed) 8ms
     ❯ placeOrder (2)
       × writes an audit line for a paid order 3ms
    
    FAIL  test/wiring.test.js > placeOrder > writes an audit line for a paid order
    AssertionError: expected "audit" to be called at least once
    ❯ test/wiring.test.js:18:19
       16|     const audit = vi.fn().mockName('audit');
       17|     await placeOrder(order, gateway);
       18|     expect(audit).toHaveBeenCalled();
         |                   ^

    Both tests build const audit = vi.fn() and neither passes it to placeOrder, which takes two parameters. The first test asserts not.toHaveBeenCalled() and passes, proving nothing. The twin fails, which is the only reason you find out. mockName('audit') is what puts the name in the message instead of vi.fn().

How to read the result

| What you see | What it means | What to do | | --- | --- | --- | | toHaveBeenCalled() passes | The collaborator ran, with unknown arguments | Replace it with toHaveBeenCalledWith | | A diff on one field | The call happened, the payload is wrong | Fix the code or the expectation, and keep the assertion | | Number of calls: 1 under a diff | One call, wrong argument | Read the diff, not the count | | Number of calls: 2 under a diff | An extra call you did not expect | Add toHaveBeenCalledTimes before chasing the payload | | Every recorded call shows the last value | The code reuses one object across calls | Copy the argument inside the double, then assert on the copy | | A negative assertion passes on its own | The double may never have been wired in | Write the positive twin and watch it fail | | expected "vi.fn()" to be called | The double has no name | Add .mockName('audit') for readable failures |

Common mistakes

Sign: An argument assertion reports a value the code never sent.Cause: mock.calls holds references. Code that fills one object and passes it on every iteration leaves three pointers to one object, all showing the final state. Step 4 prints three identical entries for three different events. Copy inside the double before asserting.
Sign: expect(mock).not.toHaveBeenCalled() passes in a test that proves nothing.Cause: A double that was never injected records zero calls, and so does a collaborator that is genuinely never used. The two are indistinguishable. Only a positive assertion on the same object separates them.
Sign: objectContaining makes an argument assertion pass after a field changes.Cause: It checks the keys you list and ignores the rest, so an added customer email or a switched currency slips through. Step 2 has one of each in the same file, one failing and one passing on the same call.
Sign: A console.log of mock.calls prints nothing.Cause: Vitest 5 default reporter suppresses stdout for a passing file. The call was recorded and the log was written, and neither reaches the terminal until the run fails or you pass --reporter=verbose.

What to check next

FAQ

How do I verify a method was not called?

expect(mock).not.toHaveBeenCalled(), and never on its own. Add a test that asserts the same double was called in a case where it should be. Step 5 shows the negative passing and the positive failing on a double nobody wired in.

How do I check the arguments a mock was called with?

toHaveBeenCalledWith(...) for any call, toHaveBeenNthCalledWith(n, ...) for a specific one, toHaveBeenLastCalledWith(...) for the last. To look rather than assert, read mock.calls, an array with one entry per call holding that call's arguments.

How does this map to Mockito?

verify(gateway).charge(payload) is expect(gateway.charge).toHaveBeenCalledWith(payload). verify(gateway, never()).charge(any()) is expect(gateway.charge).not.toHaveBeenCalled(). Mockito's ArgumentCaptor corresponds to reading mock.calls, and it has the same reference hazard as step 4.

Why does my assertion pass when the value is wrong?

Three usual reasons: the assertion is toHaveBeenCalled() and ignores arguments, it is wrapped in expect.objectContaining and ignores the field, or the double under assertion is not the one the code used.

Are these matchers the same in Jest?

The matcher names are the same. The failure output is not. The diff layout, the Number of calls line and the stdout handling on this page are Vitest 5.0.0.

Verified

Verified by Maks Vernynode 22.23.2vitest 5.0.0

Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.

intermediate9 minpublished updated Maks Verny