@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.tsruns on mocha viatest/scripts/run.js.*.spec.tsruns on vitest viapackages/server/vitest.config.ts.yarn test-unitruns both.- Convert by
git mv foo_spec.ts foo.spec.tsand rewriting the file. The mocha file must be gone when you finish. Never leave a file half-converted: everyitin the original is ported, or the file is not touched at all. - The new file imports from
vitestexplicitly and uses nothing else for test plumbing: nospec_helper,mockery,proxyquire,sinon,chaior its plugins,snap-shot-it,mocked-env. Keepnock,supertest,express,lodash,bluebirdwhere the original used them for real work. - Never weaken a test: no
.skip, no dropped or loosened assertion, novitest -uto 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, orcloud/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.