I spent last week moving the FINOS git-proxy test suite off Mocha, Chai, and Sinon and onto Vitest. While converting the sample test (1.test.js), I came across a pretty annoying failure.
Here is the failing test, trimmed to the part that mattered. It mocks fs so the config loader reads a fake proxy.config.json, then checks that the right auth methods come back:
it('should return an array of enabled auth methods when overridden', async () => {
vi.mock('fs', async (importOriginal) => {
const actual = await importOriginal();
return {
...actual,
readFileSync: vi.fn().mockReturnValue(JSON.stringify({
authentication: [
{ type: 'local', enabled: true },
{ type: 'ActiveDirectory', enabled: true },
{ type: 'openidconnect', enabled: true },
],
})),
};
});
vi.resetModules();
const config = await import('../src/config');
config.initUserConfig();
const authMethods = config.getAuthMethods();
expect(authMethods).toHaveLength(3);
expect(authMethods[0].type).toBe('local');
});
And the failure:
SyntaxError: "undefined" is not valid JSON
❯ Module.initUserConfig src/config/index.ts:23:26
23| _userSettings = JSON.parse(readFileSync(configFile, 'utf-8'));
So readFileSync handed JSON.parse an undefined. JSON.parse coerces its argument to a string first, so undefined becomes the literal text "undefined", which is then not valid JSON. The function being called was plainly not my mock. But the mockReturnValue was right there in the test. So what happened?
proxyquire trained me to think the wrong way
The old test used proxyquire, and proxyquire shaped how I expected this to behave. With proxyquire you intercept a dependency at the moment of require, inline, for that one call:
const config = proxyquire('../src/config', {
fs: { readFileSync: () => JSON.stringify({ /* ... */ }) },
});
It reads top to bottom. Set up the fake, require the module, the module sees the fake. So I translated it one for one: call vi.mock('fs', ...), then import config, and expect config to pick up the fake. That mental model is the whole bug.
vi.mock is hoisted, and that explains everything
Vitest does not run vi.mock where you write it. Its transform lifts every vi.mock call to the very top of the file, above the imports, no matter where it sits in the source. The docs say it plainly: a vi.mock call is moved to the top of the file. So putting it inside the it block was a lie I told myself about ordering. By the time my test body ran, that registration had already happened during the file’s setup, so the sequence I was counting on (mock, then import, in that block) never existed.
There is a second, quieter problem in the same test. initUserConfig only reads the file when it exists:
if (existsSync(configFile)) {
_userSettings = JSON.parse(readFileSync(configFile, 'utf-8'));
}
My mock spread ...actual and replaced only readFileSync, so existsSync stayed real. Whether that branch ran at all depended on whether a real config file happened to sit on disk in CI versus on my laptop. That is the kind of test that passes on one machine and fails on another, which is exactly what I do not want to ship.
The fix: vi.doMock plus a mocked existsSync
The right tool for “install a mock right here, then import the module that should see it” is vi.doMock. It is the non-hoisted sibling of vi.mock: it runs at the point you call it and applies only to imports that happen afterward. That is the call-time behavior proxyquire gave me, written the Vitest way. I also mock existsSync so the read branch is taken on purpose rather than by luck:
it('should return an array of enabled auth methods when overridden', async () => {
// Not hoisted: this runs right here, before the dynamic import below.
vi.doMock('fs', async (importOriginal) => {
const actual = await importOriginal();
return {
...actual,
existsSync: vi.fn().mockReturnValue(true),
readFileSync: vi.fn().mockReturnValue(
JSON.stringify({
authentication: [
{ type: 'local', enabled: true },
{ type: 'ActiveDirectory', enabled: true },
{ type: 'openidconnect', enabled: true },
],
}),
),
};
});
vi.resetModules();
const config = await import('../src/config');
config.initUserConfig();
const authMethods = config.getAuthMethods();
expect(authMethods).toHaveLength(3);
expect(authMethods[0].type).toBe('local');
vi.doUnmock('fs');
});
The shape matches the proxyquire version after all: set up the fake, then bring in the module under test with a dynamic import. vi.resetModules clears the module cache so config is evaluated fresh and binds to the mocked fs, rather than to a copy it may have already pulled in earlier through the service import at the top of the file. The closing vi.doUnmock('fs') stops the fake from bleeding into the next test.
Two things that will bite you next
First, mock the specifier the source actually imports. If config/index.ts reads import { readFileSync, existsSync } from 'node:fs', then mocking 'fs' does nothing and you land right back on an undefined. The node: prefix and the bare name are different module ids to the resolver, so match whatever the file on the left side of the import actually wrote.
Second, if you want one fake shared across the whole file rather than per test, reach for vi.hoisted instead. It defines variables that are hoisted alongside vi.mock, so the factory can reference them without the “cannot reference variables outside” error. For a single conditional read like this one, vi.doMock inside the test is the smaller and clearer choice.
The full Vitest conversion lives in git-proxy#1202. The short version: when a mock seems to do nothing, check whether it ran when you think it ran. With Vitest, the honest answer is usually “earlier than that.”