Testland
Browse all skills & agents

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-tests
View source

js-unit-tests

Overview

The two mainstream JS/TS unit frameworks share one Jest-shaped API and split by build tool:

  • Jest (jestjs.io/docs/getting-started (opens in new window)) - Meta-built, batteries-included: 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.
  • Vitest (vitest.dev/guide (opens in new window)) - Vite-native: "Vitest reads your 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).

Choosing a framework

  1. Match the existing convention first. A repo with 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.
  2. New code in a Vite project (Vue, Svelte, Solid, Astro, modern React with Vite) → Vitest: it reuses the already-configured Vite transform pipeline where Jest needs separate babel-jest / ts-jest setup.
  3. Otherwise (bundler-free Node service, React Native, CRA legacy) → Jest: the most ecosystem-supported choice.
  4. Legacy runners: maintaining a Mocha codebase → references/mocha.md; maintaining or migrating a Jasmine / Karma codebase → references/legacy-migration.md.

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 globals

babel-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 vitest

If 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 --coverage

Jest'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 }
  • Jest --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.
  • JUnit XML (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.

Authoring conventions

When authoring a new unit test in an existing project:

  1. Detect the framework, never assume. 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.
  2. Match the module system. "type": "module" or .mjs → ESM import; otherwise CommonJS require. TS source + tsconfig → emit .test.ts.
  3. Follow the placement convention. Existing __tests__/ dir → __tests__/<name>.test.<ext>; otherwise co-locate next to the source.
  4. One spec → one new test file; never modify existing test methods and never fabricate exports the target module does not declare.
  5. Assert the spec's concrete outcome - no expect(true).toBe(true) smoke asserts.
  6. Pair with present dev-deps only: @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.
  7. await the call under test in async tests - an async test body with no await resolves before the rejection surfaces and passes silently.

Anti-patterns

Anti-patternWhy it failsFix
--watchAll / bare vitest in CIWatch mode hangs the runner foreverjest --ci / vitest run (Step 6)
Snapshot-only assertionsPass on every change without semantic verificationTargeted expect() for invariants; snapshots for stable shape only
Default worker count in CIJest default = all cores; can OOM hosted runnersPin --maxWorkers=2 (Step 6)
babel-jest without tsc --noEmitType errors silently bypass testsSeparate type-check step in CI (Step 1)
globals: true in Vitest configGlobal injection; harder to typeExplicit import { test, expect } from 'vitest' (Step 3)
jest.mock leaking across testsModule mock persists; brittle orderingjest.doMock per-test or manual __mocks__/ (Step 4)
In-source Vitest tests for non-trivial logicHard to grep; mixed with prod codeSeparate *.test.ts files; in-source only for tiny utilities

Limitations

  • Jest ESM support has rough edges; many projects keep CommonJS for tests. Vitest is ESM-native but CommonJS-only projects need migration or Jest.
  • Snapshot formats differ slightly between Jest and Vitest; migrating snapshots needs care.
  • Vitest browser mode is newer; some matchers behave differently in browser vs jsdom environments.
  • Jest module hoisting (jest.mock at top of file) has subtle ordering semantics.

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) or v8."

ProviderProsCons
babelMature; Istanbul ecosystem; rich ignore comments.Slower (instruments via Babel transform); may differ from production semantics.
v8Faster (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:

ReporterOutputUse for
lcovcoverage/lcov.info + HTML in coverage/lcov-report/SaaS upload, cross-tool diffing.
coberturacoverage/cobertura-coverage.xmlJenkins, Azure DevOps, GitLab pipelines.
clovercoverage/clover.xmlAtlassian Bamboo (legacy).
jsoncoverage/coverage-final.jsonProgrammatic post-processing (below).
json-summarycoverage/coverage-summary.jsonQuick whole-repo number for dashboards.
text-summaryTerminal output (compact)CI log readability.
textTerminal output (per-file)Local dev.
htmlcoverage/lcov-report/index.htmlHuman 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: collectCoverageFromcoverage.include; coverageReporterscoverage.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-patternWhy it failsFix
collectCoverage: false in CINo coverage data emitted; downstream gate is empty.--coverage flag in the test command.
Skipping collectCoverageFromUntested files absent from denominator; coverage inflated.Always set explicitly.
coverageThreshold.global onlyA new module joins at 0%; global drops 0.3pp; gate passes.Per-path rules for critical modules.
Mixing babel and v8 ignore commentsOne provider misses the ignore; coverage drops mysteriously.Pick one; grep-replace if switching.
coverage-final.json as the gate inputPer-statement detail is huge; gate scripts slow.coverage-summary.json for whole-repo + lcov.info for per-line drilldown.
coverageDirectory outside the repoCI artifact upload misses it.Keep in coverage/ (default).
100% global thresholdFirst refactor fails the build; team disables coverage entirely.Globals at the maintainable floor, not the aspirational ceiling.

Limitations

  • Source-map fidelity affects the V8 provider. Files through multiple transforms (Babel + TS + bundler) may show wrong paths or partial coverage; switch to babel if so.
  • Coverage doesn't equal correctness. A 100%-covered if (x) {} (empty body) measures as covered but tests nothing.
  • Async branches are tricky. The resolved path may show covered while the rejection arm is missed unless tests explicitly throw.
  • Multi-project (projects: [...]) coverage is per-project. Aggregate via each project's --coverageDirectory + a combiner script.

References

  • jest-config (opens in new window) - collectCoverage, coverageProvider, coverageReporters, coverageThreshold, collectCoverageFrom.
  • lcov-analysis (qa-test-reporting) - the LCOV file Jest emits feeds this parser for cross-tool diffing; also the home for Cobertura-consuming pipelines.
  • coverage-diff-reporter (qa-test-reporting) - PR-comment formatter built on the parsed Jest output.
  • test-coverage-targeter (qa-test-reporting) - picks which uncovered branches to target, given the Jest output.

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-codemods

Point it at the spec directory and review the diff - it rewrites Jasmine spy/matcher calls into their Jest equivalents.

Manual steps for the remainder

  1. Replace spyOn().and.returnValue() with jest.spyOn().mockReturnValue().
  2. Replace jasmine.createSpy() with jest.fn().
  3. Replace jasmine.createSpyObj() with manual jest.fn() per method.
  4. Replace expect().toBeNan() with expect(Number.isNaN(...)).toBe(true) (matcher renamed).
  5. Add jest.config.js with an appropriate testMatch for the existing spec layout (Jasmine's convention is spec/**/*[sS]pec.js).
  6. Drop Karma if used - Jest provides its own jsdom environment, so the browser launcher layer is no longer needed.

After migration, follow SKILL.md for Jest configuration, mocking, coverage, and CI.

References

  • github.com/skovhus/jest-codemods - automated migration codemods
  • jestjs.io/docs/getting-started - Jest setup for the migrated suite

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:

  • Pluggable: assertions (Chai / Node assert), mocking (Sinon), coverage (nyc / c8) are separate libraries - pick what you need.
  • Two interfaces: BDD (describe/it, default) and TDD (suite/test).
  • Reporter ecosystem: spec, json, html, tap, dot, mocha-junit-reporter.
  • Parallel mode (Mocha 8+): --parallel flag for multi-process runs.

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-patternWhy it failsFix
Forget return / await on async test bodyRejection silently passes the testAlways return or await
Commit .only accidentallyCI runs only one testmocha/no-exclusive-tests lint rule
--parallel with a shared root beforeHooks run inconsistently per-processRoot hook plugins / global fixtures
Mix BDD + TDD interfacesReader confusionPick one in .mocharc.json ui:
Skip check-coverage in nycCoverage gates not enforcedEnable + set thresholds

Limitations

  • No bundled assertions / mocking / coverage - more setup vs Jest/Vitest.
  • Watch mode less polished than Vitest's; snapshots need third-party mocha-chai-jest-snapshot.
  • ESM support workable but historically rough; pin a recent Mocha version.

References

  • mocha (opens in new window) - official site; mochajs.org/api - API reference
  • chaijs.com - Chai assertions; sinonjs.org - Sinon mocking
  • istanbul.js.org / github.com/bcoe/c8 - coverage tools
  • github.com/michaelleeallen/mocha-junit-reporter - JUnit XML reporter