Skip to content
Colin Wren

Make Testing Expo Apps Easy With Deep Links

JavaScript, Testing, Automation, Software Development7 min read

motorway through a hole in the fence
Photo by Aaron Lau on Unsplash

I’ve spent the last seven months building a React Native app with Expo and I’m really enjoying the agility that choosing Expo is giving me.

The build service especially has saved me a bunch of time that has allowed me to spend more time dealing with the monotony of app store submissions, something that seems to get more and more complex every time I do it.

While Deep Links aren’t unique to Expo, there are a few extra complexities that you face as your app will run in the Expo client or as a standalone application with both having a different Deep Link structure.

What are Deep Links?

Deep Links (also called Custom URL schemes) are a Uniform Resource Identifier (URI) that, when opened on a device that supports them, opens the appropriate app listening for that particular ‘scheme’. That app can then use the URI to take the user directly to a screen in the app or set the app state in order to configure a desired behaviour.

For example the Deep Link fb://profile/6661337 when accessed on a device that has the Facebook app installed and supports Deep Links will open the Facebook app for the user and take them to the profile page with the supplied ID.

Why use Deep Links for testing?

Aside from being able to have a user launch your app when they press a link in another app on your their device, Deep Links have a number of benefits.

They allow for a more streamlined UX as launching your app will usually be quicker than waiting for the link to load on a webpage, especially if there’s a need for the user to authenticate and they are already logged into your app.

They allow for a quicker means of launching into specific app states, such as adding products to a basket or visiting a particular post without the need for the user to perform the actions required to get to that state themselves — this can mean higher engagement and conversion rates.

This latter aspect is the one that is most useful for testing, as being able to set the app into a state that meets the prerequisites of a test case saves time and yields more consistent results.

Deep Links also allow for path and query string parameters to be defined so there’s the ability to create the state dynamically based on values passed to the app in this manner.

Handling Deep Links in Expo

In order to set up Deep Links for your Expo app you first need to decide on a scheme name.

This can be your apps name or another string and can contain ., + or - if you need to break the string up, although I would avoid these to ensure maximum compatibility.

Once you’ve decided on a scheme you then add this to your app.json file under the expo.scheme property.

2 "expo": {
3 "name": "ExampleApp",
4 "slug": "exampleApp",
5 "scheme": "exampleapp"
6 }
The scheme determines the protocol part of the Deep Link URI, for instance exampleapp://

This will add the appropriate entries to the standalone version of your app so that it’s associated with URIs using that scheme.

Then in your app code you can use the expo-linking library to call the getInitialURL() function, which will return a promise of the Deep Link URI when the app is launched via the Deep Link.

For detecting Deep Links when the app is running in the foreground you can use addEventListener('url', callback) to set up a listener for URIs.

The foreground event listener is useful for handling authentication flows where you’ll send the user to a website to login and redirect them to the app via a Deep Link. It is also useful for testing as you don’t need to close and relaunch the app to change the app’s state.

Once you have the Deep Link URI you can extract the different components from it using parse(), this will also account for the difference between the Expo client and standalone URIs.

I found the best place to use getInitialURL() was inside the startAsync function of the Expo AppLoading component.

1import * as Linking from 'expo-linking';
2import { initialState } from 'contexts/AppContext';
4function getInitialState(initialUrl) {
5 const { path, queryParams } = Linking.parse(initialUrl);
6 switch (path) {
7 case 'my-deep-link':
8 return { some: 'state', to: 'set' };
9 default:
10 return initialState;
11 }
14async function loadState(setAppState) {
15 const initialUrl = await Linking.getInitialURL();
16 const initialState = getInitialState(initialUrl);
17 return setAppState(initialState);
20export default function App() {
21 const [stateLoaded, setStateLoaded] = useState(false);
22 const [appState, setAppState] = useState(null);
23 if (!stateLoaded) {
24 // Load the app state
25 return (
26 <AppLoading
27 startAsync={() => loadState(setAppState)}
28 onFinish={() => setStateLoaded(true)}
29 onError={() => console.log('Oh noes')}
30 />
31 )
32 } else {
33 // Show the app
34 return <ProperApp state={appState} />;
35 }
The AppLoading component allows for async operations to be completed within the main entry point, this is useful as this entry point cannot be an async function

This allows the Deep Link to be parsed and for you to set the state for the app while the app is still loading, which is useful if for instance you want to use Deep Links to put your app in a particular state for testing or for demonstrating it’s functionality.

Difference in URI for standalone apps and the Expo Client app

During development it’s likely you’ll be running your app using the Expo Client app. The Expo Client makes it really easy to test your app as you’re developing it however it can make working with Deep Links hard.

The Expo Client will not use the Deep Link scheme you define in app.json as it has it’s own schema exp:// and requires your path to be prefixed with the hostname of the Expo server to get the app files from and--/ .

For apps running against a local Expo server you’ll find the hostname to use on the Expo Developer Tools page above the QR code or in the terminal that’s running the process, again above the QR code.

The resulting URI would look like exp://[HOSTNAME AND PORT]/--/testing/reset-db for an app running locally or exp://[USERNAME]/[APP SLUG]/--/testing/reset-db for an app published to Expo.

Standalone apps will use the scheme defined in app.json and do not require the hostname nor the --/ prefix so the examples above would be [APP SCHEME]://testing/reset-db for a standalone app.

To handle this in my Appium based automation tests I passed in an environment variable that I could use to create the appropriate Deep Link when running the test suite against the Expo Client and the standalone app.

1function getDeepLinkUri(platform, optionalBehaviour) {
2 const prefix = platform === 'EXPO_CLIENT' ? 'exp://' : 'exampleapp://';
3 const path = optionalBehaviour ? 'testing/thing2' : 'testing/thing1';
4 return `${prefix}/${path}`;
7async function launchApp(appCaps) {
8 const client = await remote(appCaps);
9 const launchUri = getDeepLinkUri(env.RUNTIME_ENV, true);
10 await fireOffDeepLink(launchUri);
11 return client;
In getDeepLinkUri optional behaviours can be passed in to create Deep Links that set the app state into a specific configuration

Triggering Deep Links for testing

There are a number of ways to trigger Deep Links, some are better for manual testing on device and some are better for automation.

Using a web browser

Deep Links are meant to bridge the gap between apps and external services so you’d think it’s only natural that you can launch Deep Links from within the web browser.

On Android Chrome doesn’t allow custom schemes to be entered directly into the address bar, doing so just performs a Google search. Instead you need to craft a web page that has an anchor tag ( <a> ) with the Deep Link as the href attribute.

On iOS you can enter the Deep Link URI into the address bar of Safari and it will ask you if you want to open the app. Similar to Android this does not work in Chrome for iOS.

QR Code

This is my preferred method of triggering Deep Links when using an actual device, especially if that device is not plugged into a computer for debugging.

The process is to use a tool like QR Code Generator to generate a QR code for the Deep Link URI and then use the camera app on the device to scan it.

On iOS you can use the built-in camera app and when the QR is scanned it will ask you if you want to open the app, on Android you may need to download a QR scanning app if you’re camera app doesn’t support QR codes.

I like this approach as the QR codes are easy to create, share & scan and as you’re not typing in complex URIs you’re less likely to make mistakes which speeds up the testing process.

Android Debug Bridge (ADB)

If you’re running your app on a local Android emulator or your Android device is plugged in and developer mode has been activated you can use adb to trigger the Deep Link for you.

You’ll need to install adb onto your computer and have it set in your path in order to do so but once this is done it is the easiest way to trigger a Deep Link on the emulator.

In order to trigger a Deep Link you need to run adb shell am start -d [DEEP LINK URI] in your terminal and the device will open your app immediately (unlike iOS that asks you if you want to open the app).

xcrun command

For iOS the equivalent of adb is xcrun and in a similar manner you can use it to trigger Deep Links.

xcrun is installed when you install the Xcode Command Line tools and is only available on a Mac. This approach, like adb is only useful for local simulators and plugged in devices.

In order to trigger a Deep Link you need to run xcrun simctl openurl booted [DEEP LINK URI] in your terminal and the device will ask you if you want to open your app.


Appium is my automation framework of choice and has a shortcut that makes launching Deep Links on Android easy.

Unfortunately the same cannot be said for iOS. Thankfully, Appium Pro published an article about Deep Links and Appium which is really useful for understanding your options.

To launch a Deep Link on Android with Appium you can use the client.execute command mobile:deeplink and provide an options object with the URI and package:

1function getDeepLinkUri(platform, optionalBehaviour) {
2 const prefix = platform === 'EXPO_CLIENT' ? 'exp://' : 'exampleapp://';
3 const path = optionalBehaviour ? 'testing/thing2' : 'testing/thing1';
4 return `${prefix}/${path}`;
7async function launchApp(appCaps) {
8 const client = await remote(appCaps);
9 const launchUri = getDeepLinkUri(env.RUNTIME_ENV, true);
10 if (client.isAndroid) {
11 await client.execute('mobile:deepLink', { url: launchUri, package: appCaps.appPackage });
12 }
13 return client;
mobile:deepLink only works on Android

To launch a Deep Link on iOS with Appium you need to use Safari to enter the Deep Link into the address bar and accept the prompt to launch the app (there’s a capability you can set on Appium to automate accepting prompts to speed this up).

This would be easy enough if it wasn’t for the fact that iOS tends to rename the elements between versions so running your automation against the latest iOS release may require some effort to update this process.

1function getDeepLinkUri(platform, optionalBehaviour) {
2 const prefix = platform === 'EXPO_CLIENT' ? 'exp://' : 'exampleapp://';
3 const path = optionalBehaviour ? 'testing/thing2' : 'testing/thing1';
4 return `${prefix}/${path}`;
7async function enterDeepLink13_6(client, optionalBehaviour) {
8 const urlFieldSelector = 'label == "Address"';
9 const urlField = await client.$(`-ios predicate string:${ urlFieldSelector }`);
10 const deepLinkUri = getDeepLinkUri(env.RUNTIME_ENV, true);
11 // The \uE007 is a return character that acts as pressing the return key when the Deep Link is entered
12 await urlField.setValue(`${deepLinkUri}\uE007`);
15async function launchIOS(appCaps) {
16 const client = await remote(appCaps);
17 await client.execute('mobile: launchApp', { bundleId: '' });
18 await enterDeepLink13_6(client);
19 return client;
I find it easier to wrap the behaviours needed for each iOS version in a function

Expo Client history

If you’re using the Expo Client to test your app and you’ve launched the app with a Deep Link previously it will store the URI in it’s ‘Recently opened’ list.

Pressing one of these list items will launch the app with the same URI as before, however it’s worth noting that when testing against an Expo server running on the local network or via the ‘Tunnel’ option you may find the hostname has changed and these links won’t work.

If you’re testing an app that uses the foreground Deep Link listening approach though this can be a really easy way to change the app’s state as you can bring up the Expo Client menu, return to the Expo Client homescreen and select a link from the list, then go back into the app with the state set up as you need.


Deep Links are a really useful tool for testing as they allow you to configure the app easily and launch into a set app state, or if using the foreground approach even update the app state without the need to relaunch the app.

Aside from the difference in using Deep Links against the Expo Client and a standalone app, Expo makes it really easy to implement and use Deep Links which makes it incredibly simple to make your app testable.