A runner for node:test
, jest
, and tape
test suites on top of node:test
(and any runtime).
It can run your existing tests on all runtimes and also browsers, with snapshots and module mocks.
- Native ESM, including in Jest tests
- Esbuild on the fly for babelified ESM interop (enable via
--esbuild
) - TypeScript support in both transform (through tsx, enable via
--esbuild
) and typestrip (via--typescript
) modes - Runs on Node.js node:test, Bun, Deno, Electron, v8 CLI, JSC, Hermes, SpiderMonkey, Chrome, Firefox, WebKit, Brave, Microsoft Edge, QuickJS, XS, GraalJS, Escargot, and even engine262.
- Testsuite-agnostic — can run any file as long as it sets exit code based on test results
- Built-in Jest compatibility (with
--jest
), includingjest.*
global- Up to ~10x faster depending on the original setup
- Actual
expect
module, alsojest-extended
andjest-when
just work on top - Snapshots, including snapshot matchers
- Function and timer mocks
- test.concurrent
- Module mocks, including for ESM modules (already loaded ESM modules can be mocked only on
node:test
) - Loads Jest configuration
- Built-in network record/replay for offline tests, mocking
fetch
andWebSocket
sessions --drop-network
support for guaranteed offline testing- Native code coverage via v8 (Node.js or c8), with istanbul reporters
- GitHub reporter (auto-enabled by default)
- JSDOM env support
- Hanging tests error by default (unlike
jest
) - Babel support, picks up your Babel config (enable via
--babel
) - Unlike
bun:test
, it runs test files in isolated contexts
Bun leaks globals / side effects between test files (ref), and has incompatibletest()
lifecycle / order - Also features a tape API for drop-in replacement
Use --engine
(or EXODUS_TEST_ENGINE=
) to specify one of:
node:test
— the default one, runs on top of modern Node.js test runner APInode:pure
— implementation in pure JS, runs on Node.jsnode:bundle
— same asnode:pure
, but bundles everything into a single file before launching- Other runtimes:
bun:pure
/bun:bundle
— Bun, expectsbun
to be availabledeno:bundle
— Deno (v1 or v2, whicheverdeno
is)electron-as-node:test
/electron-as-node:pure
/electron-as-node:bundle
Same asnode:*
, but useselectron
binary.
The usecase is mostly to test on BoringSSL instead of OpenSSL.electron:bundle
— run tests in Electron BrowserWindow without Node.js integration.
- Browsers:
- Playwright builds (install Playwright-built engines with
exodus-test --playwright install
)chromium:playwright
— Playwright-built Chromiumfirefox:playwright
— Playwright-built Firefoxwebkit:playwright
— Playwright-built WebKit, close to Safarichrome:playwright
— Chrome (system-installed)msedge:playwright
— Microsoft Edge (system-installed)
- Puppeteer (system-provided or upstream builds)
chrome:puppeteer
— Chromefirefox:puppeteer
— Firefoxbrave:puppeteer
— Bravemsedge:puppeteer
— Microsoft Edge
- Playwright builds (install Playwright-built engines with
- Barebone engines (system-provided or installed with
npx jsvu
/npx esvu
):d8:bundle
— v8 CLI (Chrome/Blink/Node.js JavaScript engine)jsc:bundle
— JavaScriptCore (Safari/WebKit JavaScript engine)hermes:bundle
— Hermes (React Native JavaScript engine)spidermonkey:bundle
— SpiderMonkey (Firefox/Gecko JavaScript engine)quickjs:bundle
— QuickJSxs:bundle
— XSgraaljs:bundle
— GraalJSescargot:bundle
— Escargotengine262:bundle
- engine262, the per-spec implementation of ECMA-262 (install with esvu)
# tests/jest/expect.mock.test.js
✔ PASS drinkAll > drinks something lemon-flavoured (1.300417ms)
✔ PASS drinkAll > does not drink something octopus-flavoured (0.191791ms)
✔ PASS drinkAll (1.842959ms)
✔ PASS drinkEach > drinkEach drinks each drink (0.360625ms)
✔ PASS drinkEach (0.463416ms)
✔ PASS toHaveBeenCalledWith > registration applies correctly to orange La Croix (0.53325ms)
✔ PASS toHaveBeenCalledWith (0.564166ms)
✔ PASS toHaveBeenLastCalledWith > applying to all flavors does mango last (0.380375ms)
✔ PASS toHaveBeenLastCalledWith (0.473417ms)
# tests/jest/fn.invocationCallOrder.test.js
✔ PASS mock.invocationCallOrder (4.221042ms)
✅ tests/jest/lifecycle.test.js
✔ PASS A > B > C (3.26166ms) ✔ PASS A > B > D (1.699463ms) ✔ PASS A > B (6.72719ms) ✔ PASS A > E > F (1.117997ms) ✔ PASS A > E > G > H (1.330904ms) ✔ PASS A > E > G (1.94971ms) ✔ PASS A > E (3.821825ms) ✔ PASS A > I (0.533096ms) ✔ PASS A (13.887889ms) ✔ PASS J (0.373187ms) ✔ PASS K > L (0.659852ms) ✔ PASS K (1.143195ms)
✅ tests/jest/timers.async.test.js
✔ PASS advanceTimersByTime() does not let microtasks to pass (5.326604ms) ✔ PASS advanceTimersByTime() does not let microtasks to pass even with await (1.336064ms) ✔ PASS advanceTimersByTimeAsync() lets microtasks to pass (6.99526ms) ✔ PASS advanceTimersByTimeAsync() lets microtasks to pass, chained (10.131664ms) ✔ PASS advanceTimersByTimeAsync() lets microtasks to pass, longer chained (8.635472ms) ✔ PASS advanceTimersByTimeAsync() lets microtasks to pass, async chain (56.937983ms)
See live output in CI
-
@exodus/test/node
—node:test
API, working under non-Node.js platforms -
@exodus/test/jest
—jest
implementation -
@exodus/test/tape
—tape
mock (can also be helpful when moving fromtap
)
Just use "test": "exodus-test"
-
--jest
— register jest test helpers as global variables, also loadjest.config.*
configuration options -
--esbuild
— use esbuild loader, also enables Typescript support -
--babel
— use babel loader (slower than--esbuild
, makes sense if you have a special config) -
--coverage
— enable coverage, prints coverage output (varies by coverage engine) -
--coverage-engine c8
— use c8 coverage engine (default), also generates./coverage/
dirs -
--coverage-engine node
— use Node.js builtint coverage engine -
--watch
— operate in watch mode and re-run tests on file changes -
--only
— only run the tests marked withtest.only
-
--passWithNoTests
— do not error when no test files were found -
--write-snapshots
— write snapshots instead of verifying them (has--test-update-snapshots
alias) -
--test-force-exit
— force exit after tests are done
The --jest
mode is mostly compatible with Jest. There are some noteworthy differences though.
This tool does not hoist mocks, so it is important that a mock is defined before the module that uses it is imported.
In ESM, this can be achieved with dynamic imports:
jest.doMock('./hogwarts.js', () => ({
__esModule: true,
default: jest.fn(),
}))
const { default: getEntryQualification } = await import('./hogwarts.js')
const { qualifiesForHogwarts } = await import('./wizard.js') // module importing ./hogwarts.js
test('qualifies for Hogwarts', () => {
// doSomething is a mock function
getEntryQualification.mockReturnValue(['lumos'])
expect(qualifiesForHogwarts('potter')).toBe(false)
getEntryQualification.mockReturnValue([])
expect(qualifiesForHogwarts('potter')).toBe(true)
})
Note that all modules that transitively import hogwarts.js
will have to be imported after the mock is defined.