Background: Why Jasmine Fails Differently at Scale
Zero Dependencies, Many Integrations
Jasmine's core intentionally avoids external dependencies. In enterprise setups, however, it is rarely alone: it's embedded in Angular test harnesses, Protractor or WebDriver flows, Karma and headless browsers, bundlers, and custom reporters. Each layer introduces timing, isolation, and resource lifecycle concerns. The bigger the graph, the harder it is to localize failure.
Symptom vs. Root Cause
Common symptoms include flaky expectations, occasional timeouts, and cross-suite leakage. Root causes usually trace to one of five buckets: async queue mishandling, fake timer inconsistencies, global state bleed, concurrency collisions, or environment skew (timezones, locales, CPU throttling, headless rendering differences). Understanding which bucket you are in is 80% of the fix.
Architecture of Large Jasmine Test Systems
Execution Layers
- Runner: Jasmine CLI or a Karma-based launcher orchestrates suites, applies randomization, and emits results.
- Execution Context: Node.js, headless browser (Chromium), or hybrid environments (Angular TestBed) host the code under test.
- Scheduling: Promises, microtasks, timers, and animation frames interact with Jasmine's lifecycle (
beforeAll
,beforeEach
,afterEach
,afterAll
). - Virtual Time:
jasmine.clock()
or environment fakes (e.g.,zone.js
fakeAsync) control perceived time. - Parallelization: CI sharding runs slices of the suite in multiple workers or containers.
Where the Fault Lines Emerge
Most brittle failures occur at boundaries: Promise vs. callback interop, fake timers vs. real I/O, shared singletons across workers, or test code invoking real time while the suite thinks time is frozen. In Angular stacks, fakeAsync
introduces additional rules about microtasks and timers. In Node.js, mixing process.nextTick
, setImmediate
, and setTimeout
under fakes can surprise even seasoned teams.
Diagnostics: Making Flakiness Reproducible
Turn On Randomization and Capture Seeds
Jasmine supports randomized execution with a reproducible seed. Enabling this on every run is the fastest way to surface order dependencies.
jasmine --random=true --seed=auto # On failure, Jasmine prints the seed. Re-run with: jasmine --random=true --seed=12345
Increase Observability with Custom Reporters
Attach a reporter that records per-spec wall time, memory usage snapshots, and seed metadata. Persist artifacts to CI for failures.
// reporter/perfReporter.js class PerfReporter { jasmineStarted(suiteInfo) { this.start = Date.now(); this.results = []; console.log("[perf] seed=" + (jasmine.getEnv().configuration().seed || "n/a")); } specDone(result) { this.results.push({ description: result.fullName, status: result.status, timeMs: result.duration || (Date.now() - this.start) }); } jasmineDone() { console.log("[perf] top slow specs", JSON.stringify( this.results.sort((a,b)=>b.timeMs-a.timeMs).slice(0,10) )); } } module.exports = PerfReporter;
// jasmine.mjs (Node ESM) import Jasmine from "jasmine"; import PerfReporter from "./reporter/perfReporter.js"; const j = new Jasmine(); j.env.addReporter(new PerfReporter()); j.loadConfigFile(); j.execute();
Capture Event Loop and Timer Traces
Track whether a failing spec uses faked or real timers. Log active timers before and after each spec. Combine with heap snapshots when leaks are suspected.
beforeEach(() => { // naive but useful instrumentation global.__activeTimers = new Set(); const origSetTimeout = setTimeout; global.setTimeout = (fn, ms, ...args) => { const id = origSetTimeout(() => { global.__activeTimers.delete(id); fn(...args); }, ms); global.__activeTimers.add(id); return id; }; }); afterEach(() => { if (global.__activeTimers && global.__activeTimers.size) { fail("Leaked timers: " + global.__activeTimers.size); } });
Determinizing the Environment
Pin timezones, locales, and Intl
features across CI. Force headless Chrome flags and CPU throttling to stable defaults. Disable network and file I/O unless explicitly mocked.
# CI example (Linux) export TZ=UTC export LC_ALL=C export LANG=C node --icu-data-dir=node_modules/full-icu run-tests.js
Pitfalls and Root Causes
1) Mixed Async Styles: done(), async/await, and Promises
Using done
alongside async/await
or returning Promises can double-complete specs or swallow rejections. Jasmine treats a returned Promise as authoritative; mixing styles causes racey completion.
// Bad: mixing async/await and done it("fetches data", async (done) => { const data = await api.get(); expect(data.ok).toBeTrue(); done(); // may fire before assertions reject }); // Good: return the Promise or use async without done it("fetches data", async () => { const data = await api.get(); expect(data.ok).toBeTrue(); });
2) Fake Timers vs. Real I/O
jasmine.clock().install()
replaces timer APIs but not every scheduling primitive. In Node.js, process.nextTick
and setImmediate
remain real. In browsers, requestAnimationFrame
and MessageChannel
microtasks can bypass fakes. If code under test mixes these channels, tests freeze or behave non-deterministically.
// Anti-pattern: freezing time then awaiting real microtasks beforeEach(() => { jasmine.clock().install(); }); afterEach(() => { jasmine.clock().uninstall(); }); it("handles debounce", async () => { debounced(); // This advances setTimeout, but not requestAnimationFrame or nextTick jasmine.clock().tick(200); await Promise.resolve(); // executes real microtask channel expect(fn).toHaveBeenCalled(); // may fail intermittently });
3) Order-Dependent Suites and Hidden State
Singletons, global caches, and module-level mocks often persist across specs. Under randomized execution or parallel shards, assumptions break and tests fail only in CI.
// Hidden global mutation import state from "../state.js"; describe("service A", () => { it("mutates global", () => { state.setMode("debug"); expect(...).toBe(...); }); }); describe("service B", () => { it("assumes default mode", () => { expect(state.getMode()).toBe("default"); // flakes }); });
4) Spy and Resource Leaks
Spies created in beforeAll
or at module load can live for the entire suite, accumulating calls and interfering with later expectations. Heavy mocks of HTTP servers, file descriptors, or browser APIs can leak when afterEach
cleanup is missing.
5) Angular Zone Interactions
When using Jasmine through Angular's TestBed, fakeAsync
and tick
control both macro and microtasks via zone.js
. Mixing fakeAsync
with real async, or forgetting flush
, leads to stuck timers or dangling microtasks that fail only under headless Chrome.
Step-by-Step Fixes
Normalize Async Contracts
Choose one async style per spec. Prefer async/await
with returned Promises. Use done
only for callback-only APIs and ensure exactly one path to completion.
// Callback-only API it("reads file", (done) => { fs.readFile(path, (err, data) => { if (err) return done.fail(err); expect(data.length).toBeGreaterThan(0); done(); }); });
Constrain Virtual Time Usage
Use fake timers only in specs that truly need them. Document which primitives are faked and which remain real. For code that uses requestAnimationFrame
or setImmediate
, provide an abstraction and fake that abstraction instead of the raw primitives.
// time.js export const delay = (ms) => new Promise(r => setTimeout(r, ms)); export const next = () => new Promise(r => setImmediate(r)); // test beforeEach(() => jasmine.clock().install()); afterEach(() => jasmine.clock().uninstall()); it("debounces", async () => { const p = delay(100).then(next); jasmine.clock().tick(100); await p; // advances via our abstraction expect(...).toBe(...); });
Contain Global State
Reset singletons in afterEach
, not afterAll
. Provide a factory that constructs fresh instances for each spec. If using Node's module cache, expose a reset()
to clear static variables.
// state.js let mode = "default"; export const setMode = m => (mode = m); export const getMode = () => mode; export const reset = () => (mode = "default"); // test afterEach(() => require("../state.js").reset());
Lifecycle Hygiene for Spies and Mocks
Create spies in beforeEach
and restore in afterEach
. Never create perpetual spies in beforeAll
unless they are read-only and stateless.
let clockSpy; beforeEach(() => { clockSpy = spyOn(Date, "now").and.returnValue(1690000000000); }); afterEach(() => { clockSpy.and.callThrough(); });
Install Guard Rails
Add automatic fails when a spec leaks timers, intervals, or unresolved Promises. Guard rails surface issues at their source instead of a later, unrelated spec.
afterEach(async () => { // Force microtask queue to drain await Promise.resolve(); if (global.__activeTimers && global.__activeTimers.size) { fail("Spec leaked timers"); } });
Performance and Scale Considerations
Shard by Historical Duration, Not Count
Evenly distributing specs by count yields skewed runtimes. Use a JSON map of spec path to last-known duration and partition shards to equalize wall time.
// tools/shard.js const fs = require("fs"); const timings = JSON.parse(fs.readFileSync("./.jasmine-timings.json")); const specs = process.argv.slice(2); const shards = Number(process.env.SHARDS || 4); specs.sort((a,b)=>(timings[b]||0)-(timings[a]||0)); const bins = Array.from({length: shards}, () => ({t:0, files:[]})); for (const s of specs) { bins.sort((a,b)=>a.t-b.t)[0].files.push(s); bins[0].t += (timings[s]||0); } console.log(JSON.stringify(bins.map(b=>b.files)));
Parallel Execution with Worker Threads
Jasmine itself is single-process. For Node-based suites, orchestrate multiple Jasmine instances via worker_threads
or a process pool, each with isolated environment variables and temp directories.
// run-parallel.mjs import { Worker } from "node:worker_threads"; const shards = JSON.parse(process.env.SPECS_JSON); await Promise.all(shards.map((files, i) => new Promise((res, rej) => { const w = new Worker(new URL("./worker-jasmine.mjs", import.meta.url), { workerData: { files, shard: i } }); w.on("exit", code => code === 0 ? res() : rej(new Error("Shard "+i+" failed"))); })));
// worker-jasmine.mjs import Jasmine from "jasmine"; import { workerData } from "node:worker_threads"; const j = new Jasmine(); j.loadConfig({ spec_files: workerData.files, random: true }); j.execute();
Deterministic Randomness
Record and persist the random seed for each shard. On failures, re-run the single shard locally using the same seed and environment flags.
jasmine --random=true --seed=SEED_FROM_CI --config=jasmine.json
Flaky Test Quarantine Without Ignoring
Do not silently mark flaky specs with xdescribe
or xit
. Instead, move them into a quarantined suite with separate, slower gates and focused telemetry to prevent regressions.
// package.json scripts {"scripts":{ "test":"node jasmine.mjs", "test:quarantine":"JASMINE_CONFIG=jasmine.quarantine.json node jasmine.mjs" }}
Advanced Timing Control
Microtasks vs. Macrotasks
Promises resolve in the microtask queue; timers and I/O callbacks in the macrotask queue. When using fakes, explicitly advance both queues or avoid asserting outcomes that depend on a queue you do not control.
// Helper to flush microtasks deterministically export const flushMicrotasks = () => Promise.resolve(); // Usage await flushMicrotasks(); jasmine.clock().tick(0); // advance just enough for queued macrotasks
requestAnimationFrame and Idle Callbacks
Animation and idle callbacks are not controlled by Jasmine's clock. Abstract them and inject fakes in tests.
// raf.js export const raf = (cb) => requestAnimationFrame(cb); export const idle = (cb) => requestIdleCallback(cb); // test let rafQueue = []; beforeEach(() => { spyOn(window, "requestAnimationFrame").and.callFake(cb => rafQueue.push(cb)); }); it("animates deterministically", () => { startAnimation(); rafQueue.forEach(cb => cb(16)); expect(frameCount()).toBe(1); });
Stabilizing Angular + Jasmine
fakeAsync, tick, and flush
When using Angular's TestBed, prefer fakeAsync
for deterministic control. Always flush()
pending timers before leaving the spec. Avoid mixing fakeAsync
and async
in the same test module.
it("saves form", fakeAsync(() => { component.save(); tick(300); // debounce flush(); // clear pending tasks expect(service.save).toHaveBeenCalled(); }));
Zone Pollution Detection
Attach a global afterEach
that asserts no pending tasks remain in the Zone. This reveals leaks at their origin.
afterEach(inject([NgZone], (zone: NgZone) => { const hasTasks = (zone as any)._hasPendingMacrotasks || (zone as any)._hasPendingMicrotasks; if (hasTasks) fail("Leaked Zone tasks"); }));
Hardening Matchers, Spies, and Custom Equality
Custom Equality Testers
Enterprise data models often require tolerant equality (e.g., Date
normalization, BigInt
handling). Register equality testers locally to avoid global behavior shifts.
beforeEach(() => { jasmine.addCustomEqualityTester((a,b) => { if (a instanceof Date && b instanceof Date) { return a.getTime() === b.getTime(); } return undefined; // fall back to default }); });
Spy Strategies at Scale
Prefer and.callFake
for deterministic returns and and.throwError
for error paths. Use withArgs
to reduce matcher ambiguity. Clean spies after each spec.
const api = { get(){}, post(){} }; beforeEach(() => { spyOn(api, "get").withArgs("/users").and.returnValue(Promise.resolve([{id:1}])); spyOn(api, "post").and.callFake(() => Promise.reject(new Error("boom"))); });
Configuration Patterns that Prevent Regret
Baseline jasmine.json
Set explicit timeouts, enable randomization, and pin reporters. Make it boring and predictable.
{ "spec_dir": "spec", "spec_files": ["**/*[sS]pec.js"], "helpers": ["helpers/**/*.js"], "random": true, "stopSpecOnExpectationFailure": false, "defaultTimeoutInterval": 10000 }
Helper to Enforce Global Hygiene
Load a helper that installs and uninstalls fakes, resets globals, and captures seeds and environment info.
// spec/helpers/global.js beforeAll(() => { console.log("seed:", jasmine.getEnv().configuration().seed); }); afterEach(() => { // restore globals if modified if (Date.now.and) Date.now.and.callThrough(); });
CI and Operational Excellence
Seeded Reruns and Artifact Capture
On failure, store junit.xml
, console logs, screenshots (if browser), heap snapshots (if Node), and the exact seed. Provide a one-click "reproduce locally" script.
// scripts/repro.sh #!/usr/bin/env bash set -euo pipefail SEED=$1 shift export TZ=UTC LC_ALL=C LANG=C jasmine --random=true --seed=$SEED "$@"
Spec Flake Scoring
Track failure rate per spec over a rolling window. Prioritize the top offenders for engineering time.
// tools/flake-score.js const fs = require("fs"); const runs = JSON.parse(fs.readFileSync("./.jasmine-runs.json")); const score = {}; for (const r of runs) { for (const f of r.failures) score[f] = (score[f]||0)+1; } console.log(Object.entries(score).sort((a,b)=>b[1]-a[1]).slice(0,20));
Hermetic Builds
Freeze Node and browser versions. Use lockfiles, vendored Chromium, and identical flags. Disallow auto-updates in CI images. Differences in engines are a major source of surprise regressions.
Security and Compliance Considerations
Testing with Sensitive Data
Ensure fixtures do not contain real PII. Inject synthetic data and deterministic UUIDs. Clear temporary files and IPC sockets after each shard.
Sandbox External Calls
Stub network requests at the HTTP layer with a local interceptor to avoid accidental data egress or dependency on unstable third-party services.
Long-Term Maintenance Practices
Test Design Reviews
Add a review checklist for tests that touch time, concurrency, or globals. Ban "sleepy" tests that wait for arbitrary durations; require events or state changes instead.
Test Categories and Tags
Label specs by reliability and performance. Run critical fast suites on every commit; push heavier, flaky-prone suites to nightly jobs with strict quarantines.
Deprecation and Migration Strategy
When adopting new versions of Jasmine or moving from Protractor to WebDriverIO/Playwright harnesses, run a dual-stack for a time and measure stability differences. Migrate matchers and helpers in small, reversible steps.
Case Study: Eliminating a 3% Flake Rate
Symptoms
A retail platform saw a 3% CI flake rate across 12k Jasmine specs. Failures clustered around debounced UI logic and clock-dependent services.
Findings
- Mixed async patterns caused premature spec completion.
- Fake timers advanced setTimeout, but code used requestAnimationFrame.
- Seeded randomization revealed order dependence from a singleton cache.
- Node and Chrome versions drifted across agents.
Fix Plan
- Standardized on
async/await
; banneddone
except for pure callbacks. - Abstracted animation/time primitives and faked the abstraction.
- Added
afterEach
guard rails for leaked tasks and timers. - Sharded by duration and captured seeds/artifacts; added repro script.
- Pinned engines and ICU data; enforced TZ=UTC.
Outcome
Flake rate dropped below 0.2% within two sprints. Mean CI runtime fell 18% after shard rebalancing. Faster root cause isolation increased developer confidence and reduced test-only retries to near zero.
Best Practices Checklist
- Enable
--random=true
and record seeds on every run. - Use one async style per spec. Prefer
async/await
. - Avoid global spies; create and restore in
beforeEach/afterEach
. - Abstract time and animation; fake the abstraction, not the world.
- Drain microtasks explicitly before assertions that depend on them.
- Reset singletons and module caches between specs.
- Shard by historical duration; persist per-spec timing.
- Pin engines, locales, and timezones; make the environment hermetic.
- Quarantine flaky specs with telemetry; never silently ignore.
- Store artifacts and provide a one-command local reproduction.
Conclusion
Jasmine scales when its sharp edges are deliberately blunted: normalize async, contain globals, make time deterministic, and invest in reproducibility. At enterprise scope, flakiness is a systems problem—not an individual test problem. By instrumenting the runner, enforcing hygiene in helpers, and engineering your CI to preserve seeds and artifacts, you can convert intermittent, high-cost failures into fast, deterministic signals that accelerate delivery rather than block it.
FAQs
1. How do I decide between fake timers and real time?
Use fake timers only for pure time logic (debounce, throttling) where you control all scheduling primitives. If code involves real I/O or browser animation frames, abstract those operations and fake the abstraction to avoid partial simulation gaps.
2. What's the fastest way to find order-dependent tests?
Enable randomization with seed capture and run multiple seeds in parallel nightly. When a failure occurs, rerun the same seed locally to reproduce and inspect which earlier spec mutated globals or singletons.
3. How can I prevent memory and handle leaks in long suites?
Create spies and heavy mocks inside beforeEach
and restore them in afterEach
. Add guard rails to fail on leaked timers, open sockets, or pending microtasks, and run periodic heap snapshots in CI for the slowest shards.
4. Why do tests pass locally but fail in CI headless Chrome?
Headless mode changes timing and rendering heuristics. Pin Chrome versions and flags, mock animation/time primitives, and ensure all waits are event-driven rather than fixed delays to remove rendering-dependent variability.
5. How should I structure repro for flaky CI failures?
Persist the failing seed, spec file list, and environment variables as artifacts. Offer a single script that sets TZ/locale, selects the failing shard's spec set, and re-executes Jasmine with the captured seed for a faithful local rerun.