Buy @ Amazon

Write ESM, Test CJS: The Pragmatic Blueprint for Expo 54 and React 19 using Jest for testing

Migrating an application platform to pure ECMAScript Modules (ESM) looks great on paper. Modern `import/export` syntax, cleaner tree-shaking, and alignment with Node standards are incredibly compelling reasons to make the switch.

However, when you bring pure ESM (`"type": "module"`) into the native mobile landscape with Expo 54, React 19, and Jest, you quickly run into a wall of configuration issues. Native mobile architectures rely heavily on custom asset resolution pipelines and deep native bridges that expect a CommonJS architecture during unit testing.

The good news? You don't have to give up modern syntax to avoid configuration headaches. This guide outlines the ultimate middle-ground blueprint for your next greenfield Expo React Native project: Write clean ESM code, but run your test runner in standard CommonJS mode.

The Architecture Stack

Our production-grade stack focuses on stability, rapid testing feedback loops, and modern UI development:
  • Core Framework: Expo 54 & React Native 0.81+ (Leveraging React 19 primitives)
  • Language & Linter: TypeScript & ESLint (Flat Config setup)
  • UI Foundation: React Native Paper V3 (Material Design component library)
  • Testing Suite: Jest & React Native Testing Library (RNTL)

1. The Dependencies Blueprint (`package.json`)

Notice that we explicitly omit "type": "module". This tells Node to treat the execution layer as standard CommonJS, letting Babel cleanly compile code behind the scenes while you write modern import statements in your code editor.
```
{
  "private": true,
  "name": "greenfield-expo-app",
  "version": "1.0.0",
  "scripts": {
    "start": "expo start",
    "android": "expo start --android",
    "ios": "expo start --ios",
    "test": "jest",
    "test:watch": "jest --watch"
  },
  "dependencies": {
    "expo": "~54.0.33",
    "react": "19.1.0",
    "react-native": "0.81.5",
    "react-native-paper": "^5.15.2",
    "react-native-safe-area-context": "~5.6.0",
    "react-native-screens": "~4.16.0"
  },
  "devDependencies": {
    "@testing-library/jest-native": "^5.4.3",
    "@testing-library/react-native": "^13.3.3",
    "@types/jest": "29.5.14",
    "@types/react": "~19.1.0",
    "babel-jest": "^30.4.1",
    "jest": "~29.7.0",
    "jest-expo": "~54.0.17",
    "typescript": "~5.9.2"
  }
}
```

2. Eliminating the Asset Parsing Bug (`jest.config.js`)

When testing components that map static local assets (like require('./assets/image.jpg')), Jest often tries to parse raw image binaries as JavaScript code, throwing a SyntaxError: Unexpected token.

To solve this completely, create a tiny transformer file named jest-binary-transformer.js:
```
// jest-binary-transformer.js
module.exports = {
  process() {
    return { code: 'module.exports = 1;' };
  },
};
```
Now, hook this transformer into your centralized `jest.config.js` file. By pulling directly from the `jest-expo/android` preset, you ensure that native core requirements like `PlatformConstants` are automatically injected into your tests:
```
// jest.config.js
const androidPreset = require('jest-expo/android/jest-preset.js');

module.exports = {
  ...androidPreset,
  setupFiles: ['<rootDir>/jest-setup.js'],
  injectGlobals: true,

  transform: {
    // 1. Compile source typescript and imports with babel
    '^.+\\.(js|jsx|ts|tsx)$': 'babel-jest',

    // 2. Intercept asset binaries and turn them into safe mock styles instantly
    '\\.(jpg|jpeg|png|gif|webp|svg|ttf|mp4|mp3)$': '<rootDir>/jest-binary-transformer.js',
  },

  transformIgnorePatterns: [
    'node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|react-navigation|@react-navigation/.*|react-native-paper|react-native-reanimated|expo-modules-core)',
  ],

  setupFilesAfterEnv: [
    '@testing-library/jest-native/extend-expect'
  ],
};
```

3. Safe Platform Global Initializations (`jest-setup.js`)

Keep your application global variables isolated from native dependency lifecycles. This baseline setup file establishes necessary properties for modern React Native component trees without breaking layout engines:
```
// jest-setup.js
globalThis.__DEV__ = true;

// Pre-seed core batch bridges to enable fluid layout hooks
globalThis.__fbBatchedBridgeConfig = {
  remoteModuleConfig: [],
  localModulesConfig: []
};

// Mock fallback definitions for basic expo system constraints
jest.mock('expo-constants', () => ({
  manifest: {},
  expoConfig: { name: 'greenfield-app', slug: 'greenfield-app' },
  Constants: { manifest: {} },
}));
```

4. Writing Clean Lint Rules with Flat Configs (`eslint.config.cjs`)

Because the project root handles file operations via CommonJS, write your ESLint configuration using the `.cjs` extension. However, set your sourceType parser option to 'module'. This ensures that your linter fully approves of modern ESM import statements throughout your development code:
```
// eslint.config.cjs
const js = require('@eslint/js');
const tsPlugin = require('@typescript-eslint/eslint-plugin');
const tsParser = require('@typescript-eslint/parser');
const react = require('eslint-plugin-react');
const reactNative = require('eslint-plugin-react-native');
const globals = require('globals');

module.exports = [
  {
    files: ['**/*.ts', **/*.tsx'],
    languageOptions: {
      parser: tsParser,
      parserOptions: {
        ecmaVersion: 2020,
        sourceType: 'module', // Approved for modern import syntax!
      },
      globals: {
        ...globals.node,
        jest: true,
        describe: true,
        test: true,
        expect: true,
      },
    },
    plugins: {
      '@typescript-eslint': tsPlugin,
      react,
      'react-native': reactNative,
    },
    rules: {
      ...js.configs.recommended.rules,
      ...tsPlugin.configs.recommended.rules,
      ...react.configs.recommended.rules,
      'react/react-in-jsx-scope': 'off',
      'no-console': 'warn',
    },
  },
];
```

Summary Takeaway for Greenfield Teams

When kickstarting a new React Native project on Expo 54, do not let strict runtime ESM requirements slow you down. By treating ESM as a syntax standard for your source files rather than an execution rule for your test runner, you get the best of both worlds. You can write modern, future-proof code while enjoying fast, reliable test execution without the configuration headaches.