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.
75
94%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
View guide
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