hermes-test
v1.5.5
Published
26-64x faster than Jest. A test runner built for React Native and Expo. One esbuild pass, one process, zero Babel.
Maintainers
Readme
hermes-test
26–64x faster than Jest. A test runner built for React Native and Expo. One esbuild pass, one process, zero Babel — results in under a second.
1766 tests, 7 snapshots — 5s (Jest: 116s → 23x faster)Battle-tested as the sole test runner for a production Expo app (284 suites, 1766 tests, 7 snapshots). Zero Jest dependency.
The problem
Jest in React Native is slow by design. Every test file spawns a worker, runs Babel transforms, resolves transformIgnorePatterns for every node_modules import, and coordinates results over IPC. For a mid-size Expo app, that's 1-2 minutes per run. With coverage, even longer.
On top of that, the configuration tax is real: transformIgnorePatterns breaks every time you add a dependency, jest-expo mocks silently drift from real APIs, and moduleNameMapper requires manual upkeep for every monorepo alias. Developers stop running tests. Tests rot. Coverage drops.
The fix
hermes-test replaces the entire Jest pipeline with two things: esbuild (one bundle pass, <100ms) and a Rust CLI that evaluates it in a single process. No workers, no Babel, no transformIgnorePatterns. Native modules are auto-detected and externalized — zero manual configuration needed.
Your tests run in Hermes — the same JavaScript engine your app ships with — so you also get engine parity for free. But the real win is speed: results appear before your hand leaves Cmd+S.
Benchmarks
Production Expo app (284 suites, 1766 tests, 7 snapshots):
| | Jest | hermes-test | Speedup | |---|---|---|---| | Full suite | 116s | 5s | 23x | | Cached run | 54s | 0.84s | 64x | | With coverage | 128s | 5s | 26x | | Watch rerun | ~3s | ~350ms | 9x |
Micro benchmarks (Apple Silicon, no coverage):
| Scenario | hermes-test | Jest + @swc/jest | Speedup | |----------|-------------|------------------|---------| | 10 pure function tests | 16ms | 714ms | 45x | | 50 hook tests (renderHook + act) | 75ms | 721ms | 10x | | Trivial cold start | 4.6ms | 1,486ms | 364x |
V8 evaluation summary
We ran a full V8 evaluation on a large real-world Expo workload.
| Scenario | Hermes | V8 | Observation | |---|---:|---:|---| | Full run (no coverage) | ~3–5s | ~3s in best observed local run | Comparable in best case | | Coverage run | ~7–8s | ~20–26s | Hermes clearly faster | | Watch mode | Stable baseline | Improved but still experimental | Hermes better day-to-day DX |
Why Hermes won overall: bytecode-first startup path, better fit with current cache architecture, and significantly lower overhead in the current coverage pipeline.
What bytecode-first means in this runner:
- Bundle once with esbuild.
- Compile bundle to Hermes bytecode (
.hbc) ahead of execution. - Execute cached bytecode directly (
eval_bytes) instead of parsing large JS text at runtime. - Reuse
.hbcfrom cache on subsequent runs.
Detailed notes: .claude/references/v8-evaluation-summary.md
Quick start
bun add -D hermes-test// useCounter.test.ts
import { test, expect, renderHook, act } from 'hermes-test';
test('useCounter increments', () => {
const { result } = renderHook(() => useCounter(0));
act(() => result.current.increment());
expect(result.current.count).toBe(1);
});hermes-test # run all tests
hermes-test --watch # watch modeAPI
Test structure
import { test, describe, expect, beforeEach, afterEach } from 'hermes-test';
describe('myFeature', () => {
beforeEach(() => { /* reset */ });
test('does the thing', () => {
expect(result).toBe(42);
expect(arr).toEqual([1, 2, 3]);
expect(str).toContain('hello');
expect(fn).toThrow('error message');
});
});Assertions
expect(val).toBe(exact) expect(val).toEqual(deep)
expect(val).toMatchObject(sub) expect(val).toMatchSnapshot()
expect(val).toBeTruthy() expect(val).toBeFalsy()
expect(val).toBeDefined() expect(val).toBeUndefined()
expect(val).toBeNull() expect(val).toBeGreaterThan(n)
expect(val).toBeGreaterThanOrEqual(n) expect(val).toBeLessThanOrEqual(n)
expect(val).toContain(item) expect(val).toContainEqual(item)
expect(val).toMatch(/regex/) expect(val).toBeCloseTo(n, precision)
expect(obj).toHaveProperty('a.b', v) expect(val).toHaveLength(n)
expect(fn).toThrow('msg') expect(val).not.toBe(other)
// Asymmetric matchers
expect.anything() expect.any(String)
expect.objectContaining({ key }) expect.arrayContaining([1, 2])
expect.stringContaining('sub') expect.stringMatching(/pattern/)
// Async
await expect(promise).resolves.toBe(value)
await expect(promise).rejects.toThrow('msg')Spies
import { spy, spyOn, clearAllMocks } from 'hermes-test';
const fn = spy(() => 'default');
fn.mockReturnValue('mocked');
fn.mockReturnValueOnce('first');
fn.mockImplementation((x) => x * 2);
fn.mockResolvedValue('async');
expect(fn).toHaveBeenCalled();
expect(fn).toHaveBeenCalledWith('arg1', 'arg2');
expect(fn).toHaveBeenCalledTimes(3);
expect(fn.calls[0][0]).toBe('arg1'); // direct access
// spyOn — intercept real object methods
const s = spyOn(storage, 'get');
s.mockReturnValue('cached');
s.mockRestore(); // revert to original
// Clear all spies at once
clearAllMocks();Module mocking
// ht.mock() — works like jest.mock()
// Relative paths resolve from the TEST FILE's directory (jest semantics):
ht.mock('../hooks/useRedux', () => ({
useAppSelector: (selector) => mockState,
}));
// ht.unmock() — opt out of the shim system, bundle the real module
ht.unmock('moment');
// ht.shallow() — auto-mock all JSX child components
ht.shallow('../MyComponent');Mocks resolve at access time, not import time — ht.mock can appear before or after imports.
Relative mock paths are resolved against the test file and apply at every import site of the resolved module, no matter how each importer spells its own relative specifier. Alias and package mocks match the import specifier text exactly. In both cases the real module stays in the bundle — unmocked test files in the same run fall through to the real implementation.
Hook testing
import { renderHook, act, waitFor } from 'hermes-test';
const { result, history, renderCount } = renderHook(() => useCounter(0));
act(() => result.current.increment());
expect(result.current.count).toBe(1);
expect(renderCount).toBe(2);Component rendering
import { render, fireEvent, expect } from 'hermes-test';
const { getByText, getByTestId, toJSON } = render(<MyComponent />);
// Queries (all have get/getAll/query/queryAll variants)
getByText('Hello'); getByText(/hello/i);
getByTestId('submit-btn'); getByProps({ disabled: true });
getByType('View');
// Fire events
fireEvent.press(getByTestId('btn'));
fireEvent.changeText(getByTestId('input'), 'new value');
fireEvent.scroll(getByTestId('list'), { nativeEvent: { contentOffset: { y: 100 } } });
fireEvent(node, 'focus'); // generic
// Serialization
toJSON(); // plain object tree
toTree(); // pretty-printed JSX string
// Lifecycle
rerender(<MyComponent updated />);
unmount();Element matchers
expect(element).toBeRendered();
expect(element).toHaveTextContent('Hello');
expect(element).toHaveTextContent(/hello/i);
expect(element).toContainElement(child);
expect(element).toBeEmpty();
expect(input).toHaveDisplayValue('current value');
expect(element).toHaveProp('testID', 'my-id');
expect(element).toHaveStyle({ backgroundColor: 'red' });
expect(button).toBeEnabled(); expect(button).toBeDisabled();
expect(element).toBeVisible(); // checks display + opacitySnapshot testing
// First run: creates __snapshots__/myComponent.test.tsx.snap
expect(toJSON()).toMatchSnapshot();
// Subsequent runs: compares against stored snapshot, fails on mismatch
// Update snapshots:
// hermes-test --update-snapshotsFetch mocking (MSW-style)
import { http, HttpResponse } from 'hermes-test';
// Register handlers — auto-overwrites matching method+url
ht.mock.fetch(
http.get('https://api.example.com/data', () => HttpResponse.json({ ok: true })),
http.post('https://api.example.com/login', () => HttpResponse.json({ token: '...' })),
);
// Override in a specific test — same API, auto-replaces
ht.mock.fetch(http.get('https://api.example.com/data', () => HttpResponse.error()));
// Reset all handlers
ht.mock.fetch.reset();Redux store
import { setupApiStore } from 'hermes-test/store';
const ctx = setupApiStore([api, cms], { app: rootReducer }, {
preloadedState: { app: { auth: { session: mockSession } } },
});
const { result } = ctx.renderHookWithReduxStore(() => useMyHook());
ctx.store.dispatch(authActions.logout());Fake timers
import { useFakeTimers, advanceTimersByTime, useRealTimers } from 'hermes-test';
useFakeTimers();
setTimeout(() => { fired = true }, 1000);
advanceTimersByTime(1000);
expect(fired).toBe(true);
useRealTimers();Platform requirements
| Platform | Status | Notes | |----------|--------|-------| | macOS (Apple Silicon / Intel) | ✅ Fully supported | Recommended for CI/CD | | Linux | ✅ Supported | Includes NumberFormat fallback for Hermes ICU stub |
hermes-test runs on the Hermes JavaScript engine, the same engine that powers React Native on iOS and Android. Hermes's Intl (internationalization) support varies by platform:
- macOS: Full Intl support via Apple's Foundation framework (
toLocaleDateString,toLocaleStringetc. work correctly with any locale) - Android (device): Full Intl support via Java ICU
- Linux (desktop/CI): Hermes's Linux Intl implementation (
PlatformIntlICU.cpp) has an incompleteIntl.NumberFormat. hermes-test patches this at runtime with a deterministic fallback so locale-aware number formatting works in tests.
Linux CI is supported. macOS remains the reference environment for closest parity with iOS app behavior.
Linux Intl fallback behavior
On Linux, hermes-test applies small runtime fallbacks only when native behavior is clearly broken:
Intl.NumberFormat+Number.prototype.toLocaleString(locale-sensitive numeric formatting)String.prototype.toLocaleLowerCase/toLocaleUpperCase(guards against ICU stub placeholders)
This is intentionally scoped. It is not a full CLDR implementation and does not aim to perfectly replicate every locale edge case.
If a locale isn't explicitly mapped in the number fallback, hermes-test falls back to safe defaults (en-US-style separators).
Contributing Intl improvements
If you want to improve locale behavior:
- Update
packages/hermes-test/src/polyfills.js(fallback detection + formatting behavior). - Add/extend tests in
examples/expo-app/src/examples/intl-locale.test.ts. - Validate on Linux (CI or local Linux container).
- Keep fallbacks deterministic and gated by runtime checks so working native Intl (macOS/Android) remains untouched.
Hermes has three platform-specific Intl backends (source):
- Apple →
PlatformIntlApple.mm— delegates to Foundation'sNSDateFormatter/NSNumberFormatter(full CLDR locale data) - Android →
PlatformIntlAndroid.cpp— delegates to Java'sandroid.icuvia JNI - Linux/other →
PlatformIntlICU.cpp— intended to use ICU4C directly, butNumberFormatwas never implemented. The source code contains a stub with the comment: "This isn't right, but I didn't want to do more work for a stub."
The Hermes team has acknowledged this is a work-in-progress (discussion #1211, issue #23). Since Hermes is optimized for mobile (iOS/Android), desktop Linux has been lower priority.
See also: Hermes IntlAPIs documentation
How it works
Hermes is the JavaScript engine. The harness is the test runtime layer on top of Hermes.
| Step | What happens |
|---|---|
| 1 | The CLI picks the test files for this run (all, filtered, or changed in watch mode). |
| 2 | The CLI generates one entry file (.hermes-test-entry) from those tests. |
| 3 | esbuild bundles that entry and app code into one JavaScript bundle. |
| 4 | Hermes starts and evaluates harness.bundle.js first (this provides test, expect, mock, renderHook, and test orchestration). |
| 5 | Hermes evaluates the test bundle, runs tests, and returns results to the CLI. |
| 6 | Bytecode cache (.hbc) is reused on later runs for faster startup. |
Three-tier cache
| Tier | What | Speed | |---|---|---| | Bytecode (.hbc) | Pre-compiled Hermes bytecode | Fastest — skip JS parsing | | Patched JS | Post-patched esbuild output | Fast — skip bundling + patching | | Fresh bundle | Full esbuild + patch pipeline | Cold start only |
Auto-detect native externals
Native modules are detected automatically by scanning node_modules for ios/, android/, *.podspec, and app.plugin.js. No manual externals config needed for standard React Native packages.
Mock isolation (the receptionist and the brain)
Every mock in hermes-test is the same two-part machine. Think of the bundle as an
office building and every import as a visitor at the front desk:
- The receptionist (
onResolve, bundle time) only does directions, never answers: a visitor asks for./flows/foo, and the receptionist points at a door. For unmocked modules that's the real office. For mocked modules the visitor is sent to a small front office (a generated wrapper file) that stands in front of the real one — which still exists, right behind a connecting door. - The brain (
get(), run time) sits inside that front office and handles mock isolation and management. Isolation: it knows who is asking — only the currently running test file's mocks apply, never another file's. Management: it decides which registered mock answers each question (exact mock keys, barrel sub-path delegation, CJS default handling, call-time re-checks for captured functions) — and when no mock matches, it opens the connecting door and lets the real module answer.
The receptionist can't answer questions (bundling happens once, before any test runs — it doesn't know which test will be asking). The brain can't direct anyone (a Proxy only works if imports actually arrive at it — someone must point visitors its way). Directions at bundle time, answers at run time: one bundle, one runtime, per-file mock isolation, and unmocked test files always reach the real implementation through the connecting door.
CLI
hermes-test # run all test files
hermes-test src/hooks/ # run tests in a directory
hermes-test src/hooks/useLogin.test.ts # run a specific file
hermes-test --watch # watch mode — reruns on file changes
hermes-test --watch useLogin # watch mode, filtered to matching files
hermes-test --coverage # run with coverage (lcov + HTML report)Configuration
Polyrepo (single package)
No config file needed for simple projects. Just run hermes-test in your project root.
my-app/
├── src/
│ └── hooks/
│ └── useLogin.hermes.test.ts
├── package.json
└── tsconfig.json ← path aliases read automaticallyMonorepo
Create hermes-test.config.json in your app directory. The root field tells hermes-test where the monorepo root is (for resolving shared node_modules).
monorepo/
├── apps/
│ └── my-app/
│ ├── src/
│ ├── hermes-test.config.json ← config here
│ ├── package.json
│ └── tsconfig.json
├── packages/
│ └── shared/
└── node_modules/ ← root points here{
"root": "../..",
"testMatch": ".hermes.test.ts"
}Mock resolver
Mocks are delivered through an esbuild onResolve plugin (one bundle, one
Hermes VM, all mock kinds). Set HT_RESOLVER=legacy to restore the previous
delivery pipeline (shadow trees, package shims, isolated bundles) — kept as an
escape hatch for one release cycle.
hermes-test.config.json
| Key | Description | Required |
|-----|-------------|----------|
| root | Monorepo workspace root (for resolving node_modules) | Monorepo only |
| testMatch | Test file suffix (default: .test.ts) | No |
| externals | Additional modules to externalize | No (most auto-detected) |
| shims | Built-in or custom module replacements | No |
| coverageThreshold | Minimum coverage % — fails if below (e.g. 65) | No |
tsconfig paths are read automatically — monorepo path aliases just work:
{
"compilerOptions": {
"paths": {
"@app/*": ["./src/*"],
"@myorg/shared/*": ["../../packages/shared/src/*"]
}
}
}Native externals are auto-detected by scanning node_modules for ios/, android/, *.podspec, and app.plugin.js. Most projects need zero manual externals.
Built-in shims
hermes-test ships with ready-to-use shims for common React Native ecosystem packages. Use hermes-test/shims/<name> in your config — no local shim files needed.
| Shim | What it provides |
|------|-----------------|
| hermes-test/shims/react-native | Platform, StyleSheet, Dimensions, Alert, Linking stubs |
| hermes-test/shims/react-i18next | Identity translation (t('key') returns 'key') |
| hermes-test/shims/async-storage | In-memory AsyncStorage (getItem, setItem, clear, etc.) |
| hermes-test/shims/rtk-query | RTK Query createApi singleton cache |
| hermes-test/shims/react-redux | Pass-through for react-redux |
| hermes-test/shims/reduxjs-toolkit | Pass-through for @reduxjs/toolkit |
Example config with shims:
{
"root": "../..",
"testMatch": ".hermes.test.ts",
"shims": {
"react-i18next": "hermes-test/shims/react-i18next",
"@reduxjs/toolkit/query/react": "hermes-test/shims/rtk-query",
"@react-native-async-storage/async-storage": "hermes-test/shims/async-storage"
}
}You can also write custom shims for app-specific native modules — or for any package the bundler cannot parse (Flow syntax, font/media assets). A configured shim externalizes the real package (it is never bundled) and serves your file to every importer at runtime:
{
"shims": {
"react-native-keychain": "./test/shims/keychain.js",
"@react-native-masked-view/masked-view": "./test/shims/masked-view.js"
}
}Any import with a non-code extension (fonts, images, audio, …) is loaded as an empty module, so
@expo/vector-icons-style require('./Fonts/X.ttf') never breaks a bundle.
React Native globals
The harness installs what React Native itself installs in InitializeCore
(Libraries/Core/setUpXHR.js), from the same sources where possible — nothing to install or
configure on your side:
| Global | Source |
|---|---|
| Headers, Request, Response | whatwg-fetch — the package RN requires verbatim (bundled into the harness) |
| AbortController, AbortSignal | abort-controller — the package RN requires (bundled into the harness) |
| fetch | hermes-test's handler-based mock (ht.mock.fetch) — whatwg-fetch's XHR fetch is not installed |
| FormData, URL, URLSearchParams | small mirrors of RN's own Flow implementations (Libraries/Network/FormData.js, Libraries/Blob/URL.js; userinfo stripped from host, no host for non-http schemes) |
| Blob, File | RN-shaped mirrors (Libraries/Blob) |
| console | full RN surface (assert, group, table, time, …) |
| Expo projects: TextDecoder, WHATWG URL, structuredClone, spec FormData | the project's own expo/src/winter/runtime.native.ts is run before tests — same as expo/src/Expo.fx at app start; any SDK; "expoRuntime": false to disable |
Deliberately nothing RN lacks (for example TextDecoder), so code that would throw on a device
throws in your tests too. If your app installs its own polyfills at startup, they win — the
harness only fills globals that are absent.
Coverage
hermes-test --coverageGenerates:
- Terminal table — per-file line + function coverage with color coding
coverage/lcov.info— standard lcov format, works with any lcov toolcoverage/index.html— interactive HTML report with source-level green/red highlighting
Coverage uses esbuild source maps for accurate original-file line mapping. Imports, node_modules, test files, and monorepo dependencies are automatically excluded — only your source code is measured.
Coverage threshold
Add coverageThreshold to hermes-test.config.json to fail CI when coverage drops:
{
"coverageThreshold": 65
}If total statement coverage is below the threshold, hermes-test exits with code 1.
Stack
- Hermes — the JS engine that ships with React Native and Expo
- esbuild — bundler, 100x faster than Babel/Metro transforms
- Rust — CLI host, native Hermes FFI, bytecode caching
- TypeScript — test harness (spy, expect, renderHook, mockFetch, timers)
Why not Jest?
| | Jest + jest-expo | hermes-test |
|---|-----------------|-------------|
| Bundling | Babel on every import | esbuild, one pass |
| Startup | ~700ms per worker | ~5ms total |
| Native externals | Manual transformIgnorePatterns | Auto-detected |
| Config needed | transformIgnorePatterns, moduleNameMapper, mocks | Zero for most projects |
| Watch rerun | ~2-3s | ~300ms |
| 1472 tests (no coverage) | 54s | 0.84s |
| 1472 tests (with coverage) | 128s | 5s |
| Coverage | Built-in (v8/Istanbul) | --coverage with source maps, HTML report, threshold |
| Engine | Node | Hermes (same as your app) |
Platform support
| Platform | Status | |----------|--------| | macOS (Apple Silicon) | Supported | | Linux (x64) | Supported | | macOS (Intel x64) | Planned | | Windows | Not planned |
Roadmap
- [x] Coverage reporting — source map-based instrumentation, lcov + HTML report, threshold enforcement
- [ ] macOS Intel (x64) — cross-compile or dedicated CI runner
- [x] Component rendering —
render(<Component />)with query API (getByText,getByTestId,fireEvent) - [ ] Jest compatibility shim —
jest.fn()→spy(),jest.mock()→mockModule(), enables reuse of library__mocks__/files - [ ] Library mock support — auto-load mocks from expo-router, react-native-reanimated, zustand, etc.
- [ ]
setupFilesconfig — load setup files before tests (like Jest'ssetupFilesAfterFramework)
License
MIT
