JavaScript/TypeScript unit testing with Jest and Vitest as co-primary frameworks - install, config (`jest.config.js` / `vite.config.ts` test block), mocking (`jest.fn`/`jest.mock`/`jest.spyOn`, `vi.fn`/`vi.mock`/`vi.spyOn`, `__mocks__/`, fake timers), coverage (Istanbul/babel vs v8 providers, `coverageThreshold` gating), watch mode, and CI (`jest --ci`, `vitest run`, JUnit XML). Includes framework choice (Vite project → Vitest, otherwise Jest; always match an existing convention), test-authoring conventions (framework detection from package.json + config files, ESM vs CJS, no fabricated exports), and references for Mocha maintenance, Jasmine/Karma-to-Jest migration via jest-codemods, and deep Jest/Vitest coverage analysis. Use for any JS/TS unit-test task: setting up a framework, writing or mocking tests, gating coverage, or wiring CI.
93
93%
Does it follow best practices?
Impact
—
Average score across 10 eval scenarios
Passed
No findings from the security scan
The two mainstream JS/TS unit frameworks share one Jest-shaped API and split by build tool:
expect matchers, snapshot testing, mocking
(jest.fn / jest.mock / jest.spyOn), and Istanbul coverage in one
package. Home turf: React (CRA / older Next.js), React Native, Node
services.vite.config.* by default, so your existing Vite plugins and
configuration work out-of-the-box." Jest-compatible API (expect,
vi.fn, vi.mock), native ESM, in-source testing, browser mode.This skill targets the per-framework lifecycle (configure / run / mock /
coverage / CI), NOT test code hygiene - for assertion quality, AAA structure,
and mocking anti-patterns see test-code-conventions (qa-test-review).
jest.config.* (or a
"jest" package.json block) stays on Jest; one with vitest.config.* or a
test block in vite.config.* stays on Vitest. Never mix two unit
frameworks in one package.babel-jest / ts-jest setup.Jest, per jest-start:
npm install --save-dev jest
# TypeScript - choose one:
npm install --save-dev ts-jest # full type-checking; slower
npm install --save-dev babel-jest @babel/core @babel/preset-env @babel/preset-typescript
npm install --save-dev @jest/globals # explicit imports instead of globalsbabel-jest does NOT catch type errors - pair it with tsc --noEmit in CI.
Scaffold config with npm init jest@latest.
Vitest, per vt-guide:
npm install -D vitestIf the project already has Vite + a vite.config.*, no extra config is
needed.
// sum.test.js (Jest - globals available by default)
const sum = require('./sum');
test('adds 1 + 2 to equal 3', () => {
expect(sum(1, 2)).toBe(3);
});// sum.test.js (Vitest - explicit imports required)
import { expect, test } from 'vitest'
import { sum } from './sum.js'
test('adds 1 + 2 to equal 3', () => {
expect(sum(1, 2)).toBe(3)
})Wire package.json scripts:
{
"scripts": {
"test": "jest"
}
}or for Vitest - vitest with no subcommand is watch mode; vitest run is
the single pass:
{
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"coverage": "vitest run --coverage"
}
}Jest key settings (jest.config.js; full reference at
jestjs.io/docs/configuration):
module.exports = {
testEnvironment: 'jsdom', // 'jsdom' for browser code; 'node' for backend
testMatch: ['**/__tests__/**/*.[jt]s?(x)', '**/?(*.)+(spec|test).[jt]s?(x)'],
transform: { '^.+\\.(ts|tsx)$': 'ts-jest' },
collectCoverageFrom: ['src/**/*.{js,ts}', '!src/**/*.d.ts'],
coverageThreshold: {
global: { branches: 80, functions: 80, lines: 80, statements: 80 },
},
moduleNameMapper: { '^@/(.*)$': '<rootDir>/src/$1' }, // match tsconfig aliases
};Gotcha: testEnvironment defaults to jsdom in Jest ≤26 but node from
Jest 27+ - always set it explicitly.
Vitest reads vite.config.ts; add a test block via the vitest/config
wrapper (full reference at vitest.dev/config):
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
environment: 'jsdom', // 'jsdom' | 'node' | 'happy-dom' | 'edge-runtime'
globals: false, // prefer explicit imports over global injection
setupFiles: ['./vitest.setup.ts'],
coverage: {
provider: 'v8', // 'v8' (default) | 'istanbul'
reporter: ['text', 'json', 'html', 'lcov'],
thresholds: { lines: 80, functions: 80, branches: 80, statements: 80 },
include: ['src/**'],
},
},
})Same three mock forms in both frameworks - jest.* in Jest, vi.* in
Vitest (vitest.dev/api/vi; jestjs.io/docs/mock-functions):
// Standalone mock function
const myMock = jest.fn(); // Vitest: vi.fn()
myMock.mockReturnValue(42);
// Automatic module mock
jest.mock('./api-client'); // Vitest: vi.mock('./api-client', factory)
import { fetchUser } from './api-client';
fetchUser.mockResolvedValue({ id: 1, name: 'Alice' });
// Wrap an existing method
const spy = jest.spyOn(myObject, 'someMethod') // Vitest: vi.spyOn(...)
.mockImplementation(() => 'mocked');
spy.mockRestore();Jest manual mocks live in __mocks__/ adjacent to the module and are used
automatically when jest.mock('./api-client') runs.
Fake timers (identical shape; fake-clock-testing in qa-time owns selective
faking, DST/timezone cases, and timers combined with mocked fetch):
jest.useFakeTimers(); // Vitest: vi.useFakeTimers()
setTimeout(callback, 1000);
jest.advanceTimersByTime(1000); // Vitest: vi.advanceTimersByTime(1000)
expect(callback).toHaveBeenCalled();
jest.useRealTimers(); // Vitest: vi.useRealTimers()Worked example - a Node service function getUser(id) calls fetchUser
from ./api-client; verify without a live API:
import { getUser } from './user-service';
import { fetchUser } from './api-client';
jest.mock('./api-client');
test('returns the fetched user', async () => {
fetchUser.mockResolvedValue({ id: 1, name: 'Alice' });
await expect(getUser(1)).resolves.toEqual({ id: 1, name: 'Alice' });
expect(fetchUser).toHaveBeenCalledWith(1);
});Vitest-only gotcha: a vi.mock(...) factory is hoisted above imports, so
references to module-scope variables leak as undefined - move state inside
the factory closure or use vi.hoisted() (vt-guide).
Both frameworks use the same Istanbul / V8 provider stack:
npx jest --coverage
npx vitest run --coverageJest's coverageProvider is babel (Istanbul instrumentation, default) or
v8 (native, faster, subtler source-map edge cases); Vitest defaults to
v8. Gate via coverageThreshold (Jest) / coverage.thresholds (Vitest) -
the run fails when a threshold is not met. The pattern that keeps gates
honest: lower the global floor, raise the critical paths per-file, and always
set collectCoverageFrom / coverage.include so untested files count in the
denominator.
Deep coverage work - provider trade-offs, per-file threshold rules, reporter
selection (lcov for SaaS, text-summary for CI logs), parsing
coverage-final.json for PR deltas, and the coverage-gate anti-pattern
catalog - is in references/jest-coverage.md.
Local: jest --watch / bare vitest re-run affected tests on change.
CI must run single-pass:
- run: npm ci
- run: npx jest --ci --coverage --maxWorkers=2 --reporters=default --reporters=jest-junit
# or
- run: npx vitest run --coverage --reporter=verbose --reporter=junit --outputFile=junit.xml
- uses: codecov/codecov-action@v4
with: { files: ./coverage/lcov.info }--ci fails on missing snapshots instead of writing them and
disables interactive prompts (jestjs.io/docs/cli); --maxWorkers=2 suits
2-CPU hosted runners (default = all cores, which can OOM CI).vitest run is required - bare vitest enters watch mode and hangs CI.jest-junit / Vitest's junit reporter) feeds
junit-xml-analysis in qa-test-reporting.vitest run --typecheck runs tsc --noEmit against test files alongside
the run; without it, type errors in tests don't fail CI.When authoring a new unit test in an existing project:
jest in devDependencies OR
jest.config.* OR a "jest" package.json block → Jest; vitest in
devDependencies OR vitest.config.* OR a test block in vite.config.*
→ Vitest; mocha / .mocharc.* → Mocha; jasmine /
spec/support/jasmine.json → Jasmine. If two frameworks' signals
coexist, stop and ask which one to use."type": "module" or .mjs → ESM
import; otherwise CommonJS require. TS source + tsconfig → emit
.test.ts.__tests__/ dir →
__tests__/<name>.test.<ext>; otherwise co-locate next to the source.expect(true).toBe(true)
smoke asserts.@faker-js/faker in deps → use it
for domain-shaped fixtures (faker-data in qa-test-data); msw in deps →
mock HTTP at the network layer via msw-handlers (qa-test-data) instead
of jest.fn()-ing the fetch layer. Never install new packages as a side
effect of writing a test.await the call under test in async tests - an async test body
with no await resolves before the rejection surfaces and passes
silently.| Anti-pattern | Why it fails | Fix |
|---|---|---|
--watchAll / bare vitest in CI | Watch mode hangs the runner forever | jest --ci / vitest run (Step 6) |
| Snapshot-only assertions | Pass on every change without semantic verification | Targeted expect() for invariants; snapshots for stable shape only |
| Default worker count in CI | Jest default = all cores; can OOM hosted runners | Pin --maxWorkers=2 (Step 6) |
babel-jest without tsc --noEmit | Type errors silently bypass tests | Separate type-check step in CI (Step 1) |
globals: true in Vitest config | Global injection; harder to type | Explicit import { test, expect } from 'vitest' (Step 3) |
jest.mock leaking across tests | Module mock persists; brittle ordering | jest.doMock per-test or manual __mocks__/ (Step 4) |
| In-source Vitest tests for non-trivial logic | Hard to grep; mixed with prod code | Separate *.test.ts files; in-source only for tiny utilities |
jest.mock at top of file) has subtle ordering
semantics.--civi.* API referencetest-code-conventions (qa-test-review) - test code hygiene