npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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.

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:

  1. Bundle once with esbuild.
  2. Compile bundle to Hermes bytecode (.hbc) ahead of execution.
  3. Execute cached bytecode directly (eval_bytes) instead of parsing large JS text at runtime.
  4. Reuse .hbc from 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 mode

API

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 + opacity

Snapshot 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-snapshots

Fetch 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, toLocaleString etc. 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 incomplete Intl.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:

  1. Update packages/hermes-test/src/polyfills.js (fallback detection + formatting behavior).
  2. Add/extend tests in examples/expo-app/src/examples/intl-locale.test.ts.
  3. Validate on Linux (CI or local Linux container).
  4. Keep fallbacks deterministic and gated by runtime checks so working native Intl (macOS/Android) remains untouched.

Hermes has three platform-specific Intl backends (source):

  • ApplePlatformIntlApple.mm — delegates to Foundation's NSDateFormatter/NSNumberFormatter (full CLDR locale data)
  • AndroidPlatformIntlAndroid.cpp — delegates to Java's android.icu via JNI
  • Linux/otherPlatformIntlICU.cpp — intended to use ICU4C directly, but NumberFormat was 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 automatically

Monorepo

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 --coverage

Generates:

  • Terminal table — per-file line + function coverage with color coding
  • coverage/lcov.info — standard lcov format, works with any lcov tool
  • coverage/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 renderingrender(<Component />) with query API (getByText, getByTestId, fireEvent)
  • [ ] Jest compatibility shimjest.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.
  • [ ] setupFiles config — load setup files before tests (like Jest's setupFilesAfterFramework)

License

MIT