Add Cypress, Playwright, and Storybook to Nx Expo Apps

Emily Xiong
11 min readFeb 14, 2024

Expo has first-class support for building full-stack websites with React, so I can leverage that to add Cypress/Playwright for E2E testing and add the Storybook for UI components.

In this blog, I am going to add Cypress, Playwright, and Storybook to my Nx Expo app:

It is an app that shows a random fact about cats:

My example Nx Expo app

I am going to go through:

Add Cypress

In this section, I am going to go through:

  • When I do not have a Nx workspace, how do I create a new Nx workspace with Expo and Cypress projects?
  • When I already have an existing Nx Workspace, how do I add the Expo and Cypress projects?
  • When I already have an existing Nx Expo app, how do I add the Cypress tests to my app?

Generate a New Nx Expo Workspace

To create a new workspace with Expo, run the following command with the flag --e2eTestRunner=cyperss:

npx create-nx-workspace@latest --preset=expo --appName=your-app-name --e2eTestRunner=cypress

In the workspace, it should create an app project and an e2e project. I can run npx nx run <your-app-name>-e2e:e2e to run the Cypress e2e tests.

Add a new Expo App in an Nx Workspace

In any Nx workspace, I can install @nx/expo by running the following command:

npx nx add @nx/expo

Now when I generate a new Expo app, I can simply use @nx/expo:application generator with the flag --e2eTestRunner=cyperss:

npx nx generate @nx/expo:app --e2eTestRunner=cypress

It should create the expo app and its Cypress e2e tests.

Add Cypress to an Existing Nx Expo App

Before I start, I need to make sure I can serve my Expo app for the web view. In this example, in my apps/cats/project.json, I got a serve target defined:

    "serve": {
"executor": "@nx/expo:serve",
"options": {
"port": 8081
}
},

So I can run the command npx nx run cats:serve to serve the app on http://localhost:8081/.

Cats in Web View

I can also use @nx/expo inferred tasks without having a serve target in project.json. In .env file, set NX_ADD_PLUGINS=true; then run the command

npx nx add @nx/expo

Then I can still run the serve command npx nx run <your expo project>:serve without a serve target defined.

Then I can set up the cypress e2e project against the web app.

First, I need to install @nx/cypress:

npx nx add @nx/cypress

Second, I create a project.json for my Cypress project. In this example, I create a project.json at apps/cats-cypress/project.json, I name the project cats-cypress:

{
"name": "cats-cypress"
}

Third, run the below command to generate Cypress configuration:

npx nx generate @nx/cypress:configuration --project cats-cypress --devServerTarget=cats:serve --baseUrl=http://localhost:8081 --verbose --bundler=none

Since the serve command is npx nx run cats:serve, so the flag is --devServerTarget is cats:serve. Also, the --baseUrl is http://localhost:8081.

It should generate cypress.config.ts and a sample test:

Cypress Project

I can run the Cypress test using the below command:

npx nx run cats-cypress:e2e

Add Cypress Tests

I change the example tests:

Expo Cypress Tests

Now I can run the command npx nx run cats-cypress:e2e and it should pass:

  2 passing (867ms)


(Results)

┌────────────────────────────────────────────────────────────────────────────────────────────────┐
│ Tests: 2 │
│ Passing: 2 │
│ Failing: 0 │
│ Pending: 0 │
│ Skipped: 0 │
│ Screenshots: 0 │
│ Video: false │
│ Duration: 0 seconds │
│ Spec Ran: app.cy.ts │
└────────────────────────────────────────────────────────────────────────────────────────────────┘


===================================================================================

(Run Finished)


Spec Tests Passing Failing Pending Skipped
┌────────────────────────────────────────────────────────────────────────────────────────────────┐
│ ✔ app.cy.ts 869ms 2 2 - - - │
└────────────────────────────────────────────────────────────────────────────────────────────────┘
✔ All specs passed! 869ms 2 2 - - -


—————————————————————————————————————————————————————————————————————————————————

> NX Successfully ran target e2e for project cats-cypress (15s)

Add Playwright

Alternatively, I can use Playwright to e2e test.

In this section, I am going to go through:

  • When I do not have a Nx workspace, how do I create a new Nx workspace with Expo and Playwright projects?
  • When I already have an existing Nx Workspace, how do I add the Expo and Playwright projects?
  • When I already have an existing Nx Expo app, how do I add the Playwright tests to my app?

Generate a New Nx Expo Workspace

To create a new workspace with Expo, run the following command with the the flag --e2eTestRunner=playwright:

npx create-nx-workspace@latest --preset=expo --appName=your-app-name --e2eTestRunner=playwright

In the workspace, it should create an app project and an e2e project. I can run npx nx run <your-app-name>-e2e:e2e to run the Playwright e2e tests.

Add a new Expo App in an Nx Workspace

In any Nx workspace, I can install @nx/expo by running the following command:

npx nx add @nx/expo

Now when I generate a new Expo app, I can simply use @nx/expo:application generator with the flag --e2eTestRunner=playwright:

npx nx generate @nx/expo:app --e2eTestRunner=playwright

It should create the expo app and its Playwright e2e tests.

Add Playwright to an Existing Nx Expo App

Similarly to Cypress steps, before I start, I need to make sure I can serve my Expo app for the web view. In this example, in my apps/cats/project.json, I got a serve target defined:

   "serve": {
"executor": "@nx/expo:serve",
"options": {
"port": 8081
}
},

First, I need to install @nx/playwright:

npx nx add @nx/playwright

Second, I create a project.json for my Cypress project. In this example, I create a project.json at apps/cats-playwright/project.json, I name the project cats-playwright:

{
"name": "cats-playwright"
}

Third, run the below command to generate Cypress configuration:

npx nx g @nx/playwright:configuration --project cats-playwright --webServerCommand "nx run cats:serve" --webServerAddress http://localhost:8081

Since the serve command is npx nx run cats:serve, so the flag is --webServerCommand is nx run cats:serve. Also, the --webServerAddress is http://localhost:8081.

It should generate playwright.config.ts and a sample test file:

Playwright Project

For this example, I change the example test at apps/cats-playwright/e2e/example.spec.ts to:

import { test, expect } from '@playwright/test';

test('has title', async ({ page }) => {
await page.goto('/');

// Expect h1 to contain a substring.
expect(await page.locator('h1').innerText()).toContain('Cat Facts');
});

Run command npx nx run cats-playwright:e2e, and the tests should pass:

Playwright Report

Add Storybook

To add the storybook, simply run the command:

npx nx generate @nx/react:storybook-configuration <your expo app name>

In this example, I should see in the console output:

npx nx g @nx/react:storybook-configuration cats

> NX Generating @nx/react:storybook-configuration

✔ Do you want to set up Storybook interaction tests? (Y/n) · true
✔ Automatically generate *.stories.ts files for components declared in this project? (Y/n) · true

✔ Configure a static file server for the storybook instance? (Y/n) · true
UPDATE nx.json
UPDATE package.json
CREATE apps/cats/.storybook/main.ts
CREATE apps/cats/.storybook/preview.ts
CREATE apps/cats/tsconfig.storybook.json
UPDATE apps/cats/tsconfig.app.json
UPDATE apps/cats/tsconfig.json
UPDATE apps/cats/project.json
CREATE apps/cats/src/app/App.stories.tsx
CREATE apps/cats/src/app/bookmarks/bookmarks.stories.tsx
CREATE apps/cats/src/app/facts/facts.stories.tsx

That is it! In one single, it should generate the Storybook for me. Run the command to see Storybook in web view:

npx nx run <your-expo-app>:storybook

It interpolates my native UI components to web components and then creates the Storybook. How does it achieve that?

In this example, it generates a file apps/cats/.storybook/main.ts:

import type { StorybookConfig } from '@storybook/react-webpack5';

const config: StorybookConfig = {
stories: ['../src/lib/**/*.stories.@(js|jsx|ts|tsx|mdx)'],
addons: [
'@storybook/addon-essentials',
'@storybook/addon-interactions',
'@nx/react/plugins/storybook',
],
framework: {
name: '@storybook/react-webpack5',
options: {},
},
webpackFinal: async (config) => {
if (config.resolve) {
config.resolve.alias = {
...config.resolve.alias,
'react-native$': 'react-native-web',
};
config.resolve.extensions = [
'.web.tsx',
'.web.ts',
'.web.jsx',
'.web.js',
...(config.resolve.extensions ?? []),
];
}
return config;
},
};

export default config;

// To customize your webpack configuration you can use the webpackFinal field.
// Check https://storybook.js.org/docs/react/builders/webpack#extending-storybooks-webpack-config
// and https://nx.dev/recipes/storybook/custom-builder-configs

Notice there is a line:

      config.resolve.alias = {
...config.resolve.alias,
'react-native$': 'react-native-web',
};

It uses Webpack to bundle and alias react-native to react-native-web.

When serving up my Storybook for the first time, I have some issues and problems preventing components from rendering. Below are the issues I run into and how I troubleshoot them.

Troubleshooting

Root Element

“Full-screen React Native apps with a root <ScrollView> may require the following styles inlined in the HTML document shell.” (https://necolas.github.io/react-native-web/docs/setup/#root-element).

In this example, I do have a root element, so I need to add styles to apps/cats/.storybook/preview-head.html to make my root full height:

<style type="text/css">
/* These styles make the body full-height */
html,
body {
height: 100%;
}
/* These styles disable body scrolling if you are using <ScrollView> */
body {
overflow: hidden;
}
/* These styles make the root element full-height */
#storybook-root {
display: flex;
height: 100%;
}
</style>

Error: Couldn’t find a navigation object.

Because I am using the library @react-navigation/native and I use its hooks like useNavigtion and useRoute inside my component, I have this error:

Error: Couldn’t find a navigation object

The easiest way is just to mock this library and create a decorator for it. In this example, I create a Navigation Decorator at apps/cats/.storybook/mocks/navigation-decorator.tsx:

import { NavigationContainer } from '@react-navigation/native';
import React from 'react';

export const NavigationDecorator = (story: any) => {
return (
<NavigationContainer independent={true}>{story()}</NavigationContainer>
);
};

Then I can add this decoration to my story file. In this example, I add it to apps/cats/src/app/bookmarks/bookmarks.stories.tsx:

import type { Meta, StoryObj } from '@storybook/react';
import { Bookmarks } from './bookmarks';

import { within } from '@storybook/testing-library';
import { expect } from '@storybook/jest';
import { NavigationDecorator } from '../../../.storybook/mocks/navigation-decorator';

const meta: Meta<typeof Bookmarks> = {
component: Bookmarks,
title: 'Bookmarks',
decorators: NavigationDecorator, // <---- add the decorator here
};
export default meta;
type Story = StoryObj<typeof Bookmarks>;

export const Primary = {
args: {},
};

export const Heading: Story = {
args: {},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText(/Welcome to Bookmarks!/gi)).toBeTruthy();
},
};

Error: Couldn’t find a route object.

Error: Couldn’t find a route object.

This issue is similar to “Couldn’t find a navigation object”, it is also related to the library @react-navigation/native.

It happens because my component is using the useRoute hook and expecting certain routing parameters. I simply need to customize the mock NavigationDecorator for my component. For example, below is a component that is expecting an id from the route parameters:

const route = useRoute<RouteProp<{ params: { id: string } }>>();
const id = route.params?.id;

I can simply modify the mock NavigationDecorator at apps/cats/.storybook/mocks/navigation-decorator.tsx:

import { NavigationContainer } from '@react-navigation/native';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import React from 'react';

export const NavigationDecorator = (story: any) => {
const Stack = createNativeStackNavigator();
return (
<NavigationContainer independent={true}>
<Stack.Navigator>
<Stack.Screen
name="MyStorybookScreen"
component={story}
initialParams={{ id: 123 }}
/>
</Stack.Navigator>
</NavigationContainer>
);
};

Error: No QueryClient set, use QueryClientProvider to set one

This happens because I use the library @tanstack/react-query. I need to create a decorator for QueryClientProvider. At apps/cats/.storybook/mocks/query-client-decorator.tsx, I create this decorator:

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import React from 'react';

export const QueryClientDecorator = (story: any) => {
const queryClient = new QueryClient();
return (
<QueryClientProvider client={queryClient}>{story()}</QueryClientProvider>
);
};

export default QueryClientDecorator;

In this example, my story at apps/cats/src/app/facts/facts.stories.tsx will become:

import type { Meta, StoryObj } from '@storybook/react';
import { Facts } from './facts';

import { within } from '@storybook/testing-library';
import { expect } from '@storybook/jest';
import { NavigationDecorator } from '../../../.storybook/mocks/navigation-decorator';
import { QueryClientDecorator } from '../../../.storybook/mocks/query-client-decorator';

const meta: Meta<typeof Facts> = {
component: Facts,
title: 'Facts',
decorators: [NavigationDecorator, QueryClientDecorator], // <---- add the QueryClientDecorator here
};
export default meta;
type Story = StoryObj<typeof Facts>;

export const Primary = {
args: {},
};

export const Heading: Story = {
args: {},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText(/Welcome to Facts!/gi)).toBeTruthy();
},
};

Error: Unable to load react-native-vector-icons.

I got this error when I tried to compile the storybook:

You may need an additional loader to handle the result of these loaders.
|
| return (
> <Text selectable={false} {...props}>
| {glyph}
| {children}
@ ./node_modules/react-native-vector-icons/MaterialCommunityIcons.js 6:0-50 9:16-29
@ ./node_modules/react-native-paper/lib/module/components/MaterialCommunityIcon.js 8:27-94
@ ./node_modules/react-native-paper/lib/module/core/PaperProvider.js 3:0-72 73:12-33
@ ./node_modules/react-native-paper/lib/module/index.js 4:0-59 4:0-59 5:0-64 5:0-64
@ ./apps/cats/src/app/App.tsx 1:717-760 1:1882-1892
@ ./apps/cats/src/app/ lazy ^\.\/.*$ namespace object ./App.tsx ./App
@ ./storybook-stories.js 1:383-427
@ ./storybook-config-entry.js 1:171-216 1:1789-1797 1:1871-1985 1:1926-1984 1:1972-1980

This happens because I use the library react-native-paper which depends on react-native-vector-icons.

In react-native-vector-icons, it .js files contain jsx code: <Text selectable={false} {…props}>. To solve this, I need to load the react-native-vector-icons library using babel-loader. In this example, I can add a rule to Storybook’s Webpack config:

        config.module.rules.push({
test: /\.(js|jsx)$/,
include: /react-native-vector-icons/,
loader: 'babel-loader',
options: {
presets: [
'@babel/preset-env',
['@babel/preset-react', { runtime: 'automatic' }],
],
},
});

Then I need to also load the font MaterialCommunityIcons.ttf for Storybook:

staticDirs: ['./public'],
  • Add the below style to apps/cats/.storybook/preview-head.html to load the font:
<style type="text/css">
@font-face {
font-family: 'MaterialCommunityIcons';
src: url('/MaterialCommunityIcons.ttf') format('truetype');
}
</style>

The Storybook configuration at apps/cats/.storybook/main.ts will become:

import type { StorybookConfig } from '@storybook/react-webpack5';

const config: StorybookConfig = {
stories: ['../src/app/**/*.stories.@(js|jsx|ts|tsx|mdx)'],
staticDirs: ['./public'],
addons: [
'@storybook/addon-essentials',
'@storybook/addon-interactions',
'@nx/react/plugins/storybook',
],
framework: {
name: '@storybook/react-webpack5',
options: {},
},
webpackFinal: async (config) => {
if (config.resolve) {
config.resolve.alias = {
...config.resolve.alias,
'react-native$': 'react-native-web',
};
config.resolve.extensions = [
'.web.tsx',
'.web.ts',
'.web.jsx',
'.web.js',
...(config.resolve.extensions ?? []),
];
if (config.module?.rules) {
config.module.rules.push({
test: /\.(js|jsx)$/,
include: /react-native-vector-icons/,
loader: 'babel-loader',
options: {
presets: [
'@babel/preset-env',
['@babel/preset-react', { runtime: 'automatic' }],
],
},
});
}
}
return config;
},
};

export default config;

// To customize your webpack configuration you can use the webpackFinal field.
// Check https://storybook.js.org/docs/react/builders/webpack#extending-storybooks-webpack-config
// and https://nx.dev/recipes/storybook/custom-builder-configs

Now I can see react-native-vector-icons are being loaded correctly for the Storybook.

Now I can run npx nx run cats:storybook and it should render the storybook for me.

Storybook

Summary

In this article, you have learned how to add Cypress, Playwright, and Storybook to your Nx Expo app.

With Nx, it will help you with your Expo development process.

Learn more

--

--