Jest Testing skill for Codex

Mocks that reset, timers that advance, Jest that exits. Codex sets up and repairs Jest suites with the details that usually cost an afternoon: which mock-reset flags to enable, why a factory mock returns undefined, when to use the async timer APIs, how to run native ESM, and what changed in Jest 30. It fixes open handles at the source instead of reaching for --forceExit.

  • Testing & QA
  • $jest-testing
  • 218 lines
  • 2 files
  • ~2,050 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- jest-testing

Installs to ~/.agents/skills/jest-testing/ for every project on this machine.

104-line POSIX script. No sudo, no telemetry. Read install.sh

Use it in Codex

Start a prompt with $jest-testing, or pick it from /skills. You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.

What Codex reads before loading it

Configures and writes Jest 29/30 test suites - jest.config, transforms (babel-jest, ts-jest, @swc/jest), module mocks and spies, fake timers, ESM with --experimental-vm-modules and jest.unstable_mockModule, coverage thresholds and CI sharding. Use when tests run under Jest, when jest.mock does not take effect, timers or promises hang, "Cannot use import statement outside a module" appears, open handles keep Jest alive, or a Jest 30 upgrade breaks the suite.

Use it when

  • The project already runs its tests on Jest
  • Mocks leak between tests or do not take effect
  • ESM imports fail under Jest
  • Jest does not exit or reports open handles

Use something else when

How to check the result

The suite passes both with --runInBand and in parallel, exits without --forceExit, and a deliberately broken mocked behavior makes a test fail.

The mistake it prevents

Enabling resetMocks globally, which silently wipes implementations defined in jest.mock factories so mocked functions start returning undefined.

~/.agents/skills/jest-testing/SKILL.md

--- name: jest-testing description: Configures and writes Jest 29/30 test suites - jest.config, transforms (babel-jest, ts-jest, @swc/jest), module mocks and spies, fake timers, ESM… license: MIT ---

A healthy Jest suite starts fast, isolates mocks per test automatically, never waits on real timers, and exits on its own without --forceExit.

Inspect first

  1. Read AGENTS.md and package.json scripts: the real test command may add flags (--runInBand, --selectProjects, env vars).
  2. Find the config: jest.config.{js,ts,mjs,cjs} or the "jest" key in package.json. Note testEnvironment, transform, moduleNameMapper, setupFilesAfterEnv, projects, and the mock-reset flags.
  3. Check the installed version: npx jest --version. Jest 30 removed matcher aliases and renamed --testPathPattern to --testPathPatterns.
  4. Check "type": "module" in package.json and tsconfig.json module setting; they decide the ESM path below.
  5. If the project is Vite-based and has no Jest investment yet, recommend Vitest instead ($vitest-testing); do not start a new Jest setup there.

Baseline config

// jest.config.ts (loading a TS config needs ts-node; otherwise use jest.config.mjs)
import type { Config } from "jest";

const config: Config = {
  testEnvironment: "node",            // "jsdom" only for DOM tests; install jest-environment-jsdom
  transform: { "^.+\\.(t|j)sx?$": ["@swc/jest", {}] },
  moduleNameMapper: {
    "^@/(.*)$": "<rootDir>/src/$1",
    "\\.(css|scss)$": "identity-obj-proxy",
  },
  setupFilesAfterEnv: ["<rootDir>/test/setup.ts"],
  restoreMocks: true,                 // restore spies and reset mocks before each test
  clearMocks: true,
  coverageProvider: "v8",
  collectCoverageFrom: ["src/**/*.{ts,tsx}", "!src/**/*.d.ts", "!src/**/index.ts"],
  coverageThreshold: { global: { branches: 75, lines: 80 } },
};
export default config;

Transform choice

NeedUse
Fast TS/JSX, no type checking in tests@swc/jest
Type errors must fail teststs-jest (slower; or run tsc --noEmit separately and keep swc)
Existing Babel config (React Native, legacy)babel-jest (default when a Babel config exists)

Run tsc --noEmit in CI regardless; transforms that strip types do not check them.

Mocking

Module mocks

jest.mock() calls are hoisted above imports by the transform. Factories may only reference variables whose names start with mock.

import { sendEmail } from "../src/mailer";
import { registerUser } from "../src/register";

jest.mock("../src/mailer", () => ({
  sendEmail: jest.fn().mockResolvedValue({ id: "msg_1" }),
}));

it("sends a welcome email after registration", async () => {
  await registerUser({ email: "[email protected]" });

  expect(sendEmail).toHaveBeenCalledTimes(1);
  expect(sendEmail).toHaveBeenCalledWith(expect.objectContaining({ to: "[email protected]" }));
});

Partial mock, keeping the rest real:

jest.mock("../src/config", () => ({
  ...jest.requireActual("../src/config"),
  featureFlags: { newCheckout: true },
}));

Spies

const spy = jest.spyOn(paymentGateway, "charge").mockResolvedValueOnce({ ok: false });

With restoreMocks: true the spy is undone before the next test. Without it, call jest.restoreAllMocks() in afterEach, or a spy from one test leaks into the next.

FlagEffect before each test
clearMockswipes mock.calls and results
resetMocksalso removes implementations (factory mockResolvedValue is lost)
restoreMocksrestores original methods behind jest.spyOn

Gotcha: resetMocks: true erases implementations set inside jest.mock factories, so tests that relied on them suddenly get undefined. Prefer clearMocks + restoreMocks and set per-test behavior with mockResolvedValueOnce.

Typing mocks

import { fetchUser } from "../src/api";
jest.mock("../src/api");
const mockedFetchUser = jest.mocked(fetchUser);
mockedFetchUser.mockResolvedValue({ id: "u1", name: "Ada" });

Fake timers

beforeEach(() => jest.useFakeTimers());
afterEach(() => jest.useRealTimers());

it("retries after 2 seconds", async () => {
  const promise = fetchWithRetry(url);
  await jest.advanceTimersByTimeAsync(2000);   // flushes promises between timer callbacks
  await expect(promise).resolves.toEqual(data);
});

it("stamps the current date", () => {
  jest.setSystemTime(new Date("2026-03-01T12:00:00Z"));
  expect(makeInvoice().issuedAt).toBe("2026-03-01");
});
  • Use the ...Async timer functions whenever the code awaits between timers; the sync versions leave promise callbacks unrun and the test hangs or times out.
  • jest.runAllTimers() loops forever on setInterval; use runOnlyPendingTimers().
  • Libraries that capture setTimeout at import time are not faked; enable fake timers before importing them or inject a scheduler.

ESM

Pick one path and state it in the config comments.

SituationPath
Source is TS/ESM syntax but compiled by a transformKeep CJS output from the transform; nothing special
Dependency in node_modules ships ESM onlyAdd it to transformIgnorePatterns exceptions so it is transformed
Project runs native ESM ("type": "module", no transform)Run Jest with --experimental-vm-modules
// transformIgnorePatterns exception for ESM-only packages
transformIgnorePatterns: ["/node_modules/(?!(nanoid|uuid|@scope/esm-lib)/)"],
{ "scripts": { "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js" } }

In native ESM, jest.mock is not hoisted. Mock first, then import dynamically:

import { jest } from "@jest/globals";

jest.unstable_mockModule("../src/mailer.js", () => ({ sendEmail: jest.fn() }));
const { sendEmail } = await import("../src/mailer.js");
const { registerUser } = await import("../src/register.js");

If ESM pain dominates the work, say so and propose migrating the suite to Vitest rather than stacking workarounds.

Running and debugging

npx jest path/to/file.test.ts -t "sends a welcome email"   # one test
npx jest --testPathPatterns checkout                        # Jest 30 flag name
npx jest --onlyChanged                                      # files touched since last commit
npx jest --findRelatedTests src/cart.ts src/tax.ts          # tests that import these
npx jest --runInBand --detectOpenHandles                     # find what keeps Jest alive
npx jest --ci --shard=2/4 --coverage                         # CI: no snapshot writes, one shard
  • --ci makes missing snapshots fail instead of being written. Always use it in CI.
  • “Jest did not exit one second after the test run” means open handles: close servers, DB pools and timers in afterAll. Do not add --forceExit as the fix.
  • Memory growth across a big suite: try --workerIdleMemoryLimit=512MB and look for module-level caches.

Jest 30 upgrade checklist

  • Node 18+ and TypeScript 5.4+ for type definitions
  • Replace removed matcher aliases (toBeCalled -> toHaveBeenCalled, toThrowError -> toThrow, lastCalledWith -> toHaveBeenLastCalledWith); eslint-plugin-jest can autofix these
  • Rename --testPathPattern to --testPathPatterns in scripts and CI
  • Re-run snapshots with --ci first to see real diffs before updating any
  • Check that newly matched .mts/.cts files are intended tests

Gotchas

  • jest.mock paths are resolved relative to the test file, and must match how the unit imports the module (an alias import and a relative import can resolve to the same file but a mismatch in moduleNameMapper creates two module instances).
  • Mocking a module after it has been imported by another module in the same file has no effect; use jest.isolateModules or reorder.
  • expect(asyncFn()).rejects... without await passes silently. Always await expect(...).
  • testEnvironment: "jsdom" for the whole repo slows pure logic tests; use a per-file docblock /** @jest-environment jsdom */ or projects.

Verification

  1. Run the target file, then the full suite with the project command.
  2. Run the changed tests with --runInBand and again in parallel to expose shared state.
  3. Confirm the process exits without --forceExit and --detectOpenHandles is silent.
  4. For mock changes, temporarily break the mocked behavior and confirm a test fails.

What the zip contains

SKILL.md plus the interface file below. Nothing runs on install.

agents/openai.yamlgenerated from this page

Gives the Codex app a display name, a one-line description and a starter prompt for this skill.

interface:
  display_name: "Jest Testing"
  short_description: "Mocks that reset, timers that advance, Jest that exits"
  default_prompt: "$jest-testing jest.mock isn't replacing my module, the real one still runs"

About this skill

Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.

esc