A unit test that should pass is failing, and the failure message is the exact error the test was trying to assert. The test feeds a config file containing broken JSON, expects the loader to throw, and instead the whole run dies with this from Vitest:
SyntaxError: Expected property name or '}' in JSON at position 2 (line 1 column 3)
and for the empty-file case:
SyntaxError: Unexpected end of JSON input
If you have ever written expect(() => something()).toThrow() and watched Vitest report that the function did not throw while also printing the very error you expected, this is the same trap. The short version: toThrow in that form only catches synchronous throws, and the call under test was asynchronous.
The code we were covering
GitProxy validates its configuration at start-up and refuses to boot on a broken config. That behavior came out of an issue where an invalid regex in commitConfig crashed pushes at runtime and the fix that moved the check to load time. Part of that path is reading the user’s config file and parsing it. The relevant piece of loadFullConfiguration in src/config/index.ts looks like this:
if (existsSync(userConfigFile)) {
try {
const userConfigContent = readFileSync(userConfigFile, 'utf-8');
const rawUserConfig = JSON.parse(userConfigContent);
userSettings = cleanUndefinedValues(rawUserConfig);
} catch (error) {
console.error(`Error loading user config from ${userConfigFile}:`, error);
throw error;
}
}
JSON.parse throws a SyntaxError on malformed input, and this catch logs it and re-throws so a bad file fails loudly instead of silently loading nothing. We wanted a test that locks in both halves of that behavior: it throws, and it logs first.
The test that let the error escape
Here is the version that failed:
it('should throw error when user config file contains invalid JSON', async () => {
fs.writeFileSync(tempUserFile, '{ invalid json }');
const config = await import('../src/config');
config.invalidateCache();
expect(() => {
config.reloadConfiguration(); // routes through loadFullConfiguration
}).toThrow();
expect(consoleErrorSpy).toHaveBeenCalledWith(
`Error loading user config from ${tempUserFile}:`,
expect.any(Error),
);
});
It reads correctly at a glance, which is what makes it sticky. The problem is that reloadConfiguration is async. When you pass a function to expect(...).toThrow(), Vitest calls it inside a try/catch and checks whether that call threw synchronously. An async function never does: it returns a promise straight away, and the SyntaxError raised inside it becomes a rejected promise rather than a synchronous throw. So toThrow sees a clean return and the assertion fails, while the rejection goes unhandled and Vitest surfaces it as the raw SyntaxError you saw in the output. The Vitest docs say this directly: you wrap the code in a function so the error can be caught, and that wrapping does not apply to async calls, where rejects is what unwraps the promise.
The empty-file test fails for the same reason. JSON.parse('') throws Unexpected end of JSON input, and it leaks out through the same async gap.
The fix: test the function that actually throws
loadFullConfiguration is synchronous (it returns GitProxyConfig, not a promise) and it is the unit that does the parsing and the re-throw, so point the test straight at it. Capturing the error in a try/catch instead of using toThrow has a bonus: you can assert the thrown error and the console.error call in the same test, which is the whole reason for covering this branch.
it('should throw and log when the user config file contains invalid JSON', async () => {
fs.writeFileSync(tempUserFile, '{ invalid json }');
const config = await import('../src/config');
config.invalidateCache();
let thrownError;
try {
config.loadFullConfiguration();
} catch (error) {
thrownError = error;
}
expect(thrownError).toBeInstanceOf(SyntaxError);
expect(consoleErrorSpy).toHaveBeenCalledWith(
`Error loading user config from ${tempUserFile}:`,
expect.any(SyntaxError),
);
});
If the only thing you can call is genuinely async, the right tool is the promise form, await expect(somethingAsync()).rejects.toThrow(), which unwraps the rejection before matching. One caveat worth knowing: that only works if the async function actually lets the error reach the caller. If a wrapper catches the error internally and, say, emits it on an event emitter instead of re-throwing, even .rejects has nothing to match against. That is another reason to test the synchronous unit directly here; there is no wrapper in the way to swallow or defer the throw.
The takeaway
expect(fn).toThrow() is synchronous only. The moment the error you care about crosses an async boundary, that form quietly stops working: the assertion fails with “did not throw”, and the real error leaks out as an unhandled rejection that reads like a confusing test crash instead of a clean assertion failure. When the thing under test is async, reach for await expect(p).rejects.toThrow(). Better still, aim the test at the synchronous function that actually throws, so the error has nowhere to hide and you can check the logged message right next to it. The configuration loader this covers lives in the git-proxy repo if you want to follow the full path.