hermes-test 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/README.md +421 -0
  2. package/package.json +4 -4
package/README.md ADDED
@@ -0,0 +1,421 @@
1
+ # hermes-test
2
+
3
+ **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.
4
+
5
+ ```
6
+ 1472 tests — 0.84s (cached) | 5s with coverage
7
+ ```
8
+
9
+ <p align="center">
10
+ <img src="docs/demo.gif" alt="hermes-test demo — 1472 tests with coverage in 5s" width="800">
11
+ </p>
12
+
13
+ > **⚠️ Early release (v0).** hermes-test is battle-tested on a production Expo app (1472 tests, 100% pass rate) but the API may still change.
14
+ >
15
+ > **Recommended approach:** Use `.hermes.test.ts` as 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.
16
+
17
+ ---
18
+
19
+ ### The problem
20
+
21
+ 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.
22
+
23
+ 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.
24
+
25
+ ### The fix
26
+
27
+ 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.
28
+
29
+ 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`.
30
+
31
+ ### Benchmarks
32
+
33
+ Production Expo app (Topdanmark, Danish insurance — 259 files, 1472 tests):
34
+
35
+ | | Jest | hermes-test | Speedup |
36
+ |---|---|---|---|
37
+ | Full suite (no coverage) | 54s | **0.84s** cached / 2.5s cold | **64x** / **22x** |
38
+ | Full suite (with coverage) | 128s | **5s** | **26x** |
39
+ | Watch rerun | ~3s | **~300ms** | **10x** |
40
+
41
+ Micro benchmarks (Apple Silicon, no coverage):
42
+
43
+ | Scenario | hermes-test | Jest + @swc/jest | Speedup |
44
+ |----------|-------------|------------------|---------|
45
+ | 10 pure function tests | **16ms** | 714ms | **45x** |
46
+ | 50 hook tests (renderHook + act) | **75ms** | 721ms | **10x** |
47
+ | Trivial cold start | **4.6ms** | 1,486ms | **364x** |
48
+
49
+ ---
50
+
51
+ ## Quick start
52
+
53
+ ```bash
54
+ bun add -D hermes-test
55
+ ```
56
+
57
+ ```ts
58
+ // useCounter.test.ts
59
+ import { test, expect, renderHook, act } from 'hermes-test';
60
+
61
+ test('useCounter increments', () => {
62
+ const { result } = renderHook(() => useCounter(0));
63
+ act(() => result.current.increment());
64
+ expect(result.current.count).toBe(1);
65
+ });
66
+ ```
67
+
68
+ ```bash
69
+ hermes-test # run all tests
70
+ hermes-test --watch # watch mode
71
+ ```
72
+
73
+ ## API
74
+
75
+ ### Test structure
76
+
77
+ ```ts
78
+ import { test, expect, group, beforeEach, afterEach } from 'hermes-test';
79
+
80
+ group('myFeature', () => {
81
+ beforeEach(() => { /* reset */ });
82
+
83
+ test('does the thing', () => {
84
+ expect(result).toBe(42);
85
+ expect(arr).toEqual([1, 2, 3]);
86
+ expect(str).toContain('hello');
87
+ expect(fn).toThrow('error message');
88
+ });
89
+
90
+ test.skip('not yet', () => {});
91
+ test.only('focus this', () => {});
92
+ test('slow test', () => { /* ... */ }, { timeout: 10000 });
93
+ });
94
+ ```
95
+
96
+ ### Assertions
97
+
98
+ ```ts
99
+ expect(val).toBe(exact) expect(val).toEqual(deep)
100
+ expect(val).toBeTruthy() expect(val).toBeFalsy()
101
+ expect(val).toBeDefined() expect(val).toBeUndefined()
102
+ expect(val).toBeNull() expect(val).toBeGreaterThan(n)
103
+ expect(val).toContain(item) expect(val).toContainEqual(item)
104
+ expect(val).toMatch(/regex/) expect(val).toBeCloseTo(n, precision)
105
+ expect(fn).toThrow('msg') expect(val).not.toBe(other)
106
+
107
+ // Asymmetric matchers
108
+ expect.anything() expect.any(String)
109
+ expect.objectContaining({ key }) expect.arrayContaining([1, 2])
110
+ expect.stringContaining('sub') expect.stringMatching(/pattern/)
111
+
112
+ // Async
113
+ await expect(promise).resolves.toBe(value)
114
+ await expect(promise).rejects.toThrow('msg')
115
+ ```
116
+
117
+ ### Spies
118
+
119
+ ```ts
120
+ import { spy, spyOn, clearAllMocks } from 'hermes-test';
121
+
122
+ const fn = spy(() => 'default');
123
+ fn.mockReturnValue('mocked');
124
+ fn.mockReturnValueOnce('first');
125
+ fn.mockImplementation((x) => x * 2);
126
+ fn.mockResolvedValue('async');
127
+
128
+ expect(fn).toHaveBeenCalled();
129
+ expect(fn).toHaveBeenCalledWith('arg1', 'arg2');
130
+ expect(fn).toHaveBeenCalledTimes(3);
131
+ expect(fn.calls[0][0]).toBe('arg1'); // direct access
132
+
133
+ // spyOn — intercept real object methods
134
+ const s = spyOn(storage, 'get');
135
+ s.mockReturnValue('cached');
136
+ s.mockRestore(); // revert to original
137
+
138
+ // Clear all spies at once
139
+ clearAllMocks();
140
+ ```
141
+
142
+ ### Module mocking
143
+
144
+ ```ts
145
+ import { mockModule } from 'hermes-test';
146
+ import { useMyHook } from './useMyHook'; // import order doesn't matter
147
+
148
+ mockModule('./useRedux', () => ({
149
+ useAppSelector: (selector) => mockState,
150
+ }));
151
+ ```
152
+
153
+ Shadow wrappers check mocks at call time — `mockModule` can appear before or after imports.
154
+
155
+ ### Hook testing
156
+
157
+ ```ts
158
+ import { renderHook, act, waitFor } from 'hermes-test';
159
+
160
+ const { result, history, renderCount } = renderHook(() => useCounter(0));
161
+ act(() => result.current.increment());
162
+ expect(result.current.count).toBe(1);
163
+ expect(renderCount).toBe(2);
164
+ ```
165
+
166
+ ### Fetch mocking (MSW-style)
167
+
168
+ ```ts
169
+ import { mockFetch, mockFetchUse, mockFetchReset, http, HttpResponse } from 'hermes-test';
170
+
171
+ mockFetch(
172
+ http.get('https://api.example.com/data', () => HttpResponse.json({ ok: true })),
173
+ http.post('https://api.example.com/login', () => HttpResponse.json({ token: '...' })),
174
+ );
175
+
176
+ // Per-test override
177
+ mockFetchUse(http.get('https://api.example.com/data', () => HttpResponse.error()));
178
+
179
+ // Cleanup
180
+ mockFetchReset();
181
+ ```
182
+
183
+ ### Redux store
184
+
185
+ ```ts
186
+ import { setupApiStore } from 'hermes-test/store';
187
+
188
+ const ctx = setupApiStore([api, cms], { app: rootReducer }, {
189
+ preloadedState: { app: { auth: { session: mockSession } } },
190
+ });
191
+ const { result } = ctx.renderHookWithReduxStore(() => useMyHook());
192
+ ctx.store.dispatch(authActions.logout());
193
+ ```
194
+
195
+ ### Fake timers
196
+
197
+ ```ts
198
+ import { useFakeTimers, advanceTimersByTime, useRealTimers } from 'hermes-test';
199
+
200
+ useFakeTimers();
201
+ setTimeout(() => { fired = true }, 1000);
202
+ advanceTimersByTime(1000);
203
+ expect(fired).toBe(true);
204
+ useRealTimers();
205
+ ```
206
+
207
+ ## How it works
208
+
209
+ ```
210
+ ┌──────────────┐ ┌─────────┐ ┌────────────┐
211
+ │ .test.ts │────▶│ esbuild │────▶│ Hermes │
212
+ │ files │ │ bundle │ │ VM eval │
213
+ └──────────────┘ └─────────┘ └────────────┘
214
+ │ │ │
215
+ mockModule() <100ms bundle native execution
216
+ spy/expect path aliases drainMicrotasks
217
+ renderHook Hermes patches real React tree
218
+ ```
219
+
220
+ 1. **esbuild** bundles your test + source into a single IIFE (~100ms)
221
+ 2. Rust CLI applies **Hermes patches** (class-extends, for-let-of)
222
+ 3. **Bytecode compilation** — cached .hbc for instant loading on subsequent runs
223
+ 4. **Hermes VM** evaluates the bytecode — same engine as your app
224
+ 5. Results printed to terminal — single process, no workers, no IPC
225
+
226
+ ### Three-tier cache
227
+
228
+ | Tier | What | Speed |
229
+ |---|---|---|
230
+ | **Bytecode (.hbc)** | Pre-compiled Hermes bytecode | Fastest — skip JS parsing |
231
+ | **Patched JS** | Post-patched esbuild output | Fast — skip bundling + patching |
232
+ | **Fresh bundle** | Full esbuild + patch pipeline | Cold start only |
233
+
234
+ ### Auto-detect native externals
235
+
236
+ 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.
237
+
238
+ ### Mock isolation (Shadow Wrappers)
239
+
240
+ 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.
241
+
242
+ ## CLI
243
+
244
+ ```bash
245
+ hermes-test # run all test files
246
+ hermes-test src/hooks/ # run tests in a directory
247
+ hermes-test src/hooks/useLogin.test.ts # run a specific file
248
+ hermes-test --watch # watch mode — reruns on file changes
249
+ hermes-test --watch useLogin # watch mode, filtered to matching files
250
+ hermes-test --coverage # run with coverage (lcov + HTML report)
251
+ ```
252
+
253
+ ## Configuration
254
+
255
+ ### Polyrepo (single package)
256
+
257
+ No config file needed for simple projects. Just run `hermes-test` in your project root.
258
+
259
+ ```
260
+ my-app/
261
+ ├── src/
262
+ │ └── hooks/
263
+ │ └── useLogin.hermes.test.ts
264
+ ├── package.json
265
+ └── tsconfig.json ← path aliases read automatically
266
+ ```
267
+
268
+ ### Monorepo
269
+
270
+ 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`).
271
+
272
+ ```
273
+ monorepo/
274
+ ├── apps/
275
+ │ └── my-app/
276
+ │ ├── src/
277
+ │ ├── hermes-test.config.json ← config here
278
+ │ ├── package.json
279
+ │ └── tsconfig.json
280
+ ├── packages/
281
+ │ └── shared/
282
+ └── node_modules/ ← root points here
283
+ ```
284
+
285
+ ```json
286
+ {
287
+ "root": "../..",
288
+ "testMatch": ".hermes.test.ts"
289
+ }
290
+ ```
291
+
292
+ ### hermes-test.config.json
293
+
294
+ | Key | Description | Required |
295
+ |-----|-------------|----------|
296
+ | `root` | Monorepo workspace root (for resolving node_modules) | Monorepo only |
297
+ | `testMatch` | Test file suffix (default: `.test.ts`) | No |
298
+ | `externals` | Additional modules to externalize | No (most auto-detected) |
299
+ | `shims` | Built-in or custom module replacements | No |
300
+ | `split` | Enable vendor/group bundle splitting for large suites | No |
301
+ | `coverageThreshold` | Minimum coverage % — fails if below (e.g. `65`) | No |
302
+
303
+ **tsconfig paths** are read automatically — monorepo path aliases just work:
304
+
305
+ ```json
306
+ {
307
+ "compilerOptions": {
308
+ "paths": {
309
+ "@app/*": ["./src/*"],
310
+ "@myorg/shared/*": ["../../packages/shared/src/*"]
311
+ }
312
+ }
313
+ }
314
+ ```
315
+
316
+ **Native externals** are auto-detected by scanning `node_modules` for `ios/`, `android/`, `*.podspec`, and `app.plugin.js`. Most projects need zero manual `externals`.
317
+
318
+ ### Built-in shims
319
+
320
+ 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.
321
+
322
+ | Shim | What it provides |
323
+ |------|-----------------|
324
+ | `hermes-test/shims/react-native` | Platform, StyleSheet, Dimensions, Alert, Linking stubs |
325
+ | `hermes-test/shims/react-i18next` | Identity translation (`t('key')` returns `'key'`) |
326
+ | `hermes-test/shims/async-storage` | In-memory AsyncStorage (getItem, setItem, clear, etc.) |
327
+ | `hermes-test/shims/rtk-query` | RTK Query createApi singleton cache |
328
+ | `hermes-test/shims/react-redux` | Pass-through for react-redux |
329
+ | `hermes-test/shims/reduxjs-toolkit` | Pass-through for @reduxjs/toolkit |
330
+
331
+ Example config with shims:
332
+
333
+ ```json
334
+ {
335
+ "root": "../..",
336
+ "testMatch": ".hermes.test.ts",
337
+ "shims": {
338
+ "react-i18next": "hermes-test/shims/react-i18next",
339
+ "@reduxjs/toolkit/query/react": "hermes-test/shims/rtk-query",
340
+ "@react-native-async-storage/async-storage": "hermes-test/shims/async-storage"
341
+ }
342
+ }
343
+ ```
344
+
345
+ You can also write custom shims for app-specific native modules:
346
+
347
+ ```json
348
+ {
349
+ "shims": {
350
+ "react-native-keychain": "./test/shims/keychain.js"
351
+ }
352
+ }
353
+ ```
354
+
355
+ ## Coverage
356
+
357
+ ```bash
358
+ hermes-test --coverage
359
+ ```
360
+
361
+ Generates:
362
+ - **Terminal table** — per-file line + function coverage with color coding
363
+ - **`coverage/lcov.info`** — standard lcov format, works with any lcov tool
364
+ - **`coverage/index.html`** — interactive HTML report with source-level green/red highlighting
365
+
366
+ 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.
367
+
368
+ ### Coverage threshold
369
+
370
+ Add `coverageThreshold` to `hermes-test.config.json` to fail CI when coverage drops:
371
+
372
+ ```json
373
+ {
374
+ "coverageThreshold": 65
375
+ }
376
+ ```
377
+
378
+ If total statement coverage is below the threshold, hermes-test exits with code 1.
379
+
380
+ ## Stack
381
+
382
+ - **Hermes** — the JS engine that ships with React Native and Expo
383
+ - **esbuild** — bundler, 100x faster than Babel/Metro transforms
384
+ - **Rust** — CLI host, native Hermes FFI, bytecode caching
385
+ - **TypeScript** — test harness (spy, expect, renderHook, mockFetch, timers)
386
+
387
+ ## Why not Jest?
388
+
389
+ | | Jest + jest-expo | hermes-test |
390
+ |---|-----------------|-------------|
391
+ | Bundling | Babel on every import | esbuild, one pass |
392
+ | Startup | ~700ms per worker | ~5ms total |
393
+ | Native externals | Manual `transformIgnorePatterns` | Auto-detected |
394
+ | Config needed | `transformIgnorePatterns`, `moduleNameMapper`, mocks | Zero for most projects |
395
+ | Watch rerun | ~2-3s | ~300ms |
396
+ | 1472 tests (no coverage) | 54s | **0.84s** |
397
+ | 1472 tests (with coverage) | 128s | **5s** |
398
+ | Coverage | Built-in (v8/Istanbul) | `--coverage` with source maps, HTML report, threshold |
399
+ | Engine | Node | Hermes (same as your app) |
400
+
401
+ ## Platform support
402
+
403
+ | Platform | Status |
404
+ |----------|--------|
405
+ | macOS (Apple Silicon) | Supported |
406
+ | Linux (x64) | Supported |
407
+ | macOS (Intel x64) | Planned |
408
+ | Windows | Not planned |
409
+
410
+ ## Roadmap
411
+
412
+ - [x] **Coverage reporting** — source map-based instrumentation, lcov + HTML report, threshold enforcement
413
+ - [ ] **macOS Intel (x64)** — cross-compile or dedicated CI runner
414
+ - [ ] **Component rendering** — `render(<Component />)` with query API (`getByText`, `getByTestId`, `fireEvent`)
415
+ - [ ] **Jest compatibility shim** — `jest.fn()` → `spy()`, `jest.mock()` → `mockModule()`, enables reuse of library `__mocks__/` files
416
+ - [ ] **Library mock support** — auto-load mocks from expo-router, react-native-reanimated, zustand, etc.
417
+ - [ ] **`setupFiles` config** — load setup files before tests (like Jest's `setupFilesAfterFramework`)
418
+
419
+ ## License
420
+
421
+ MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hermes-test",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "147x faster than Jest. Test runner for React Native and Expo that runs in Hermes.",
5
5
  "main": "src/index.ts",
6
6
  "types": "index.d.ts",
@@ -50,9 +50,9 @@
50
50
  "react": ">=18"
51
51
  },
52
52
  "optionalDependencies": {
53
- "@hermes-test/darwin-arm64": "0.2.0",
54
- "@hermes-test/darwin-x64": "0.2.0",
55
- "@hermes-test/linux-x64": "0.2.0"
53
+ "@hermes-test/darwin-arm64": "0.2.1",
54
+ "@hermes-test/darwin-x64": "0.2.1",
55
+ "@hermes-test/linux-x64": "0.2.1"
56
56
  },
57
57
  "files": [
58
58
  "src/",