Upwell

Testing and simulation

Step through every update state in a dev build or Expo Go, and test your app's update logic in Jest.

Testing in-app updates for real needs Play internal app sharing, TestFlight, and two builds. With simulation you can see every state in seconds, in a dev build or even in Expo Go.

simulate()

const { simulate } = useAppUpdate();

await simulate('store.downloading'); // jump to a state
await simulate(null);                // back to the real device
ScenarioWhat you get
upToDateInstalled build is the latest
store.recommendedNewer build available. Tap "Update" to go through the download
store.downloadingA flexible download in progress (Android)
store.readyToInstallA downloaded flexible update (Android)
store.requiredInstalled build below minimum
strandedRuntime 3.3.0 while OTA updates target 3.4.0
ota.downloadingAn OTA update downloading
ota.readyA downloaded OTA update, with applyMode: "prompt"
errorNo policy (simulated network error)

Simulation replaces the device, the store, the OTA tool and the policy with fakes, then runs the real controller and engine on them. Actions work too: "Update" downloads, "Restart" installs, "Later" snoozes. It uses in-memory storage, so it never touches real snoozes or cached policies.

Options

simulate('store.recommended', { platform: 'ios', stepMs: 200, userAccepts: false });
OptionDefault
platformthe device'sPretend to be 'ios' or 'android'. Download states are Android-only
stepMs400Delay between simulated steps (download progress ticks)
userAcceptstrueWhether the simulated user accepts Play's dialog
nativePlaytrue when linkedAndroid: use Play's FakeAppUpdateManager (see below)

The real Kotlin path

On Android with the native module linked, store.recommended and store.required don't use the JavaScript fake. They configure Play's FakeAppUpdateManager inside the native module, so the real Kotlin code runs: the accept dialog, download progress events and completeUpdate(). Pass nativePlay: false to use the JavaScript fake instead.

The dev menu

<UpwellDevMenu /> puts all of this on screen: a button per scenario, a "real device" button, and the live trace.

import { UpwellDevMenu } from 'upwell/ui';

export function DebugScreen() {
  return <UpwellDevMenu style={{ flex: 1 }} />;
}

Only render it in debug builds, or behind your own internal flag: anyone who can open it can change what the app shows.

Unit tests

Pass a fake platform and a static policy, and assert on the decision. createSimulation() builds the fakes for a scenario:

__tests__/updates.test.tsx
import { render, screen } from '@testing-library/react-native';
import { Text } from 'react-native';
import { createSimulation, staticSource } from 'upwell';
import { UpwellProvider } from 'upwell/react';
import { UpwellUI } from 'upwell/ui';

test('blocks the app when an update is required', async () => {
  const sim = await createSimulation('store.required', { platform: 'android', nativePlay: false });
  await render(
    <UpwellProvider platform={sim.platform} ota={sim.ota} policy={staticSource(sim.policy!)}>
      <Text>Home</Text>
      <UpwellUI />
    </UpwellProvider>,
  );
  expect(await screen.findByTestId('upwell-force-update')).toBeTruthy();
  sim.platform.dispose();
});

The UI kit's components have stable testIDs (upwell-update-prompt, upwell-force-update, upwell-restart, …), listed in the UI kit reference.

For your own policies, test the engine directly. It's a pure function:

import { decide } from 'upwell';
import policy from '../upwell/policy.json';

test('build 290 must update', () => {
  const decision = decide({
    installed: {
      platform: 'android',
      appId: 'com.example',
      nativeVersion: '2.9.0',
      buildNumber: '290',
      runtimeVersion: '2.9.0',
      otaUpdateId: null,
      installSource: 'play',
      installationId: 'test',
    },
    policy: policy as never,
    now: Date.parse('2026-10-21'),
  });
  expect(decision.kind).toBe('store.required');
});

On real devices

Simulation covers Upwell's logic and native code paths, but before a big release it's still worth testing the real stores once:

  • Android: internal app sharing with two builds.
  • iOS: TestFlight builds get store links, not the App Store sheet. To see the sheet, test against the live App Store version with an older build installed from the App Store.

On this page