After bumping Vitest from 3 to 4 in GitProxy to clear a security advisory, the test suite passed cleanly on my machine, then came back red in CI with 19 failures. The commit was the same in both places, which makes this the confusing kind of failure: green on my laptop, broken in CI, with nothing in the diff to point at. If your Vitest tests pass locally but fail in CI right after a major version bump, this walks through why that happens and how to fix each failure it produces.
The bump itself was a single line in package.json, plus the lockfile update. Running npm test locally was clean; in CI, npm ci followed by the same test command produced failures spread across mocking, test signatures, and ports. When the only thing that differs between two runs is the machine, the problem is rarely in your code. It is in the environment, and specifically in what each environment had installed.
Why it was green locally: stale node_modules
The starting point is a detail that is easy to miss: bumping a version in package.json and the lockfile does not change what is already installed in node_modules. My local node_modules still held Vitest 3, so npm test was quietly running the old binary, which still accepts everything version 4 removed. CI starts from npm ci, which deletes node_modules and reinstalls strictly from the lockfile, so it received a clean Vitest 4 and every breaking change landed at once.
There was one tell that the lockfile itself was already on version 4: npm ci refuses to run when package.json and the lockfile disagree. It ran far enough to execute the tests, so the lockfile was current and only my installed copy had fallen behind. Reproducing the CI result locally takes a single command:
rm -rf node_modules && npm ci && npm test
With a fresh install, your machine sees exactly what CI sees, and you can fix the failures directly instead of guessing at them.
The dropped it() signature
The first failure showed up in a few files at once:
Signature "test(name, fn, { ... })" was deprecated in Vitest 3 and removed in Vitest 4.
Vitest 4 removed the call signature where an options object is passed as the third argument. The options object now goes second, before the test function, so the fix is a straightforward swap:
// removed in v4
it('throws on a bad repo URL', async () => { /* ... */ }, { timeout: 20000 });
// v4
it('throws on a bad repo URL', { timeout: 20000 }, async () => { /* ... */ });
A plain numeric third argument, as in it(name, fn, 20000), still works. It is specifically the { ... } object form that moved.
Mocks that broke: “is not a constructor” and mockReturnValue
The largest group of failures came from mocking, and it produced two different messages with the same underlying cause:
TypeError: () => mockClient is not a constructor
Cannot use mockReturnValue when called with new. Use mockImplementation with a class keyword instead.
In Vitest 4, a mock invoked with new constructs an instance rather than simply running the mock function. GitProxy calls new PluginLoader(plugins) and new MongoClient(...), so those mocks are now treated as real constructors. That breaks two common patterns. An arrow function cannot be used as a constructor at all, which is the () => mock is not a constructor error, and mockReturnValue describes a plain call rather than a new one, which is the second message.
The fix is to give the mock an actual constructor, meaning a function or a class, never an arrow. A regular function that returns an object works here, because calling new on a function that returns an object yields that object:
// both of these break in v4:
PluginLoader: vi.fn().mockImplementation(() => mockLoader); // arrow
vi.mocked(PluginLoader).mockReturnValue(mockLoader); // mockReturnValue + new
// this works:
vi.mocked(PluginLoader).mockImplementation(function () {
return mockLoader;
});
One thing to watch while applying this: only change the mocks your code actually constructs. http.createServer(...) is called as a plain function, never with new, so its mockReturnValue is still correct and should be left alone. The new behavior only affects your new calls, and converting the rest adds churn without fixing anything.
EADDRINUSE on a shared port, and the teardown bug behind it
Two integration test files each started the service on a hardcoded port 8080, and CI runs test files in parallel, so they collided:
listen EADDRINUSE: address already in use :::8080
The useful fix here is not to assign a second fixed port, but to stop depending on a fixed port at all. These tests drive the application through supertest, which starts its own ephemeral server for each request, so the port the service binds to is never actually contacted. Letting start() take a port and passing 0 in the tests asks the OS for any free port:
async function start(proxy, port = config.getUIPort()) {
_httpServer.listen(port); // production still defaults to the config port
}
// in the test:
await Service.start(proxy, 0);
With an OS-assigned port, two files cannot collide no matter how many CI workers run them at the same time.
Clearing the port collision revealed a quieter failure that had been hidden underneath it:
Error: Server is not running.
That message is Node’s ERR_SERVER_NOT_RUNNING, and there is only one way to produce it: calling server.close() on a server that is not currently listening. In a test suite, that points to a teardown problem: an afterAll closing a server that a given code path never started. Guarding the close so it only runs against a listening server fixes it:
if (server.listening) {
await new Promise((res, rej) => server.close((e) => (e ? rej(e) : res())));
}
Apply that guard to every close() in stop(), and a partially started suite stops throwing on the way out. After these changes, the suite ran clean in CI.
The takeaway
Two points are worth carrying to the next upgrade. When tests pass locally but fail in CI on an identical commit, look at the gap between your lockfile and your installed node_modules before suspecting your own code; rm -rf node_modules && npm ci settles that question in one step. And a major test-runner upgrade is rarely a single change. It is usually a set of small breaking changes that surface as different error messages, and the fastest way through is to take each literal error string in turn and fix them one at a time. With those done, the git-proxy suite is back to green on Vitest 4 and the advisory is cleared.