Testing your app
Testing your app
The SDK resolves its native module when it is imported, through TurboModuleRegistry.getEnforcing. Under Jest there is no native binary, so any test file that imports the SDK, directly or through one of your components, fails before a test runs:
TurboModuleRegistry.getEnforcing(...): 'NativeCodemagicPatch' could not be found.
The package ships a fake of that native module for Jest. The real SDK runs on top of it, so sync(), checkForUpdate() and the rest behave as they do in the app.
Set up Jest
Keep the preset you already use, add the setup file, and add @codemagic to the allowlist your transformIgnorePatterns already has:
// jest.config.js
module.exports = {
preset: '@react-native/jest-preset', // or 'react-native' on older RN, or 'jest-expo'
setupFiles: ['@codemagic/react-native-patch/jest-setup'],
transformIgnorePatterns: [
'node_modules/(?!((jest-)?react-native|@react-native(-community)?|@codemagic)/)',
],
};
Under Jest the package resolves to its TypeScript source, as React Native itself does, so it has to be transformed. The setup file installs the default scenario: the app runs the store binary and the server has no update. That is all a test needs if it only has to load the SDK.
Drive the SDK into an OTA state
For another state, install a fake with a seed, then load a fresh SDK with loadSdk(). Each test starts with its own fake:
import { installPatchFake, loadSdk } from '@codemagic/react-native-patch/jest';
test('store binary, nothing installed', async () => {
installPatchFake();
const Patch = await loadSdk();
expect(await Patch.getRunningBundleUpdateMetadata()).toBeNull();
});
test('running a release', async () => {
installPatchFake({ running: { label: 'v4', releaseNotes: 'Fixes' } });
const Patch = await loadSdk();
expect(await Patch.getRunningBundleUpdateMetadata()).toMatchObject({ label: 'v4' });
});
test('first launch of a new release, before notifyAppReady()', async () => {
installPatchFake({
running: { label: 'v5' },
runningUnconfirmed: true,
previous: { label: 'v4' }, // what native would roll back to
});
const Patch = await loadSdk();
await expect(Patch.notifyAppReady()).resolves.toBeUndefined();
});
test('the server offers the release that failed on this device', async () => {
installPatchFake({
running: { label: 'v4' },
failedInstall: { label: 'v5' },
server: { release: { label: 'v5' } },
});
const Patch = await loadSdk();
const check = await Patch.checkForUpdate();
expect(check.remotePackage?.previouslyFailed).toBe(true);
expect(await Patch.sync()).toBe('up-to-date');
});
The seed also takes binaryVersion, deploymentKey, isMandatory on a package, and server.latestBinaryVersion for a store update. For the full shape, see client/src/fake/types.ts.
An update ready for the next launch
A device never cold-boots into "update ready", so there is no seed for it. Reach it the way the app does, by installing a server release:
test('an update is ready for the next launch', async () => {
installPatchFake({ server: { release: { label: 'v5' } } });
const Patch = await loadSdk();
const status = await Patch.sync({
installMode: Patch.InstallMode.ON_NEXT_RESTART,
// A mandatory release installs IMMEDIATE unless this says otherwise.
mandatoryInstallMode: Patch.InstallMode.ON_NEXT_RESTART,
});
expect(status).toBe('update-installed');
expect(await Patch.isNextVersionReady()).toBe(true);
});
sync() calls notifyAppReady() first, so it confirms an unconfirmed running release. To keep it unconfirmed, use checkForUpdate(), downloadUpdate() and installUpdate() instead. restartApp() resolves without reloading JS: afterwards the SDK still reports the update as ready, not running. To test the next launch, install a fake seeded with that release as running.
Failures and server changes
installPatchFake() returns a handle. fake.fail(method, error) makes every later call of one native method reject until you pass null. fake.setServer() replaces what later update checks get:
test('a download error', async () => {
const fake = installPatchFake({ server: { release: { label: 'v5' } } });
fake.fail('downloadUpdate', new Error('Connection reset'));
const Patch = await loadSdk();
expect(await Patch.sync()).toBe('error');
fake.fail('downloadUpdate', null);
fake.setServer({ release: null });
expect(await Patch.sync()).toBe('up-to-date');
});
To make the update check itself fail, seed server: { error } or fail fetchManifest.
Keep scenarios isolated
- Install before the SDK is imported. An SDK instance reads the native module and boot state once, when it is imported.
installPatchFake()only affects SDK instances imported after it, so callloadSdk()after installing, or install before importing the code under test. - One scenario at a time. A test file has one current fake, and
loadSdk()does not reset it: failures, server answers and downloaded packages stay. Start each scenario with a newinstallPatchFake()andloadSdk(). - Require SDK users after
loadSdk().loadSdk()resets Jest's module registry and imports a fresh SDK. Anything else that uses the SDK, such as your components, must be required after it to share that instance. - Keep React on one side of the reset. The reset clears every module, React included. For a component test, either require React, the renderer and the component after
loadSdk(), or install the scenario in a module imported first in the test file and skiploadSdk(), so nothing is loaded twice.
Vitest
The same fake works under Vitest. Add the plugin, which points the package at its TypeScript source so Vitest's module mocks reach the SDK, and the setup file, which installs the store-binary scenario:
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import { codemagicPatch } from '@codemagic/react-native-patch/vitest-plugin';
export default defineConfig({
plugins: [codemagicPatch()],
test: { setupFiles: ['@codemagic/react-native-patch/vitest-setup'] },
});
Then use installPatchFake and loadSdk from @codemagic/react-native-patch/vitest as above. The adapter replaces react-native with a mock that has only what the SDK uses (TurboModuleRegistry, AppState, DeviceEventEmitter), so render components with react-dom in a jsdom environment. Vitest does not reset React on loadSdk(), so the test and the SDK share one React instance.
Not covered yet
The fake covers the React Native SDK only, not Capacitor. It does not cover code signing (it reports no signing key), download progress, rollout percentages, reverting to the store binary's bundle, or activating an installed update on restart.