TypeScript Testing
Practices and tooling for plain TypeScript/JavaScript tests - libraries, Node CLIs and tooling,
framework-free web code, and the browser-extension unit layer. This skill sets NO coverage
percentage - the % bar is the user's, owned and recorded by the project-test-coverage-analyzer
capture; what lives here is how to write tests worth counting and which code coverage cannot
meaningfully claim.
Plain-JavaScript projects (.js/.mjs, with or without JSDoc/checkJs) share everything here -
the published-type-surface section is the only TS-only part; a checked-JS project keeps
tsc --noEmit in CI the same way. Framework surfaces have their own hubs: Angular (and Ionic)
suites are angular-testing's, .NET is dotnet-testing's. Browser extensions share everything here for their chrome-free logic; the
extension-specific seams - the mocked chrome.* API and Playwright persistent-context E2E - are
the browser-extension skill's ground.
Runner routing
Use whichever the workspace already runs - package.json names it. The default-runner rule is
the javascript skill's: Vitest for a new plain-TS/JS suite (ESM-first, Jest-compatible
API), Jest only where the project already signals it (existing config/deps, a monorepo sibling
on Jest), node:test the zero-dependency floor for small libraries. Detect, never install or
migrate a runner inside a task; a migration is its own user-approved change.
Test strategy by role
- Pure modules - the bulk of a well-factored TS codebase: plain input/output specs,
table-driven (
test.each) when the interesting part is the input matrix. No mocks - a 'pure'
module that needs one is a design smell to surface, not to mock around.
- Boundary seams (HTTP, storage, clock, chrome.*) - inject the dependency and stub the seam
you OWN (a fetch wrapper, a storage port, a clock), not the global it wraps.
vi.mock/jest.mock whole-module mocking is the last resort, not the default: it is hoisted,
couples specs to import paths, and survives refactors worst. msw belongs to workspaces already
carrying it - it exercises the real request pipeline; do not add it for one spec.
- DOM-adjacent code - jsdom/happy-dom covers DOM structure and synchronous events; assert
through user-facing queries (Testing Library where present) rather than implementation
internals. Be honest about the boundary: no layout, no navigation, no real focus or scroll -
a behavior only a browser proves moves to a Playwright E2E (or, interactively, the MCP that
drives a real browser, when one is registered), never into deeper jsdom mocking.
- Node-runtime code (fs, env, processes) - real temp dirs (
fs.mkdtemp) beat fs mocks;
memfs where the workspace already uses it. vi.stubEnv (restored per test) over raw
process.env writes; child-process work goes behind an injected exec seam like any boundary.
- Published type surface -
expectTypeOf/tsd assertions only for types that ARE the
product (a library's public generics, a message-contract union); app-internal types are
already tested by the compiler, and tsc --noEmit in CI is part of the suite.
Timing and async
Fake timers (vi.useFakeTimers + advanceTimersByTime) for debounce/retry/backoff logic;
always await the promise under test - a floating promise passes vacuously and lands its
failure in the wrong spec. Never a raw setTimeout wait in a spec; a spec that passes only
with an arbitrary sleep is a bug in the spec.
The mock-masking trap
Module mocks bypass real wiring, so a broken entry point ships with a green suite - the same
failure class as Angular's TestBed masking. Keep one smoke spec that imports the REAL public
entry (the package barrel or the composition root), builds the object graph unmocked, and
exercises one end-to-end call - a broken export map, circular import, or mis-wired factory then
fails a spec, not the consumer:
// smoke.spec.ts - the one spec with no vi.mock anywhere: real modules, real wiring;
// only the network boundary is injected through the seam the app already exposes.
import { createApp } from '../src';
test('the real wiring boots and answers', async () => {
const app = createApp({ fetchJson: async () => ({ ok: true }) });
await expect(app.healthCheck()).resolves.toEqual({ ok: true });
});
Coverage
- The % bar is the USER's, owned and recorded by the
project-test-coverage-analyzer capture -
this skill sets no number.
- What this skill owns is the mechanics: coverage is computed after exclusions so the number
reflects real logic coverage, not padding - the catalog below is that list for plain TS/JS.
Standard exclusions
- Config files (
*.config.ts/.js/.mjs - vitest/vite/eslint/prettier), environment and
bootstrap stubs, bin-entry shims that only call main()
- Type-only code:
.d.ts files, modules holding only types/interfaces/constants (the compiler
tests those)
- Barrel files (
index.ts re-exports), generated code and build output (dist/, generated API
clients)
- Extensions: the manifest (generated or not) and toolkit-generated wiring - covered by the
extension E2E, not by line coverage
Suite quality
Every spec asserts observable behavior - return values, thrown errors, emitted events, written
files, HTTP traffic; no assertion-free or coverage-padding specs, no expect(true). When
reviewing an existing suite (or running mutation testing), load this skill's own
references/suite-audit.md - the false-confidence catalog, the assertion-depth and mock-usage
passes, and StrykerJS mutation testing. The catalog:
assertion-free / always-true, coverage-touching, tautological, missing-await,
swallowed-exception, disabled assertions.
1---2name: ts-js-testing3description: Plain TypeScript/JavaScript testing hub - practices and tooling only, no coverage numbers (the % bar is user-set via project-test-coverage-analyzer): runner routing (Vitest the house default, Jest where the workspace signals it, node:test the zero-dependency floor - detect, never install), a test strategy keyed off role (pure module / boundary seam / DOM-adjacent / Node-runtime / published types), fake timers vs real async, the mock-masking smoke spec, and the TS/JS exclusion catalog. Covers libraries, Node CLIs/tooling, framework-free web code, and the browser-extension unit layer (the chrome.* seam and extension E2E live in browser-extension). Load before writing, modifying, or reviewing TS/JS tests outside a framework harness, auditing suite quality, running mutation testing, or configuring coverage - do not rely on recall. Do NOT load for Angular/Ionic (angular-testing) or .NET (dotnet-testing).4---56# TypeScript Testing78Practices and tooling for plain TypeScript/JavaScript tests - libraries, Node CLIs and tooling,9framework-free web code, and the browser-extension unit layer. This skill sets NO coverage10percentage - the % bar is the user's, owned and recorded by the `project-test-coverage-analyzer`11capture; what lives here is how to write tests worth counting and which code coverage cannot12meaningfully claim.1314Plain-JavaScript projects (`.js`/`.mjs`, with or without JSDoc/checkJs) share everything here -15the published-type-surface section is the only TS-only part; a checked-JS project keeps16`tsc --noEmit` in CI the same way. Framework surfaces have their own hubs: Angular (and Ionic)17suites are `angular-testing`'s, .NET is `dotnet-testing`'s. Browser extensions share everything here for their chrome-free logic; the18extension-specific seams - the mocked `chrome.*` API and Playwright persistent-context E2E - are19the `browser-extension` skill's ground.2021## Runner routing2223Use whichever the workspace already runs - `package.json` names it. The default-runner rule is24the `javascript` skill's: **Vitest** for a new plain-TS/JS suite (ESM-first, Jest-compatible25API), Jest only where the project already signals it (existing config/deps, a monorepo sibling26on Jest), `node:test` the zero-dependency floor for small libraries. Detect, never install or27migrate a runner inside a task; a migration is its own user-approved change.2829## Test strategy by role3031- **Pure modules** - the bulk of a well-factored TS codebase: plain input/output specs,32 table-driven (`test.each`) when the interesting part is the input matrix. No mocks - a 'pure'33 module that needs one is a design smell to surface, not to mock around.34- **Boundary seams (HTTP, storage, clock, chrome.*)** - inject the dependency and stub the seam35 you OWN (a fetch wrapper, a storage port, a clock), not the global it wraps.36 `vi.mock`/`jest.mock` whole-module mocking is the last resort, not the default: it is hoisted,37 couples specs to import paths, and survives refactors worst. msw belongs to workspaces already38 carrying it - it exercises the real request pipeline; do not add it for one spec.39- **DOM-adjacent code** - jsdom/happy-dom covers DOM structure and synchronous events; assert40 through user-facing queries (Testing Library where present) rather than implementation41 internals. Be honest about the boundary: no layout, no navigation, no real focus or scroll -42 a behavior only a browser proves moves to a Playwright E2E (or, interactively, the MCP that43 drives a real browser, when one is registered), never into deeper jsdom mocking.44- **Node-runtime code (fs, env, processes)** - real temp dirs (`fs.mkdtemp`) beat fs mocks;45 `memfs` where the workspace already uses it. `vi.stubEnv` (restored per test) over raw46 `process.env` writes; child-process work goes behind an injected exec seam like any boundary.47- **Published type surface** - `expectTypeOf`/`tsd` assertions only for types that ARE the48 product (a library's public generics, a message-contract union); app-internal types are49 already tested by the compiler, and `tsc --noEmit` in CI is part of the suite.5051## Timing and async5253Fake timers (`vi.useFakeTimers` + `advanceTimersByTime`) for debounce/retry/backoff logic;54always `await` the promise under test - a floating promise passes vacuously and lands its55failure in the wrong spec. Never a raw `setTimeout` wait in a spec; a spec that passes only56with an arbitrary sleep is a bug in the spec.5758## The mock-masking trap5960Module mocks bypass real wiring, so a broken entry point ships with a green suite - the same61failure class as Angular's TestBed masking. Keep one smoke spec that imports the REAL public62entry (the package barrel or the composition root), builds the object graph unmocked, and63exercises one end-to-end call - a broken export map, circular import, or mis-wired factory then64fails a spec, not the consumer:6566```ts67// smoke.spec.ts - the one spec with no vi.mock anywhere: real modules, real wiring;68// only the network boundary is injected through the seam the app already exposes.69import { createApp } from '../src';70test('the real wiring boots and answers', async () => {71 const app = createApp({ fetchJson: async () => ({ ok: true }) });72 await expect(app.healthCheck()).resolves.toEqual({ ok: true });73});74```7576## Coverage7778- The % bar is the USER's, owned and recorded by the `project-test-coverage-analyzer` capture -79 this skill sets no number.80- What this skill owns is the mechanics: coverage is computed after exclusions so the number81 reflects real logic coverage, not padding - the catalog below is that list for plain TS/JS.8283## Standard exclusions8485- Config files (`*.config.ts`/`.js`/`.mjs` - vitest/vite/eslint/prettier), environment and86 bootstrap stubs, bin-entry shims that only call `main()`87- Type-only code: `.d.ts` files, modules holding only types/interfaces/constants (the compiler88 tests those)89- Barrel files (`index.ts` re-exports), generated code and build output (`dist/`, generated API90 clients)91- Extensions: the manifest (generated or not) and toolkit-generated wiring - covered by the92 extension E2E, not by line coverage9394## Suite quality9596Every spec asserts observable behavior - return values, thrown errors, emitted events, written97files, HTTP traffic; no assertion-free or coverage-padding specs, no `expect(true)`. When98reviewing an existing suite (or running mutation testing), load this skill's own99`references/suite-audit.md` - the false-confidence catalog, the assertion-depth and mock-usage100passes, and StrykerJS mutation testing. The catalog:101assertion-free / always-true, coverage-touching, tautological, missing-await,102swallowed-exception, disabled assertions.