In mobile development, there is a moment when you recognize that the project is getting bigger and bigger and there is no need to keep some of the components directly in the app. Moving most reusable pieces of the code into the separate npm package sounds like a reasonable plan. But what if you want to test there also your UI components? So integration with jest and storybook could be a good way to make sure features you are developing are looking and working well without running a full app.
Content:
- Building and publishing npm package from expo app
- Adding unit testing with Jest
- Adding UI testing with Storybook
Who is this article for?
Basically this article is for people who want to create an npm package with expo, Typescript and integrate it with tests and Storybook.
I was trying to create the most understandable post as possible. So this article contains a lot of details and explanations which I figured out building an npm package for the first time. I added also some of the details about project structure, what, as I deeply believe, could show you how to keep a clear schema of the project. That's why I think this post is mainly dedicated for beginners in mobile development.
What will you need?
- npm account
- basic knowledge about expo, react-native, jest and storybook
- a loooot of patiency :)
Let's start
Here you can find the source code with complete project.
Github repo represents code at the end of this step.
At the beginning I simply initialized an empty expo project with a typescript template by running following command:
$ expo init app
.
After running this command you need to choose a blank typescript template just like I did below:
Since we want to write our package in TypeScript, we need to add the compilation step. To do this, we need to configure a tsconfig.file
. Also, we are going to use compile process by running "tsc" command. It's going to emit files along with provided rules.
So, here we are going to define source and emitting paths with some of the basic config.
{
"compilerOptions": {
"outDir": "./package",
"module": "esnext",
"target": "es5",
"lib": ["es6", "dom", "es2016", "es2017"],
"declaration": true, //emit an .d.ts definitions file with compiled JS
"allowJs": true, //js support
"noEmit": false, //set to false allow to generate files with tsc
"esModuleInterop": true, // import CommonJS modules in compliance with es6
"moduleResolution": "node",
"resolveJsonModule": true, //allows import json modules
"jsx": "preserve", //keep the JSX as part of the output to be further consumed by another transform step
"sourceMap": true, // Generates a source map for .d.ts files which map back to the original .ts source file
"sourceRoot": "/",
"baseUrl": "./src"
},
"include": ["**/*.ts", "**/*.tsx", "app.config.js"],
"exclude": ["node_modules", "package"],
"extends": "expo/tsconfig.base"
}
Next, we need to add an important script command into our package.json file: the one that will compile our project into an actual npm package. For this purpose, we need to build both CJS and ESM. In this way, we will introduce support for all current/common module systems: "ECMAScript Modul" (ESM) and "CommonJs" (CJS).
So we place the following commands into the package.json:
"build": "npm run build:esm && npm run build:cjs",
"build:esm": "tsc",
"build:cjs": "tsc --module commonjs",
Let's try this!
Now, when we finally have all the setup done, we can finally run some code to create an actual UI component.
You can create your own component or follow along with me.
What we will create
During development I am using atomic design methodology which defines the structure of the project. So the project is going to have an src folder, with a components folder. Inside the components folder, there will be folders for atoms, molecules, etc...
Inside of those folders I'm going to create a folder for every component, so I will be able to place here a component, tests, and stories.
In this step, I'm going to create a simple UI component that will display provided styled text in a couple of ways. I will use the native Text components and some of the basic styles. In the next steps, I will add to this folder tests and stories.
Create a Text.tsx file inside a src/components/atoms/Text directory.
//src/components/atoms/Text/Text.tsx
import React, { FC } from "react";
import { Text as NativeText, StyleSheet } from "react-native";
type TextProps = {
type: "big" | "medium" | "small";
};
export const Text: FC<TextProps> = ({ type, children }) => {
return (
<NativeText
style={[
(type === "big" && styles.big) ||
(type === "medium" && styles.medium) ||
(type === "small" && styles.small),
]}
>
{children}
</NativeText>
);
};
const styles = StyleSheet.create({
big: {
fontSize: 22,
fontWeight: "900",
},
medium: {
fontSize: 18,
fontWeight: "600",
},
small: {
fontSize: 14,
fontWeight: "400",
},
});
Create also an index file and export our component from Text and from atoms:
// src/components/atoms/Text/index.ts
export { Text } from "./Text";
// src/components/atoms/index.ts
export { Text } from "./Text";
Now let's create an export in index.ts file in the root directory:
/index.ts
export * from "./src/components/atoms";
Fine, first part is almost done :)
Now we have to boost our package.json
Entry point
Look at the "main" field inside of 'package.json'. It's an entry point for both your package and your expo app. So for exporting components out of your package you need a different path than for running the app directly in the project.
Here you can see an entry point for expo app:
"main": "node_modules/expo/AppEntry.js",
But for the npm package we are going to use another value for this field:
"main": "./package/index.js",
Inside the "main" field for the package, you are defining exactly from where you are exporting components. As far as I remember We created an index file in the root directory, where we are exporting all components from the atoms folder. Also, this file is compiled into the package, so I think this is a perfect one to be our entry point.
It will allow us to use smooth imports like this one below:
import {Text} from "native-package"
in place of:
import {Text} from "native-package/package/src"
Smooth importing is not only a case of visual advantage, but it's also about accessibility. As a creator of a package - you perfectly know its content and structure. But think about other users - How they will know the structure of the code and figure out from where they should import different components from the package? This is why it's important to take care about proper exports inside and outside of your package and create an easy one to use.
Types
At the beginning, we setted declarationTypes to true into the 'tsconfig.json' file. This line instructs the TypeScript compiler to output declaration files (.d.ts). But to be able to check types out of the package after installation - we need to expose them somehow. That's why now we are going to define field "types" inside of the 'package.json' file. This field tells the Typescript exactly where to look for types definitions.
So let's add this field:
"types": "./package/index.d.ts",
Cool :) We can almost publish it. But - there is no need to publish everything, right? The next step is a configuration of publishing. Here we will define what we want to push to the npmjs.
There are multiple ways you can go with publishing only necessary files. If you would use any other compiler than typescript - you could without any doubt build only the files you need. But we are using the way with 'tsconfig'. Basically in this case you can go with two options: you can create a .npmingore and ignore their files in the publishing process. This solution is based on excluding. There is also another way: you can define "files" filed inside the package.json and define there what you want to *include * in your package folder during publishing.
.npmignore
This time I'm gonna go with ignoring files that will be published inside a .npmignore file. Because this way is much cleaner way than method with placing files names inside the 'package.json'.
First of all, I want to ignore everything, that's why I'm using *, then using the "!" invocation I'm excluding files from ignoring. So this way I'm including them in the package.
//.npmignore
*
!index.ts
!package/**/*.tsx
!package/**/*.ts
!package/**/*.js
!package/**/*.jsx
So here we are :D. We can finally publish and test our first component.
Publishing
Versioning
Before every publish you need to remember about increasing version of your package inside 'package.json' in field "version". Otherwise you'll see an error during publishing.
You can do this by running command which will increase the npm work. For this purpose I'm going to use below command which will upgrade the third filed of the version (from 1.0.0 to 1.0.1). Using 'minor' you will increase the middle number of the package (from 1.0.0 to 1.1.0), and 'major' will adjust one to the first field of the version (from 1.0.0 to 1.1.0).
And because our purpose is to increase version with method presented below:
"version": 1.0.0 =====> "version": 1.0.1
You can do this manually in 'package.json'. I'm going to use following command in my terminal:
npm version patch
Package name
At the top of the 'package.json', you can see the field 'name' which defines the name of your package. Be aware that it has to be a unique one. Otherwise, you'll not able to publish your package into npmjs.
Now it's time to build your package into the 'package' folder. To achieve that run the terminal command we set earlier:
npm run build
Another important thing is to have an access to publishing on your npmjs account.
If you want to create a private package, ensure that you have in the 'package.json' field:
"private": true,
Also, you will have to deal with npmrc registry.
For purpose of this project, I am going to create a package with public access. That's why the only thing I have to do is to ensure that I removed the field 'private' from the 'package.json' and login into my npm account by running the command:
npm login
In the last point before publishing we are going to make sure if our package is built properly and contains a proper content. We can use below command which will imitate publish process:
npm publish -–dry-run
When you are sure everything is working fine, it's time to run in your terminal a command:
npm publish
After publishing you should get an email with info about the successful build :)
And now you should be able to install your npm package by running the command as usual: npm i package-name
and test it in any other mobile app.
This is the structure of my package after installation in an app.
Jest integration
When you are building a package with UI component don't be under the illusion that visual testing is sufficient. It's always important to create multiple scenarios of component's usage and test code execution. That's why I added a testing part into this article.
Github repo that shows the result at the end of this step.
Next we need some of the test dependencies which would allow us to test components and hooks inside our package. Let's add some dev dependencies by runnning:
npm i -D react-test-renderer @testing-library/react-native @types/jest jest jest-expo
Be aware you can see a couple of errors regarding versions of the packages and comparing them with the react and react-dom versions. Below you can see a piece of the code which I have in my 'package.json' after comparing versions. Be aware of not using flag '--legacy-peer-deps' when you are installing dependencies because it's going to produce more problems and errors in the future. This flag in installation process is not going to fix your errors - it's going to ignore them. It might be better to pay more attention to it at the beginning than to spend tone of hours in the future figuring errors out.
// package.json
"peerDependencies":{
"react": "*",
"react-dom": "*"
},
"devDependencies": {
"react-test-renderer": "17.0.2",
"@testing-library/react-native": "^9.0.0",
"@types/jest": "^27.0.3",
"jest": "^26.5.1"
"jest-expo": "^45.0.0",
...
As I said earlier: you can face some errors regarding versioning of packages. :) Be patient. It's worth it :)
Next we have to be sure that our test will test only content inside our app without node_modules or package folder. That's why now we are going to create jest.config.js file, and provide some of crucial configuration:
const config = {
preset: "jest-expo",
verbose: true,
testPathIgnorePatterns: ["/node_modules/", "package", "/.expo/"],
};
module.exports = config;
Great! Now we can write simple test for Text component. Let's create a Text.test.tsx file inside Text folder:
//src//components/atoms/Text/Text.test.tsx
import { render } from "@testing-library/react-native";
import { Text } from "./Text";
describe("Text", () => {
it("should render Text component properly", () => {
const { getByText } = render(<Text type="big">Hello test</Text>);
expect(getByText("Hello test")).toBeTruthy();
});
});
At the end add a test script into your package.json file:
"test": "jest"
Also remember about excluding test files from our package in the .npmigonre. You should also fill it with excluding test files. It may look like this code below:
//.npmignore
*
!index.ts
!package/**/*.tsx
!package/**/*.ts
!package/**/*.js
!package/**/*.jsx
package/**/*.test.tsx
package/**/*.test.jsx
package/**/*.test.js
package/**/*.test.ts
package/**/*.test.d.ts
package/**/*.test.d.tsx
Well it was a quick shot. Next step is what you are here for:
Storybook integration
Github repo with final code at the end of this step.
First things first - we need all of the dependencies for running storybook :)
Let's add into our package.json some of them into devDependencies:
npm i -D @storybook/addon-knobs storybook/addon-links storybook/addon-ondevice-actions @storybook/react-native storybook/react-native-server @storybook/addon-controls @storybook/addon-ondevice-controls
Below you can see how I matched the versions of those packages in my package.json:
"@storybook/addon-knobs": "^5.3.21",
"@storybook/addon-links": "^5.3.21",
"@storybook/addon-ondevice-actions": "5.3.23",
"@storybook/react-native": "^5.3.25",
"@storybook/react-native-server": "^5.3.23",
"@storybook/addon-controls": "^6.4.22",
"@storybook/addon-ondevice-controls": "^6.0.1-alpha.0",
Next we need some storybook configuration. For that we will create a storybook folder inside our root directory. Inside of this folder we need to register our addons. Addons are an extension for Storybook which help us to manipulate UI in various ways. Because Storybook provides only essential addons often we want to register some additional features. For that we will create 'addon.js' file, where we will register addons and 'addon-rn.js' file where we will register ondevice addons. The order of those registrations defines the order in which they appear on your addon panel. :) We will need there also an 'index.js' file, where actually, we will configure our stories.
In the storybook folder - create folder stories and add index.js file inside. Here we will import our stories form components.
We are going to install:
@storybook/addon-actions - gives ability to display received data which allows you to log events/actions inside stories in Storybook;
@storybook/addon-ondevice-actions - wrapper for actions;
@storybook/addon-links - navigates between stories;
@storybook/addon-knobs-allow you to edit props dynamically using the Storybook UI;
@storybook/addon-ondevice-knobs - allows editing a component's arguments dynamically via a graphical UI without needing to code;
//addon.js
import '@storybook/addon-actions/register';
import '@storybook/addon-links/register';
import '@storybook/addon-knobs/register';
//rn-addons.js
import '@storybook/addon-ondevice-actions/register';
import '@storybook/addon-ondevice-knobs/register';
Now it's time to write basic configuration which will allow us to display storybook UI on device like emulators. Additionally we should add decorator for knobs to be able to manipulate with state of components.
//index.js
import { getStorybookUI, configure, addDecorator } from "@storybook/react-native";
import { withKnobs } from "@storybook/addon-controls";
import "./rn-addons";
addDecorator(withKnobs);
configure(() => {
require("./stories");
}, module);
export default getStorybookUI({
asyncStorage: null,
});
Now let's create a story for our Text component :)
//src/components/atoms/Text/Text.stories.tsx
import React from "react";
import { View } from "react-native";
import { storiesOf } from "@storybook/react-native";
import { radios, text } from "@storybook/addon-knobs";
import { Text } from "./Text";
const stories = storiesOf("Atoms", module);
stories.addDecorator((getStory) => (
<View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
{getStory()}
</View>
));
stories.add("Text", () => (
<Text
type={radios(
"Text type",
{ big: "big", medium: "medium", small: "small" },
"medium"
)}
>
{text("Text", "Simple text")}
</Text>
));
export default stories;
Now we have to import it inside our stories folder:
//src/storybook/stories/index.js
import "../../src/components/atoms/Text/Text.stories";
Last point is to add our storybook container into App.tsx file - so in place of running an app - expo will run storybook.
//App.tsx
import React from "react";
import StorybookApp from "./storybook";
const Storybook = () => {
return <StorybookApp />;
};
export default Storybook;
I think we don't need to publish components stories, so at the end just exclude them in the '.npmignore':
//.npmignore
*
!index.ts
!package/**/*.tsx
!package/**/*.ts
!package/**/*.js
!package/**/*.jsx
package/**/*.test.tsx
package/**/*.test.jsx
package/**/*.test.js
package/**/*.test.ts
package/**/*.test.d.ts
package/**/*.test.d.tsx
"package/**/storybook",
"package/**/*.stories.d.ts",
"package/**/*.stories.jsx.map",
"package/**/*.stories.jsx",
"package/**/stories.d.ts",
"package/**/stories.js",
"package/**/stories.js.map"
And voila!
Now change the 'main' field in the 'package.json' to be an entry point for expo app and run expo start
. Enjoy your package with storybook! :)
Top comments (1)
For businesses and influencers, monitoring Story views is crucial for assessing the effectiveness of marketing campaigns. Tracking views can help determine the success of promotional content and optimize future campaigns for better results instagram story viewer anonymous.