SuperAPI AI Directory

server-mocha-to-vitest

Testing & quality

View repository

Updated 10-17 10:30

About this skill

Migrate a @packages/server unit spec from mocha to vitest

Scope: packages/server/test/unit only. Integration and performance suites stay on mocha.

This skill lists the things that go wrong silently: a converted spec that is green while its mocks never engaged, or that asserts less than the mocha original did. Plain API mapping (sinon.stub to vi.fn, context to describe) is not covered; you already know it. Parent issue: cypress-io/cypress#34846.

The contract

  • *_spec.ts runs on mocha via test/scripts/run.js. *.spec.ts runs on vitest via packages/server/vitest.config.ts. yarn test-unit runs both.
  • Convert by git mv foo_spec.ts foo.spec.ts and rewriting the file. The mocha file must be gone when you finish. Never leave a file half-converted: every it in the original is ported, or the file is not touched at all.
  • The new file imports from vitest explicitly and uses nothing else for test plumbing: no spec_helper, mockery, proxyquire, sinon, chai or its plugins, snap-shot-it, mocked-env. Keep nock, supertest, express, lodash, bluebird where the original used them for real work.
  • Never weaken a test: no .skip, no dropped or loosened assertion, no vitest -u to make a snapshot agree. If a test cannot be made green, leave it failing and say why in the PR.
  • Do not touch .circleci/, vitest.config.ts, or cloud/environment_spec.ts. The last one is named in four CI workflows and migrates in its own human PR.
  • Specs never connect to a non-loopback address. Tests must pass on a sandboxed host.

spec_helper.js is not ported. Inline what the file needs.

test/spec_helper.js installs root hooks for the whole mocha process. A mocha spec depends on them even when it never imports spec_helper, because some other spec in the same run did. Under vitest each file is on its own, so read spec_helper.js and inline only the hooks this file actually relies on:

Original behavior Inline as
setCtx(makeDataContext({})) before, getCtx()._reset() + clearCtx() after only if the SUT calls getCtx(); see the GraphQL note below before using makeDataContext
nock.disableNetConnect() + nock.enableNetConnect(/localhost/) before, nock.cleanAll() + nock.enableNetConnect() after any spec that uses nock, even when the original had no hooks (cloud/api/create_instance.spec.ts)
cache.remove() before each specs touching lib/cache
GracefulExit.resetForTesting() specs that start servers; it is a silent no-op unless globalThis.IS_TEST = true is set first (server-base.spec.ts)
process.env cloned and restored after each specs that set env; vi.stubEnv + vi.unstubAllEnvs() also works
mockElectron(mockery) vi.mock('electron', ...) only if the SUT's import graph reaches electron; most do not
sinon.restore() after each vi.restoreAllMocks(), with the caveat below

A spec with no hooks is fine. automation/commands/key_press.spec.ts needs none.

Mocks that load the real module without telling you

Prove each mock intercepts. Add a temporary throw or call count to the factory, run the spec, then remove it. A green run alone proves nothing.

vi.mock specifiers must be the string the importer wrote, resolved from your spec's location for relative paths. vi.mock('../../../../lib/cloud/network/fetch') from the spec matches the SUT's import ... from '../network/fetch' because both resolve to the same file. A path that resolves elsewhere is ignored and the real module loads.

A bare require() inside the SUT is never intercepted by vi.mock, with or without server.deps.inline. vite-node hands transformed modules a plain node require, and vitest's mocker only hooks ESM imports. Seed the CJS cache instead, which is what mockery did:

const requireCjs = createRequire(import.meta.url)
const morganPath = requireCjs.resolve('morgan')

requireCjs.cache[morganPath] = { exports: morganMockFactory } as NodeModule
// afterAll / afterEach: delete requireCjs.cache[morganPath]

The exports value is exactly what the SUT's require() returns, so a callable module.exports = function package (morgan, the @cypress/* preprocessors) is mocked with the function itself. Examples: plugins/child/run_plugins.spec.ts, server-base.spec.ts.

Partial mocks of node builtins must override default too. A SUT that does import fs from 'fs' reads fs.createReadStream off the default export. Spread the actual module into both the named exports and a rebuilt default, or the real function runs:

vi.mock('fs', async (importOriginal) => {
  const actual = await importOriginal<typeof import('fs')>()

  return { ...actual, createReadStream: stub, default: { ...actual, createReadStream: stub } }
})

Example: cloud/api/put_protocol_artifact.spec.ts.

A two-hop proxyquire chain usually collapses to one vi.mock of the leaf. The second hop existed because proxyquire cannot reach a transitive dependency. vi.mock applies to the whole module graph, so mock chrome-remote-interface once with vi.hoisted state and import the SUT statically. Only reach for vi.resetModules() plus a dynamic import when the SUT holds module-level mutable state that mocha reset by re-requiring. Example: browsers/cri-client.spec.ts.

vi.spyOn on an ESM namespace import does work for lib/*.ts modules, because vite's SSR transform rewrites named imports to live property reads. vi.spyOn(ensureUrl, 'isListening') patches what server-base.ts sees. It keeps the real implementation unless you add .mockImplementation, which sinon.stub did not.

Assertions that flip meaning

chai deep.equal is toStrictEqual, not toEqual. toEqual ignores keys whose value is undefined, so an expected { envUrl: undefined } also matches an object with no envUrl key, which chai rejects (cloud/environment.spec.ts).

sinon calledWith is a prefix match. vitest toHaveBeenCalledWith is exact-arity. client.send('Network.enable', opts, sessionId) satisfies calledWith('Network.enable') but fails toHaveBeenCalledWith('Network.enable'). Worse, not.toHaveBeenCalledWith('Fetch.disable') passes vacuously against a 3-argument call. Either assert the full argument list or filter mock.calls by leading arguments. Examples: expectCalledWith in browsers/cri-client.spec.ts, callsFor in server-base.spec.ts.

chai deep.equal / eql maps to toStrictEqual, not toEqual. toEqual ignores keys whose value is undefined, which chai does not. toStrictEqual is stricter than chai mainly about prototypes: Node's header objects have a null prototype, so spread them into a plain object before asserting (request.spec.ts).

sinon.stub().withArgs(x) returns the filtered stub. When the original assigned that return value, the "filter" never gated anything and the stub answered every call. Porting it as an argument-conditional mock returns undefined for the real calls. Read what the mocha stub actually did before translating it.

vi.restoreAllMocks() wipes vi.fn().mockReturnValue(...). sinon.restore() left .returns() behavior on plain stubs alone. Shared fixtures need vi.fn(() => value) so the implementation survives restore.

Bluebird. Server code still chains .return(), .spread(), .tap() on values it awaits. Where the SUT chains Bluebird methods on a mocked return, mockResolvedValue hands back a native promise and the SUT throws a confusing not a function. Return Bluebird.resolve(...) from that mock. Check the current SUT first; many call sites are plain await now and native promises are fine (ensureUrl.isListening in server-base.ts is one).

The transpiler is visible in stringified functions. A test that asserts on fn.toString() of a handler defined in the spec reads esbuild's output under vitest, not ts-node's. Keep the assertion exact and update the literal; do not normalize it away.

Decomposed Unicode literals get normalized by editors. 'é' as e + U+0301 becomes U+00E9 on save. Byte-compare string literals against git show HEAD:<mocha file> when a spec contains combining characters (automation/commands/key_press.spec.ts).

GraphQL, nock, timers

Importing lib/server-base, lib/routes, or lib/makeDataContext builds the GraphQL schema a second time in the worker and graphql rejects the duplicate realm. Mock @packages/data-context/graphql/makeGraphQLServer with graphqlWS and graphQLHTTP stubs. Mocking that alone is not enough if you also call makeDataContext; use a minimal DataContext object carrying only the members the SUT touches (routes.spec.ts).

nock delayConnection with one large advanceTimersByTimeAsync fires the delayed connection before the request timeout and consumes the wrong interceptor. Use vi.runAllTimersAsync() in rounds or step the clock in small increments. Also note that chaining delayConnection(5000) then delayConnection(0) on the same interceptor mutates one object, so both replies get delay 0; port that verbatim rather than "fixing" it.

Fake timers: vi.useFakeTimers() / vi.useRealTimers() in hooks. clock.tickAsync(n) becomes await vi.advanceTimersByTimeAsync(n). A non-async helper that the original awaited must stay non-async, or you add a microtask and change what mid-flight assertions observe.

Source edits for testability

Allowed only when obvious on sight: an optional parameter with a default that preserves behavior, exporting a function that already exists, extracting an inline expression into a named pure function in the same file. Not allowed: changing control flow, changing a signature callers depend on, touching more than one lib/ file per batch, or anything you cannot justify in one sentence. Every source edit gets its own heading in the PR body. When in doubt, skip the spec; it stays in the pool.

Verify

yarn workspace @packages/server test-unit-vitest -- test/unit/<dir>/foo.spec.ts
yarn workspace @packages/server test-unit          # both runners, from a real terminal
yarn lint --scope @packages/server
yarn workspace @packages/server check-ts

Run the converted spec at least twice. Run test-unit from a terminal, not with stdout piped to a file: util/tty_spec needs process.stdout.getWindowSize, which only exists on a TTY, and fails on develop too when piped. Count it( in the mocha original and in the vitest file; they must match (loops that generate tests expand at runtime, so compare the static count).

Style

Single quotes, no semicolons, 2-space indent, trailing commas, blank line before return, import type for type-only imports, no console. Prefer no comment; when one is needed, say why in one or two lines and describe the present code, never how it used to work.