Vitest: vi.mocked(...).mockResolvedValue is not a function

Vitest 3 test fails with vi.mocked(...).mockResolvedValue is not a function after restoreAllMocks. Here's how to fix it in one line.

While running the test suite for git-proxy on a branch still pinned to Vitest 3.2.4, two tests in the same file started failing with this:

TypeError: vi.mocked(...).mockResolvedValue is not a function

Both were doing the same ordinary thing: stubbing a database lookup before hitting an Express route.

vi.mocked(db.findUser).mockResolvedValue({ username: 'alice' /* ... */ });

What made it strange is that plenty of other tests in the same file mock that exact function and pass. Only this one block fails, on a line that looks identical to working code elsewhere.

The setup

The file tests GitProxy’s auth routes. It mocks the database module with a factory, then restores mocks after every test:

vi.mock('../../../src/db', () => ({
  findUser: vi.fn(),
  updateUser: vi.fn(),
  createUser: vi.fn(),
}));

describe('Auth API', () => {
  afterEach(() => {
    vi.restoreAllMocks();
  });
  // ...
});

Earlier blocks set up db.findUser with vi.spyOn(db, 'findUser'). The failing block came later and used vi.mocked(db.findUser).mockResolvedValue(...) instead. That one difference turned out to be the whole story.

Why the mock was gone

A quick log right before the failing line told me what I needed:

console.log(typeof db.findUser);                  // "function"
console.log(typeof db.findUser.mockResolvedValue); // "undefined"

So db.findUser was still a function, but not a Vitest mock. It was the real findUser from the database module, which is why .mockResolvedValue did not exist on it.

The cause is a Vitest 3 behavior. vi.restoreAllMocks() does not only undo vi.spyOn spies. Once a factory-mocked export has been touched by vi.spyOn, restoring puts that export back to its real implementation. My earlier blocks spied on db.findUser, so after the last spy ran and the afterEach fired, db.findUser pointed at the real database function for the rest of the run. The blocks that re-stub the function fresh in each test kept working; the one block that assumed the factory mock was still in place did not.

The reason vi.mocked(...) cannot rescue you here is that it does nothing at runtime. It is a TypeScript helper that returns its argument unchanged, there only so the mock methods type-check. Hand it the real function and it hands the real function right back, with no .mockResolvedValue attached.

This changed in Vitest 4: vi.restoreAllMocks() no longer touches automocked or factory-mocked modules, so the same code passes there. The official Vitest migration guide lists it under the mocking changes. On a branch still on Vitest 3, though, you have to write around it.

The fix

Install a mock at the point of use with vi.spyOn, instead of assuming one is already there:

// before: assumes db.findUser is still the factory mock
vi.mocked(db.findUser).mockResolvedValue(null);

// after: installs a fresh spy on whatever db.findUser currently is
vi.spyOn(db, 'findUser').mockResolvedValue(null);

vi.spyOn works whether db.findUser is currently the factory mock or the real function, because it wraps whatever is there at that moment. It also matches how the rest of the file already sets up that function, so the block stops being the odd one out. Both tests went green, on Vitest 3 and on 4.

The takeaway

vi.mocked() is only a type cast. It never creates a mock and never restores one. If anything earlier in a file can turn a mocked export back into the real thing, and on Vitest 3 a vi.spyOn plus restoreAllMocks does exactly that, prefer vi.spyOn at the point of use so the test does not depend on the mock still being in place. If you are partway through moving from Vitest 3 to 4, this is one of the quieter behavior changes to keep in mind. I ran into a different Vitest 4 surprise earlier, where tests passed locally but failed in CI.