hermes-test
v0.2.4
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.
1472 tests — 0.84s (cached) | 5s with coverage⚠️ Early release (v0). hermes-test is battle-tested on a production Expo app (1472 tests, 100% pass rate) but the API may still change.
Recommended approach: Use
.hermes.test.tsas your test file suffix. This lets you run hermes-test alongside Jest without overwriting your existing tests. Migrate one file at a time, verify it passes in both runners, then expand. Don't delete your Jest tests until you're confident.
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 (Topdanmark, Danish insurance — 259 files, 1472 tests):
| | Jest | hermes-test | Speedup | |---|---|---|---| | Full suite (no coverage) | 54s | 0.84s cached / 2.5s cold | 64x / 22x | | Full suite (with coverage) | 128s | 5s | 26x | | Watch rerun | ~3s | ~300ms | 10x |
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 |
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, expect, group, beforeEach, afterEach } from 'hermes-test';
group('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');
});
test.skip('not yet', () => {});
test.only('focus this', () => {});
test('slow test', () => { /* ... */ }, { timeout: 10000 });
});Assertions
expect(val).toBe(exact) expect(val).toEqual(deep)
expect(val).toBeTruthy() expect(val).toBeFalsy()
expect(val).toBeDefined() expect(val).toBeUndefined()
expect(val).toBeNull() expect(val).toBeGreaterThan(n)
expect(val).toContain(item) expect(val).toContainEqual(item)
expect(val).toMatch(/regex/) expect(val).toBeCloseTo(n, precision)
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
import { mockModule } from 'hermes-test';
import { useMyHook } from './useMyHook'; // import order doesn't matter
mockModule('./useRedux', () => ({
useAppSelector: (selector) => mockState,
}));Shadow wrappers check mocks at call time — mockModule can appear before or after imports.
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);Fetch mocking (MSW-style)
import { mockFetch, mockFetchUse, mockFetchReset, http, HttpResponse } from 'hermes-test';
mockFetch(
http.get('https://api.example.com/data', () => HttpResponse.json({ ok: true })),
http.post('https://api.example.com/login', () => HttpResponse.json({ token: '...' })),
);
// Per-test override
mockFetchUse(http.get('https://api.example.com/data', () => HttpResponse.error()));
// Cleanup
mockFetchReset();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();How it works
┌──────────────┐ ┌─────────┐ ┌────────────┐
│ .test.ts │────▶│ esbuild │────▶│ Hermes │
│ files │ │ bundle │ │ VM eval │
└──────────────┘ └─────────┘ └────────────┘
│ │ │
mockModule() <100ms bundle native execution
spy/expect path aliases drainMicrotasks
renderHook Hermes patches real React tree- esbuild bundles your test + source into a single IIFE (~100ms)
- Rust CLI applies Hermes patches (class-extends, for-let-of)
- Bytecode compilation — cached .hbc for instant loading on subsequent runs
- Hermes VM evaluates the bytecode — same engine as your app
- Results printed to terminal — single process, no workers, no IPC
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 (Shadow Wrappers)
When multiple test files mock the same module differently, hermes-test uses shadow wrappers — filesystem-based Proxy wrappers that check which test file is running at call time. One bundle, one runtime, per-file mock isolation.
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"
}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 |
| split | Enable vendor/group bundle splitting for large suites | 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:
{
"shims": {
"react-native-keychain": "./test/shims/keychain.js"
}
}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
- [ ] 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
