bun-test-react-native

22 August 2026

BunReact NativeTesting

Giving Bun the runtime React Native expects

How platform resolution, Flow transforms, CommonJS interop, and native-module mocks recreated the runtime that React Native packages expect.

We wanted our React Native tests to run in Bun, alongside our web and backend tests. That meant taking over work that Jest and the React Native toolchain had been doing for us.

The test runner is only the visible part of the toolchain. React Native packages are written with assumptions supplied by Metro, Babel, Jest, and a native host. Remove those systems and ordinary imports start asking questions that Node-style resolution cannot answer:

  • Is View.ios.tsx, View.native.tsx, or View.tsx the correct file?
  • Should a package's react-native field replace its normal entrypoint?
  • Who strips Flow syntax before Bun parses a dependency?
  • What does NativeModules return when there is no device?
  • How does jest.requireActual() preserve the module identity expected by package mocks?
  • What happens to a PNG import or a CommonJS file that creates named exports dynamically?

bun-test-react-native handles those gaps. We built it around the behaviour that React Native and Expo packages needed to load and run in tests.

Resolution is part of program behaviour

Metro's platform rules are semantic, not cosmetic. Importing ./View on iOS means searching .ios.*, then .native.*, then the base file. Package resolution also prefers react-native mappings before browser, module, and main entries, and a mapping can intentionally replace a module with nothing.

The resolver reproduces those rules for relative imports, package roots, package subpaths, and directories:

Component.ios.tsx
Component.native.tsx
Component.tsx

That work uncovered a Bun runtime-plugin constraint that shaped the rest of the architecture. Runtime onResolve and onLoad hooks are pre-filtered before user filters run. Extensionless relative imports and many bare package specifiers never reach a plugin callback at all.

The fix could not live only in the resolver. Source that contains an extensionless platform import is rewritten to the selected concrete file during transformation. Namespaced specifiers are used when code must deliberately re-enter the plugin pipeline, including requireActual behaviour.

React Native source is not ordinary published JavaScript

Many packages ship source intended for Metro rather than precompiled JavaScript for a generic server runtime. The compatibility transformer has to handle several independent cases:

Source assumptionCompatibility step
Flow annotations and newer Flow match syntaxLower the match form, then strip Flow with SWC
Extensionless platform importsResolve and rewrite the specifier to the selected file
Dynamic CommonJS export definitionsProject stable named exports for ESM consumers
TypeScript or JSX in package sourceParse with the appropriate SWC loader
Images, fonts, audio, and videoReturn lightweight asset modules

The CommonJS case was especially subtle. React Native's root module defines exports dynamically. A consumer can write a static named import, but Bun cannot infer those names before evaluation.

Routing the file through onLoad does not automatically solve it: runtime plugin output is ESM-shaped and does not receive Bun's normal CommonJS wrapper. The package therefore analyses the export definitions and, when necessary, evaluates the actual CommonJS module inside an explicit wrapper that emits a default export and stable named exports.

Transformation results for dependencies are cached under node_modules/.btrn-cache. The key includes the package name and version, source hash, selected platform, and exact transform set. Compatibility stays correct when any input changes without paying the SWC cost on every test process.

NativeModules is a system, not one empty object

Once source can load, the next failures come from the absent native host.

React Native code reaches native functionality through several paths: the legacy NativeModules object, __turboModuleProxy, UIManager, generated module registries, Reanimated worklets, and Expo's global module proxy. Returning {} from one import only moves the crash a few lines later.

The setup creates one shared native-module registry and exposes it through the entry points packages actually use. It provides stable UIManager and component behaviour, TurboModule lookup, animation-frame timing, browser-like globals, and focused mocks for common Expo, Firebase, Reanimated, gesture-handler, keyboard-controller, and community packages.

Expo projects install jest-expo at the matching SDK major version. The package reuses its native-module definitions without running Jest or applying the Jest preset. That avoids maintaining a second, drifting description of Expo's native surface while keeping Bun as the runner.

The mocks are intentionally behavioural where tests depend on behaviour. Expo FileSystem uses an in-memory file model. MediaLibrary maintains assets and albums. Component refs expose methods such as measure, focus, and scrollTo. Native event APIs return removable subscriptions.

Empty mocks would let more imports load, but they would give tests nothing useful to check when an app reads a file or removes a subscription.

Module identity caused some of the hardest bugs

Mocks frequently ask for the real implementation underneath themselves. In a workspace, a plain require() from inside the compatibility package can resolve against the wrong node_modules, load a second copy, or fail to see an optional dependency installed only by the application.

Project-level resolution is anchored with createRequire() at the consuming project's working directory. The jest.requireActual bridge then routes through the React Native resolver and transformer while preserving the distinction between a mocked module and its actual implementation.

This mattered for React Native internals, Reanimated's Jest setup, Expo preset definitions, and packages whose mocks import platform-specific files. It also prevented module duplication bugs that looked like broken mocks but were really broken identity.

Real packages were the test specification

We test the resolver on its own and also run fixtures that install actual packages. Those include React Native 0.85 and 0.86, Expo SDK 56 and 57-era modules, Expo Router, Reanimated, Gesture Handler, Safe Area Context, Screens, Worklets, Skia setup, FlashList refs, Clipboard, Keyboard Controller, and Expo native modules.

Several workarounds exist because those fixtures found assumptions that isolated tests would miss:

  • Reanimated and Gesture Handler need their setup modules to share the same mocked native registry;
  • Expo HMR code imports extensionless platform files inside package source;
  • react-native root exports must work through both static imports and requireActual;
  • Worklets use source patterns that need different platform-prefix handling;
  • some Expo packages expect prettier during module evaluation even though the application never calls it;
  • Cloudflare Worker fixtures exposed a Miniflare workerd lifecycle edge while running inside the same Bun suite.

Focused tests and real-package fixtures cover the compatibility cases described here. The package fixtures are slower, but they catch assumptions our resolver tests cannot.

Maintaining the compatibility layer

We keep resolution, transforms, module loading, and native mocks separate so a change in one package does not require rewriting the whole setup. The transform cache includes package versions and source content, and Expo projects use the matching jest-expo definitions.

The real-package fixtures give us somewhere to check upgrades. When a package changes its imports or expects another native method, we can reproduce that failure in the fixture before it reaches an application suite.

Most of the work was in making imports, mocks, and module identity behave as the packages expected. Once those pieces worked, we could run the tests through Bun without stripping out the behaviour they were there to check.