Migrating GitProxy's tests from Mocha and Chai to Vitest

How to fix all the ESM mocking errors when moving GitProxy's test suite from Mocha, Chai, Sinon and proxyquire to Vitest + TypeScript

Moving a Node and TypeScript test suite off Mocha and Chai and onto Vitest sounds like a config swap and a find-and-replace. For the assertions, it mostly is. The part that actually ate the time was module mocking under ESM, and it kept announcing itself with the same line: Cannot spy on export "spawnSync". Module namespace is not configurable in ESM. If you are partway through your own migration and staring at that error, or at No "default" export is defined on the ... mock, or at tests that pass quietly while obviously running the real code, this is the page I wish I’d had open in another tab.

GitProxy (the FINOS git-proxy project) had a test suite written in JavaScript on ts-mocha, with Chai for assertions, Sinon for stubs and spies, proxyquire for swapping out dependencies, and nyc for coverage. The goal was to get all of that onto Vitest, with the test files themselves rewritten in TypeScript. What follows is each distinct failure I hit, why it happened, and the fix that worked, ending with what the suite looks like now that the work is merged.

What was being replaced, and why bother

GitProxy sits in front of a git remote and runs a chain of checks on every push, so the tests are mostly small unit tests around each processor in that chain, plus a few heavier integration tests that stand up the proxy itself. The old setup needed four tools to do its job: ts-mocha to run TypeScript through Mocha, Chai for the expect style assertions, Sinon for stubbing functions and spying on calls, and proxyquire to intercept require() so a processor could be tested against fake versions of its dependencies. Coverage came from nyc on top.

Vitest collapses most of that into one tool. It runs TypeScript natively through esbuild, so there is no separate ts-mocha layer to keep happy. Its assertions are Jest-style, which most people already have in their fingers. It ships its own mocking (vi.mock, vi.fn, vi.spyOn) and its own coverage, so Sinon, proxyquire and nyc all fall away. The watch mode is fast enough that you actually leave it running. That is the upside, and it is real. The catch is that Vitest runs your code as ES modules, and ESM has rules about what you are allowed to reach in and change at runtime. Almost every problem below comes back to that one fact.

”Cannot spy on export … Module namespace is not configurable in ESM”

This was the first wall and the one that shows up most. A processor like checkHiddenCommits calls spawnSync from child_process and readdirSync from fs. Under Sinon you would stub those directly. The naive port keeps that shape with vi.spyOn:

import * as childProcess from 'child_process';
import * as fs from 'fs';

beforeEach(() => {
  spawnSyncSpy = vi.spyOn(childProcess, 'spawnSync');
  readdirSyncSpy = vi.spyOn(fs, 'readdirSync');
});

Every test then fails before it even runs:

TypeError: Cannot spy on export "spawnSync". Module namespace is not configurable in ESM. See: https://vitest.dev/guide/browser/#limitations
 ❯ test/checkHiddenCommit.test.ts:14:23
Caused by: TypeError: Cannot redefine property: spawnSync

The “Caused by” line is the real explanation. vi.spyOn works by replacing a property on an object, the same way Sinon did. With CommonJS that object is the module’s mutable exports, so swapping a property is allowed. With ESM the thing you import is a module namespace object, and its bindings are read-only and non-configurable by spec. You cannot redefine spawnSync on it, so the spy cannot be installed. The fs.existsSync and fs.readFileSync versions of this error are the same problem wearing a different function name.

The fix is to stop spying on individual exports and mock the whole module instead. vi.mock replaces the entire module before anything imports it, which sidesteps the immutable-namespace rule because the code under test never sees the real module at all. The one subtlety is timing: vi.mock calls are hoisted to the very top of the file, above your imports, so any mock function you reference inside the factory has to exist before those imports run. That is what vi.hoisted is for. It runs its callback early, in the same hoisted phase, so the functions are ready when the factory needs them.

import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
import { exec as checkHidden } from '../src/proxy/processors/push-action/checkHiddenCommits';
import { Action } from '../src/proxy/actions';

// build the mock fns first; vi.hoisted runs before the vi.mock factories below
const mockSpawnSync = vi.hoisted(() => vi.fn());
const mockReaddirSync = vi.hoisted(() => vi.fn());

vi.mock('child_process', () => ({ spawnSync: mockSpawnSync }));
vi.mock('fs', () => ({ readdirSync: mockReaddirSync }));

beforeEach(() => {
  vi.clearAllMocks();
  // configure mockSpawnSync / mockReaddirSync per test here
});

Now each test drives behaviour with mockSpawnSync.mockReturnValueOnce(...) and asserts on the calls, and the immutable-namespace error is gone because nothing is being redefined. This pattern, top-level vi.mock plus vi.hoisted for the functions, became the default move for any file that touched a Node builtin.

Why vi.doMock in a beforeEach silently does nothing

The second failure was sneakier because there was no error at all. Coming from proxyquire, the obvious translation is to mock inside beforeEach so each test gets a fresh setup, and vi.doMock looks like the tool for that. Here is roughly what the first gitleaks test looked like:

beforeEach(async () => {
  vi.doMock('../../../config', () => ({ getAPIs: stubs.getAPIs }));
  vi.doMock('node:fs/promises', () => stubs.fs);
  vi.doMock('node:child_process', () => ({ spawn: stubs.spawn }));

  const mod = await import('../../src/proxy/processors/push-action/gitleaks');
  exec = mod.exec;
});

It ran, but most of the assertions failed, because the test was exercising the real config, the real fs/promises, and the real spawn rather than the stubs. The difference between vi.mock and vi.doMock is the whole story. vi.mock is hoisted and applies to the entire file before any import resolves. vi.doMock is deliberately not hoisted; it only affects imports that happen after the call. proxyquire re-resolved the dependency tree on every call, so mocking right before each require was the correct mental model there. With Vitest, if the module graph has already been pulled in (or if the dynamic import resolves against an already-evaluated module), your doMock arrives too late and the real code loads.

The reliable fix is to move the mocks to the top level with vi.mock, then pull references to the mocked functions inside beforeEach so you can configure them per test:

vi.mock('../../../config', () => ({ getAPIs: vi.fn() }));
vi.mock('node:fs/promises', () => ({
  default: { stat: vi.fn(), access: vi.fn(), constants: { R_OK: 0 } },
  stat: vi.fn(),
  access: vi.fn(),
  constants: { R_OK: 0 },
}));
vi.mock('node:child_process', () => ({ spawn: vi.fn() }));

beforeEach(async () => {
  vi.clearAllMocks();
  const { getAPIs } = await import('../../../config');
  const fsPromises = await import('node:fs/promises');
  const { spawn } = await import('node:child_process');

  vi.mocked(getAPIs).mockReturnValue({ gitleaks: { enabled: true } });
  // configure fsPromises and spawn per test...

  const mod = await import('../../src/proxy/processors/push-action/gitleaks');
  exec = mod.exec;
});

One detail in that fs/promises mock is easy to miss and worth pointing out: it exports the functions both as named exports and under default. Whether your source does import fs from 'node:fs/promises' or import { stat } from 'node:fs/promises', the mock has to cover both shapes, or one import style ends up holding undefined. Mirroring named and default exports in the factory saved a separate round of confusing failures.

”mockResolvedValue is not a function”

Once the modules were mocked at the top level, a new error appeared on the files that captured a mock into a variable:

TypeError: getUsersMock.mockResolvedValue is not a function
 ❯ test/processors/checkUserPushPermission.test.ts:52:20

The code that produced it looked perfectly reasonable:

vi.mock('../../../db', () => ({
  getUsers: vi.fn(),
  isUserPushAllowed: vi.fn(),
}));

import { getUsers, isUserPushAllowed } from '../../../db';
const getUsersMock = getUsers as Mock;

This is the hoisting rule biting from the other side. Because vi.mock is lifted above the imports, the factory runs first and creates a set of vi.fn() instances. The import line and the const getUsersMock = getUsers as Mock assignment run afterwards. Depending on how the binding resolves, getUsersMock can end up pointing at something that is not the same mock function the code under test actually calls, which is why the .mockResolvedValue method is missing on it. The assertion is reaching for a mock API on a value that is not the mock.

The clean fix is to create the functions with vi.hoisted and reference those exact instances both inside the factory and in your tests, so there is only ever one of each:

const { getUsersMock, isUserPushAllowedMock } = vi.hoisted(() => ({
  getUsersMock: vi.fn(),
  isUserPushAllowedMock: vi.fn(),
}));

vi.mock('../../../db', () => ({
  getUsers: getUsersMock,
  isUserPushAllowed: isUserPushAllowedMock,
}));

Now getUsersMock.mockResolvedValue([...]) works, and more importantly it configures the same function the processor invokes. The general rule I took from this: if you need a handle to a mocked export in your test body, define it with vi.hoisted and wire that handle into the vi.mock factory. Do not import the mocked name and hope the binding lines up.

”No ‘default’ export is defined on the mock”

The proxy module imports the push/pull action chain and, during setup, assigns the loaded plugins onto it with chain.chainPluginLoader = pluginLoader. The first attempt to mock that chain was minimal:

vi.mock('../src/proxy/chain', () => ({ chainPluginLoader: null }));

Every test then failed with a message that, to Vitest’s credit, tells you exactly what to do:

Error: [vitest] No "default" export is defined on the "../src/proxy/chain" mock. Did you forget to return it from "vi.mock"?
 ❯ Proxy.proxyPreparations src/proxy/index.ts:49:5

The cause is that chain.ts does not export a flat object. It exports a default object built from getters and setters, including a chainPluginLoader setter that writes to a module-level variable. The real code path goes through that default export, so chain.chainPluginLoader = pluginLoader is really calling the setter on default. A mock that only returns chainPluginLoader: null has no default at all, so the assignment throws.

There are two ways out. The first is to reproduce the shape the code expects, default export and all, using a closure variable to back the getter and setter:

vi.mock('../src/proxy/chain', () => {
  let loader: any = null;
  return {
    executeChain: vi.fn(),
    getChain: vi.fn().mockResolvedValue([]),
    default: {
      set chainPluginLoader(l: any) { loader = l; },
      get chainPluginLoader() { return loader; },
      get pluginsInserted() { return false; },
      get pushActionChain() { return []; },
      get pullActionChain() { return []; },
      get defaultActionChain() { return []; },
      executeChain: vi.fn(),
      getChain: vi.fn().mockResolvedValue([]),
    },
  };
});

The second, which Vitest itself suggests in the error, is a partial mock with importOriginal, keeping the real module and overriding only the pieces you care about:

vi.mock(import('../src/proxy/chain'), async (importOriginal) => {
  const actual = await importOriginal();
  return { ...actual, getChain: vi.fn().mockResolvedValue([]) };
});

Which one to use depends on how much of the real module you want in the test. For a module that is mostly state plumbing, like this chain, faking the structure outright was simpler and made the test’s intent clearer. For a module where you only need to neutralise one function, importOriginal is less to maintain.

”Cannot find module ‘@finos/git-proxy/src/plugin’”

A different class of failure showed up around the plugin loader tests. The plugin fixtures are small CommonJS files that pull in GitProxy’s own source with require('@finos/git-proxy/src/plugin'), and under ts-mocha that just worked:

Cannot find module '@finos/git-proxy/src/plugin'

ts-mocha installed TypeScript handling globally enough that a CommonJS require could resolve and compile a .ts file from source on the fly. Vitest does its own module resolution and does not extend that courtesy to external CommonJS files reaching into your TypeScript source. The fixtures were asking for a .ts path through a require that Vitest would not satisfy.

Two fixes hold up. The straightforward one is to build the package before tests run and have the fixtures import from the compiled output rather than source:

{
  "scripts": {
    "pretest": "tsc",
    "test": "vitest"
  }
}

The other is to teach Vitest the path with an alias, which works when Vitest is the one doing the importing:

// vitest.config.ts
import { defineConfig } from 'vitest/config';
import { resolve } from 'path';

export default defineConfig({
  resolve: {
    alias: {
      '@finos/git-proxy/src/plugin': resolve(__dirname, './src/plugin.ts'),
    },
  },
});

The alias has a limit worth knowing: it only helps for imports that go through Vitest’s resolver. If a plain CommonJS fixture does the require itself, the alias may not be consulted, and building first is the more dependable answer. The honest tradeoff is that pointing fixtures at compiled output makes them slightly less representative of how plugins load in production, but it is stable, which matters more for a test fixture.

”listen EADDRINUSE: address already in use”

The hardest part was not any single processor; it was the integration-style tests that stand up the proxy. Two things stacked up. First, http and https are ESM-only here, so the same immutable-namespace rule applies and you cannot vi.spyOn their createServer; those tests mock the modules at the top level and have createServer return a fake server whose listen and close are vi.fn() that invoke their callback. That part is tractable. The real trouble was isolation across files. Several proxy tests actually call proxy.start() and proxy.stop(), which bind and release a port, and when cleanup between files was not airtight the port stayed claimed:

Error: listen EADDRINUSE: address already in use :::8000

The symptom was order-dependent in the worst way. The tests in testPush.test.ts would fail in CI specifically when some of the proxy tests ran before them, and locally they would fail on the very first run and then pass on every run after, which points at a caching or cleanup gap rather than a logic bug. proxy.test.ts, testProxy.test.ts and testProxyRoute.test.ts were all involved, with a race where one set or the other failed depending on execution order.

I will be straight about how this was resolved, because pretending it was fully solved would not help anyone. Tightening proxy.stop() in teardown removed most of it. For the residual race I did not have a clean root-cause fix in hand, so rather than leave the CI red I skipped the worst offenders with a describe.skip and a comment explaining exactly why, plus a tracking issue so it does not get quietly forgotten:

/*
  jescalada: these tests currently cause
  Error: listen EADDRINUSE: address already in use :::8000
  when run in CI or on the first local run. Likely improper test isolation
  or cleanup in another file around proxy.start() / proxy.stop().
  Related: a race with skipped tests in testProxyRoute.test.ts, where one set
  or the other fails depending on order.
  TODO: find the root cause and fix it. https://github.com/finos/git-proxy/issues/1294
*/
describe.skip('Proxy Module TLS Certificate Loading', () => {
  // ...
});

A skipped test with a written reason and an issue number is a known gap. A flaky test left running is a slow leak of everyone’s trust in the suite, so given the choice I would rather mark it and move on. If you hit EADDRINUSE in your own migration, look first at any test that binds a real port, make sure every start has a matching stop in an afterEach or afterAll, and remember that the file that fails is often the victim of a different file that did not clean up.

The boring but necessary part: translating assertions and stubs

With the mocking understood, the rest is mechanical, and it is most of the line count. Chai’s chained assertions map onto Vitest’s directly once you learn the handful you actually use. expect(x).to.equal(y) becomes expect(x).toBe(y). to.have.lengthOf(n) becomes toHaveLength(n). to.include(s) becomes toContain(s). to.be.true and to.be.false become toBe(true) and toBe(false). to.deep.equal(obj) becomes toEqual(obj). to.be.an('object') becomes toBeTypeOf('object'), and to.have.property('k') becomes toHaveProperty('k').

Sinon translates just as cleanly into the vi API. sinon.stub() is vi.fn(). A stub’s .resolves(v) and .rejects(e) become .mockResolvedValue(v) and .mockRejectedValue(e). Chained call setups like .onFirstCall().returns(a).onSecondCall().returns(b) flatten into successive .mockReturnValueOnce(a).mockReturnValueOnce(b). sinon.restore() becomes vi.restoreAllMocks() in afterEach. For verifying calls, stub.calledWith(args) becomes expect(mock).toHaveBeenCalledWith(args), and reaching into stub.lastCall.args[0] is better written as expect(mock).toHaveBeenLastCalledWith(expected).

A few non-obvious ones came up enough to list. Mocha’s this.timeout(10000) has no equivalent context object in Vitest, so the timeout moves to a third argument on the test: it('slow thing', async () => { ... }, 10000). Error assertions that were written as try/catch with a manual fail are far shorter as await expect(fn()).rejects.toThrow(/pattern/), and the regex form is handy when you only want to match part of the message. And property-based tests that used fast-check switch to the @fast-check/vitest entry point, wrapping cases in fc.assert(fc.asyncProperty(...)). None of this is hard, but there is a lot of it, so budget time for the volume rather than the difficulty.

When mocking is more trouble than a real fixture

One file pushed back on every mocking approach, and the lesson there was to stop fighting. getDiff uses simple-git, whose default export is a callable that returns a chainable object, and mocking that faithfully is fiddly and brittle. Rather than mock it, the test spins up a real throwaway git repository in beforeAll, runs the actual diff against it, and deletes it in afterAll:

import path from 'path';
import simpleGit, { SimpleGit } from 'simple-git';
import fs from 'fs/promises';
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { exec } from '../../src/proxy/processors/push-action/getDiff';

describe('getDiff', () => {
  let tempDir: string;
  let git: SimpleGit;

  beforeAll(async () => {
    tempDir = path.join(__dirname, 'temp-test-repo');
    await fs.mkdir(tempDir, { recursive: true });
    git = simpleGit(tempDir);
    await git.init();
    await git.addConfig('user.name', 'test');
    await git.addConfig('user.email', 'test@test.com');
    await fs.writeFile(path.join(tempDir, 'test.txt'), 'initial content');
    await git.add('.');
    await git.commit('initial commit');
  });

  afterAll(async () => {
    await fs.rm(tempDir, { recursive: true, force: true });
  });

  // tests run the real diff against the real repo
});

This runs a bit slower than a mock would, but it tests the real interaction with simple-git instead of a guess at its internals, and it is far less likely to break the next time the library changes shape. A real fixture is not a defeat. For modules that are hostile to mocking, it is frequently the better test, and the one you will not have to keep nursing.

What the suite looks like now

The migration is merged. The test files are TypeScript, the runner is Vitest, and everything executes as ES modules without a separate compile step in the way. The biggest practical change is the shorter dependency list: the assertions no longer go through Chai, the stubs and spies no longer go through Sinon, and module substitution no longer goes through proxyquire, because Vitest covers all three with expect, vi.fn/vi.spyOn, and vi.mock. Coverage moved off nyc onto Vitest’s built-in coverage. The diff for this dropped Chai straight out of devDependencies, and the rest of the Mocha-era stack went with it.

I will not dress up the numbers. Coverage came down a little, to 80.5%, partly because a few proxy tests are skipped for the isolation reasons above and partly because some untested plugin code is still counted; clearing out that dead code should bring it back up. A handful of tests carry a describe.skip with a written explanation and a tracking issue rather than a green checkmark they did not earn. That is the honest state, and I would rather ship that than a suite that looks perfect and flakes in CI. The full set of changes, including the reviewer back-and-forth that shaped a lot of these decisions, is in the merged Vitest migration PR on the FINOS git-proxy repo.

The single lesson that would have saved me the most time: nearly all of the pain was ESM module mocking, and the fix is almost always the same shape. Stop spying on individual exports, mock the whole module with vi.mock at the top of the file, and build the mock functions with vi.hoisted so they exist before the hoisted factory runs. When a module fights every mock you throw at it, reach for a real fixture instead. The assertion rewrites are tedious but they are just volume; the mocking is where the actual thinking goes.