js-unit-tests
Sets up and repairs JavaScript / TypeScript unit tests - a suite that cannot resolve the project's path aliases (`@lib/...` declared in jsconfig or tsconfig), a regression that shipped because the tests asserted too little, or a module dependency that needs mocking, spying, or fake timers. 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). Use when authoring, configuring, or repairing unit tests in a JS/TS project.
Install with skills.sh (any agent)
npx skills add testland/qa --skill js-unit-testsjs-unit-tests
Overview
The two mainstream JS/TS unit frameworks share one Jest-shaped API and split by build tool:
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).
Choosing a framework
Step 1 - Install
Jest, per jest-start (opens in new window):
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 (opens in new window):
npm install -D vitestIf the project already has Vite + a vite.config.*, no extra config is needed.
Step 2 - First test
// 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"
}
}Step 3 - Configuration
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/**'],
},
},
})Step 4 - Mocking
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 (opens in new window)).
Step 5 - Coverage
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.
Step 6 - Watch mode and CI
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 }Authoring conventions
When authoring a new unit test in an existing project:
Anti-patterns
| 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 |
Limitations
References
Deep Jest / Vitest coverage analysis
View source (opens in new window)Deep Jest / Vitest coverage analysis
Companion reference for js-unit-tests Step 5. Consult when the team needs PR-time coverage signal that is both local-runnable and CI-gateable: provider choice, reporter selection for downstream consumers, per-file coverageThreshold rules, and parsing the per-file JSON output.
Pick the provider
Per jest-config (opens in new window), on coverageProvider:
"Indicates which provider should be used to instrument code for coverage. Allowed values are
babel(default) orv8."
| Provider | Pros | Cons |
|---|---|---|
babel | Mature; Istanbul ecosystem; rich ignore comments. | Slower (instruments via Babel transform); may differ from production semantics. |
v8 | Faster (uses V8's native coverage); closer to runtime truth. | Source-map edge cases; some files may show partial coverage where Babel is clean. |
/** @type {import('jest').Config} */
module.exports = {
coverageProvider: 'v8', // or 'babel'
};Each provider has a different ignore-comment syntax (jest-config (opens in new window)): babel uses /* istanbul ignore next */, v8 uses /* c8 ignore next */. Don't mix; switching providers requires updating ignore comments across the codebase.
Choose coverageReporters
Per jest-config (opens in new window), "Any istanbul reporter can be used." Defaults are ["clover", "json", "lcov", "text"]. The useful ones:
| Reporter | Output | Use for |
|---|---|---|
lcov | coverage/lcov.info + HTML in coverage/lcov-report/ | SaaS upload, cross-tool diffing. |
cobertura | coverage/cobertura-coverage.xml | Jenkins, Azure DevOps, GitLab pipelines. |
clover | coverage/clover.xml | Atlassian Bamboo (legacy). |
json | coverage/coverage-final.json | Programmatic post-processing (below). |
json-summary | coverage/coverage-summary.json | Quick whole-repo number for dashboards. |
text-summary | Terminal output (compact) | CI log readability. |
text | Terminal output (per-file) | Local dev. |
html | coverage/lcov-report/index.html | Human review (per-file drill-down). |
Pragmatic default for a CI + SaaS + local-dev setup:
coverageReporters: ['lcov', 'json', 'text-summary', 'html']Per-file thresholds (the gate-correctness pattern)
Per jest-config (opens in new window), coverageThreshold accepts global, glob, or path-specific rules:
coverageThreshold: {
global: { branches: 50, functions: 50, lines: 50, statements: 50 },
'./src/components/': { branches: 40, statements: 40 },
'./src/reducers/**/*.js': { statements: 90 },
'./src/api/very-important-module.js': {
branches: 100, functions: 100, lines: 100, statements: 100,
},
},The pattern is lower the global, raise the critical paths. A 50% global keeps refactors flowing; a 100% per-file rule on a payment-processing module catches any drop immediately.
"Jest will fail if thresholds aren't met." (jest-config (opens in new window))
"Negative numbers = maximum uncovered entities allowed."
The negative-number form suits legacy modules: statements: -10 allows up to 10 uncovered statements, letting the team ratchet down over time without an aspirational percentage.
Verify the gate fires: run npx jest --coverage with a critical-path file left below its threshold and confirm Jest exits non-zero. If it exits 0, check that collectCoverageFrom includes the file and the coverageThreshold path key matches, then re-run.
Scope collectCoverageFrom
Per jest-config (opens in new window): "An array of glob patterns indicating which files should have coverage collected, even if they have no tests."
collectCoverageFrom: [
'src/**/*.{js,jsx,ts,tsx}',
'!src/**/*.d.ts',
'!src/**/*.stories.{js,ts,tsx}',
'!src/index.js',
],Without this, coverage only counts files a test imported - files with no test at all disappear from the report and coverage looks artificially high. Always set it for an honest denominator.
Parse the JSON output
The json reporter writes coverage/coverage-final.json, keyed by absolute path:
{
"/abs/path/src/checkout/cart.ts": {
"statementMap": { "0": { "start": {}, "end": {} } },
"s": { "0": 42, "1": 42, "2": 0 },
"f": { "0": 42, "1": 0 },
"b": { "0": [42, 0] }
}
}s = per-statement hit counts; f = per-function; b = per-branch arm.
// scripts/parse_jest_coverage.js
import { readFileSync } from 'node:fs';
const data = JSON.parse(readFileSync('coverage/coverage-final.json', 'utf8'));
for (const [absPath, file] of Object.entries(data)) {
const stmts = Object.values(file.s);
const stmtPct = (stmts.filter(c => c > 0).length / stmts.length) * 100;
const fns = Object.values(file.f);
const fnPct = (fns.filter(c => c > 0).length / fns.length) * 100;
// Branch coverage: each entry is an array of arm hit counts.
const branchEntries = Object.values(file.b);
const branchTotal = branchEntries.flat().length;
const branchHit = branchEntries.flat().filter(c => c > 0).length;
const brPct = branchTotal === 0 ? 100 : (branchHit / branchTotal) * 100;
console.log({ path: absPath, stmtPct, fnPct, brPct });
}coverage-summary.json (from the json-summary reporter) is the pre-aggregated version when per-statement detail isn't needed.
Vitest equivalent
Vitest uses the same Istanbul / V8 stack with vitest --coverage:
// vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
coverage: {
provider: 'v8',
reporter: ['text-summary', 'lcov', 'json', 'html'],
include: ['src/**/*.{ts,tsx}'],
thresholds: {
global: { branches: 50, functions: 50, lines: 50, statements: 50 },
'src/api/**/*.ts': { branches: 100, functions: 100, lines: 100, statements: 100 },
},
},
},
});Key naming differences vs Jest: collectCoverageFrom → coverage.include; coverageReporters → coverage.reporter; coverageThreshold → coverage.thresholds. Output formats and PR-gating logic are identical.
CI shape
- name: Run tests with coverage
run: npx jest --coverage --coverageReporters=lcov,json,text-summary
- name: Show summary in CI log
run: cat coverage/coverage-summary.json
- name: Upload to dashboard
if: always()
uses: codecov/codecov-action@v4
with:
files: coverage/lcov.info
token: ${{ secrets.CODECOV_TOKEN }}--coverage activates collectCoverage: true; --coverageReporters overrides config-side reporter selection.
Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
collectCoverage: false in CI | No coverage data emitted; downstream gate is empty. | --coverage flag in the test command. |
Skipping collectCoverageFrom | Untested files absent from denominator; coverage inflated. | Always set explicitly. |
coverageThreshold.global only | A new module joins at 0%; global drops 0.3pp; gate passes. | Per-path rules for critical modules. |
Mixing babel and v8 ignore comments | One provider misses the ignore; coverage drops mysteriously. | Pick one; grep-replace if switching. |
coverage-final.json as the gate input | Per-statement detail is huge; gate scripts slow. | coverage-summary.json for whole-repo + lcov.info for per-line drilldown. |
coverageDirectory outside the repo | CI artifact upload misses it. | Keep in coverage/ (default). |
| 100% global threshold | First refactor fails the build; team disables coverage entirely. | Globals at the maintainable floor, not the aspirational ceiling. |
Limitations
References
Migrating a legacy Jasmine / Karma suite to Jest
View source (opens in new window)Migrating a legacy Jasmine / Karma suite to Jest
Companion reference for js-unit-tests. Consult when a legacy Jasmine codebase (typically AngularJS-era, often paired with Karma for in-browser runs) should move to Jest. Context: Karma has been in maintenance-only mode since 2023, AngularJS reached end-of-life in January 2022, and new Angular projects use Jest or Vitest - Karma + Jasmine setups are explicitly legacy.
Jest's API descends from Jasmine (describe, it, beforeEach, expect(...).toBe(...), spies all originated there), so migration is mostly mechanical.
Automated path: jest-codemods
The jest-codemods (opens in new window) package handles ~80% of the syntax transformations:
npx jest-codemodsPoint it at the spec directory and review the diff - it rewrites Jasmine spy/matcher calls into their Jest equivalents.
Manual steps for the remainder
After migration, follow SKILL.md for Jest configuration, mocking, coverage, and CI.
References
Mocha - pluggable JS test runner (maintenance reference)
View source (opens in new window)Mocha - pluggable JS test runner (maintenance reference)
Companion reference for js-unit-tests. Consult when maintaining a legacy Mocha codebase, or when a library/tooling project prefers a minimal pluggable runner over Jest/Vitest's batteries-included model. For new projects, prefer Jest or Vitest (see the Choosing section of SKILL.md).
Per mochajs.org (opens in new window):
Mocha is the original mainstream JS test runner. Distinguishing features:
Install and first test
npm install --save-dev mocha
npm install --save-dev chai sinon nyc # typical peers// test/sum.test.js
const { expect } = require('chai');
const { sum } = require('../src/sum');
describe('sum', () => {
it('adds 1 + 2 to equal 3', () => {
expect(sum(1, 2)).to.equal(3);
});
});Wire "test": "mocha" in package.json. Default test glob: ./test/*.{js,cjs,mjs} plus ./test/**/*.spec.js. Node's built-in node:assert works too when zero extra deps matter.
.mocharc.json configuration
Config files: .mocharc.json, .mocharc.js, .mocharc.yaml, or a mocha key in package.json:
{
"spec": ["test/**/*.spec.js"],
"recursive": true,
"require": ["ts-node/register", "./test/setup.js"],
"reporter": "spec",
"timeout": 5000,
"parallel": true,
"jobs": 4,
"ui": "bdd",
"extension": ["js", "ts"]
}Key options: parallel + jobs (multi-process, Mocha 8+); ui: 'bdd' (default) vs 'tdd'; recursive (nested test dirs); require (preload TS support / setup).
Async patterns
Per mocha (opens in new window), three approaches - callback (done), returned promise, and async/await (preferred):
it('async/await', async () => {
const result = await doAsyncWork();
expect(result).to.equal(42);
});The async function MUST return (or await) - otherwise the promise's rejection isn't surfaced to Mocha and tests pass-by-accident.
Hooks, exclusivity, and skipping
before / after (once per describe block) and beforeEach / afterEach (per test) all accept async bodies. it.only / describe.only run exclusively; it.skip / xit skip. Forbid committed .only via eslint-plugin-mocha's mocha/no-exclusive-tests rule.
Coverage with nyc / c8
{
"extends": "@istanbuljs/nyc-config-typescript",
"all": true,
"check-coverage": true,
"branches": 80, "lines": 80, "functions": 80, "statements": 80,
"include": ["src/**/*.{js,ts}"],
"reporter": ["text", "lcov", "html"]
}Run nyc mocha (Istanbul instrumentation), or c8 mocha (Node's built-in V8 coverage; faster, no instrumentation). check-coverage + thresholds make the run fail below the floor.
Parallel mode and root hooks
Per parallel mode (opens in new window) (Mocha 8+): mocha --parallel --jobs 4. Tests must be independent - shared state across describe blocks breaks parallel runs.
Root hooks stop working in parallel mode. "Each test file gets its own instance of Mocha", so a root hook defined in file A "will not be present" in file B (mocha-par (opens in new window)). The serial-era pattern - --file ./test/setup.js installing a top-level before - does not carry over. Two supported replacements (root hook plugins (opens in new window)):
// test/hooks.js - loaded with `mocha --require test/hooks.js`
export const mochaHooks = {
beforeEach() { /* runs in every worker, before every test */ },
};
// once per run, not per worker ([global fixtures][mocha-gf]):
export const mochaGlobalSetup = async () => { /* seed */ };
export const mochaGlobalTeardown = async () => { /* tear down */ };CI integration
- run: npm ci
- run: npx mocha --reporter mocha-junit-reporter --reporter-option mochaFile=./test-results/junit.xml
# Or with coverage:
- run: npx c8 --reporter lcov mocha
- uses: codecov/codecov-action@v4
with: { files: ./coverage/lcov.info }mocha-junit-reporter emits JUnit XML for junit-xml-analysis (qa-test-reporting).
Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
Forget return / await on async test body | Rejection silently passes the test | Always return or await |
Commit .only accidentally | CI runs only one test | mocha/no-exclusive-tests lint rule |
--parallel with a shared root before | Hooks run inconsistently per-process | Root hook plugins / global fixtures |
| Mix BDD + TDD interfaces | Reader confusion | Pick one in .mocharc.json ui: |
Skip check-coverage in nyc | Coverage gates not enforced | Enable + set thresholds |