В мобильной разработке наступает момент, когда вы понимаете, что проект становится все больше и больше, и нет необходимости держать некоторые компоненты непосредственно в приложении. Перемещение большинства многократно используемых частей кода в отдельный пакет npm звучит как разумный план. Но что, если вы хотите тестировать там и компоненты пользовательского интерфейса? Интеграция с jest и storybook может стать хорошим способом убедиться, что разрабатываемые вами функции выглядят и работают хорошо без запуска полноценного приложения.
Содержание:
- Создание и публикация пакета npm из приложения expo
- Добавление модульного тестирования с помощью Jest
- Добавление тестирования пользовательского интерфейса с помощью Storybook
Для кого эта статья?
В основном эта статья для тех, кто хочет создать npm-пакет с помощью expo, Typescript и интегрировать его с тестами и Storybook.
Я пытался создать как можно более понятный пост. Поэтому эта статья содержит много деталей и объяснений, которые я выяснил, создавая пакет npm в первый раз. Я также добавил некоторые детали о структуре проекта, что, по моему глубокому убеждению, может показать вам, как сохранить четкую схему проекта. Именно поэтому я считаю, что этот пост в основном предназначен для новичков в мобильной разработке.
Что вам понадобится?
- аккаунт npm
- базовые знания о expo, react-native, jest и storybook
- много терпения 🙂
Давайте начнем
Здесь вы можете найти исходный код с полным проектом.
Github repo представляет код в конце этого шага.
В начале я просто инициализировал пустой проект expo с шаблоном typescript, выполнив следующую команду:
После выполнения этой команды вам нужно выбрать пустой шаблон typescript, как я сделал ниже:
Поскольку мы хотим написать наш пакет на TypeScript, нам нужно добавить шаг компиляции. Для этого нам нужно настроить tsconfig.file
. Также мы собираемся использовать процесс компиляции, выполнив команду «tsc». Это приведет к эмуляции файлов в соответствии с заданными правилами.
Итак, здесь мы собираемся определить пути к источнику и эмуляции с некоторыми базовыми настройками.
{
"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"
}
Далее нам нужно добавить важную команду скрипта в наш файл package.json: ту, которая скомпилирует наш проект в настоящий пакет npm. Для этого нам нужно собрать как CJS, так и ESM. Таким образом, мы обеспечим поддержку всех существующих/обычных систем модулей: «ECMAScript Modul» (ESM) и «CommonJs» (CJS).
Итак, мы помещаем следующие команды в package.json:
Let’s try this!
Теперь, когда мы, наконец, выполнили все настройки, мы можем запустить код для создания реального компонента пользовательского интерфейса.
Вы можете создать свой собственный компонент или следовать вместе со мной.
Что мы будем создавать
Во время разработки я использую методологию атомного проектирования, которая определяет структуру проекта. Итак, в проекте будет папка src и папка components. Внутри папки components будут папки для атомов, молекул и т.д..
Внутри этих папок я собираюсь создать папку для каждого компонента, так что я смогу поместить сюда компонент, тесты и истории.
В этом шаге я собираюсь создать простой компонент пользовательского интерфейса, который будет отображать стилизованный текст несколькими способами. Я буду использовать родные компоненты Text и некоторые базовые стили. В следующих шагах я добавлю в эту папку тесты и истории.
Создайте файл Text.tsx в каталоге src/components/atoms/Text.
//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",
},
});
Создайте также индексный файл и экспортируйте наш компонент из Text и из atoms:
// src/components/atoms/Text/index.ts
export { Text } from "./Text";
// src/components/atoms/index.ts
export { Text } from "./Text";
Теперь создадим экспорт в файле index.ts в корневом каталоге:
/index.ts
export * from "./src/components/atoms";
Отлично, первая часть почти сделана 🙂
Теперь нам нужно увеличить наш package.json
Точка входа
Посмотрите на поле «main» внутри ‘package.json’. Это точка входа как для вашего пакета, так и для вашего приложения expo. Поэтому для экспорта компонентов из вашего пакета вам нужен другой путь, чем для запуска приложения непосредственно в проекте.
Здесь показана точка входа для приложения expo:
"main": "node_modules/expo/AppEntry.js",
Но для пакета npm мы будем использовать другое значение для этого поля:
"main": "./package/index.js",
Внутри поля «main» для пакета вы определяете, откуда именно вы экспортируете компоненты. Насколько я помню, мы создали индексный файл в корневом каталоге, куда мы экспортируем все компоненты из папки atoms. Кроме того, этот файл компилируется в пакет, поэтому я думаю, что он идеально подходит для того, чтобы быть нашей точкой входа.
точка входа в пакет
Это позволит нам использовать гладкий импорт, подобный приведенному ниже:
import {Text} from "native-package"
вместо:
import {Text} from "native-package/package/src"
Плавный импорт — это не только визуальное преимущество, но и доступность. Как создатель пакета — вы прекрасно знаете его содержание и структуру. Но подумайте о других пользователях — как они узнают структуру кода и поймут, откуда им следует импортировать различные компоненты из пакета? Вот почему важно позаботиться о правильном экспорте внутри и снаружи вашего пакета и создать простой в использовании пакет.
Типы
В самом начале мы установили declarationTypes в true в файле ‘tsconfig.json’. Эта строка инструктирует компилятор TypeScript выводить файлы декларации (.d.ts). Но чтобы иметь возможность проверять типы из пакета после установки — нам нужно как-то их раскрыть. Поэтому сейчас мы определим поле «types» внутри файла ‘package.json’. Это поле указывает Typescript, где именно искать определения типов.
Итак, давайте добавим это поле:
"types": "./package/index.d.ts",
Круто 🙂 Мы почти можем опубликовать его. Но — нет необходимости публиковать все, верно? Следующий шаг — это настройка публикации. Здесь мы определим, что мы хотим опубликовать в npmjs.
Есть несколько путей, по которым вы можете пойти, публикуя только необходимые файлы. Если бы вы использовали любой другой компилятор, кроме typescript, вы могли бы без сомнения собрать только те файлы, которые вам нужны. Но мы используем способ с ‘tsconfig’. В принципе, в этом случае вы можете пойти двумя путями: вы можете создать .npmingore и игнорировать их файлы в процессе публикации. Это решение основано на исключении. Есть и другой способ: вы можете определить файл «files» внутри package.json и определить там, что вы хотите *включить* в папку вашего пакета при публикации.
.npmignore
В этот раз я собираюсь игнорировать файлы, которые будут опубликованы внутри файла .npmignore. Потому что этот способ намного чище, чем метод с размещением имен файлов внутри ‘package.json’.
Прежде всего, я хочу игнорировать все, поэтому я использую *, затем, используя вызов «!», я исключаю файлы из игнорирования. Таким образом, я включаю их в пакет.
//.npmignore
*
!index.ts
!package/**/*.tsx
!package/**/*.ts
!package/**/*.js
!package/**/*.jsx
Ну вот и все :D. Наконец-то мы можем опубликовать и протестировать наш первый компонент.
Публикация
Версионирование
Перед каждой публикацией вам необходимо помнить о повышении версии вашего пакета внутри ‘package.json’ в поле «version». В противном случае вы увидите ошибку при публикации.
Это можно сделать, выполнив команду, которая увеличит работу npm. Для этого я собираюсь использовать приведенную ниже команду, которая обновит третий файл версии (с 1.0.0 до 1.0.1). Используя ‘minor’, вы увеличите средний номер пакета (с 1.0.0 до 1.1.0), а ‘major’ добавит единицу в первое поле версии (с 1.0.0 до 1.1.0).
А поскольку наша цель — увеличить версию с помощью метода, представленного ниже:
"version": 1.0.0 =====> "version": 1.0.1
Вы можете сделать это вручную в ‘package.json’. Я собираюсь использовать следующую команду в своем терминале:
Имя пакета
В верхней части ‘package.json’ вы можете увидеть поле ‘name’, которое определяет имя вашего пакета. Имейте в виду, что оно должно быть уникальным. В противном случае вы не сможете опубликовать свой пакет в npmjs.
Теперь пришло время собрать ваш пакет в папку ‘package’. Для этого выполните команду терминала, которую мы задали ранее:
Еще один важный момент — это наличие доступа к публикации в вашей учетной записи npmjs.
Если вы хотите создать приватный пакет, убедитесь, что в поле ‘package.json’:
"private": true,
Также вам придется иметь дело с реестром npmrc.
Для целей этого проекта я собираюсь создать пакет с публичным доступом. Поэтому единственное, что мне нужно сделать, это убедиться, что я удалил поле ‘private’ из ‘package.json’ и войти в свой аккаунт npm, выполнив команду:
В последнем пункте перед публикацией мы собираемся убедиться, что наш пакет собран правильно и содержит надлежащее содержимое. Мы можем использовать приведенную ниже команду, которая будет имитировать процесс публикации:
Когда вы убедитесь, что все работает нормально, самое время выполнить в терминале команду:
После публикации вы должны получить письмо с информацией об успешной сборке 🙂
И теперь вы должны быть в состоянии установить ваш пакет npm, выполнив команду как обычно: npm i package-name
и протестировать его в любом другом мобильном приложении.
Вот структура моего пакета после установки в приложение.
Интеграция с Jest
Когда вы создаете пакет с UI-компонентом, не питайте иллюзий, что визуального тестирования достаточно. Всегда важно создать несколько сценариев использования компонента и протестировать выполнение кода. Именно поэтому я добавил в эту статью часть, посвященную тестированию.
Github repo, в котором показан результат в конце этого шага.
Далее нам понадобятся некоторые тестовые зависимости, которые позволят нам тестировать компоненты и хуки внутри нашего пакета. Давайте добавим некоторые dev-зависимости, выполнив команду runnning:
Обратите внимание, что вы можете увидеть пару ошибок относительно версий пакетов и сравнения их с версиями react и react-dom. Ниже вы можете видеть часть кода, который я имею в своем ‘package.json’ после сравнения версий. Помните, что не стоит использовать флаг ‘—legacy-peer-deps’ при установке зависимостей, потому что это приведет к большим проблемам и ошибкам в будущем. Этот флаг в процессе установки не исправит ваши ошибки — он будет их игнорировать. Лучше обратить на это внимание в самом начале, чем потратить тонну часов на исправление ошибок в будущем.
// 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",
...
Как я уже говорил: вы можете столкнуться с некоторыми ошибками, связанными с версионированием пакетов 🙂 Будьте терпеливы. Оно того стоит 🙂
Далее мы должны быть уверены, что наш тест будет проверять только содержимое внутри нашего приложения, без node_modules или папки пакетов. Поэтому сейчас мы создадим файл jest.config.js и предоставим некоторые важные настройки:
const config = {
preset: "jest-expo",
verbose: true,
testPathIgnorePatterns: ["/node_modules/", "package", "/.expo/"],
};
module.exports = config;
Отлично! Теперь мы можем написать простой тест для компонента Text. Давайте создадим файл Text.test.tsx в папке Text:
//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();
});
});
В конце добавьте тестовый скрипт в файл package.json:
Также помните об исключении тестовых файлов из нашего пакета в .npmigonre. Вы также должны заполнить его, исключив тестовые файлы. Это может выглядеть как приведенный ниже код:
//.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
Что ж, это была быстрая попытка. Следующий шаг — это то, ради чего вы здесь находитесь:
Интеграция Storybook
Github repo с финальным кодом в конце этого шага.
Прежде всего, нам нужны все зависимости для запуска storybook 🙂
Давайте добавим в наш package.json некоторые из них в devDependencies:
Ниже вы можете увидеть, как я сопоставил версии этих пакетов в моем 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",
Далее нам понадобится некоторая конфигурация сборника рассказов. Для этого мы создадим папку storybook в нашей корневой директории. Внутри этой папки нам нужно зарегистрировать наши аддоны. Аддоны — это расширение для Storybook, которое помогает нам манипулировать пользовательским интерфейсом различными способами. Поскольку Storybook предоставляет только основные аддоны, часто мы хотим зарегистрировать некоторые дополнительные функции. Для этого мы создадим файл ‘addon.js’, в котором будем регистрировать аддоны, и файл ‘addon-rn.js’, в котором будем регистрировать аддоны для устройств. Порядок этих регистраций определяет порядок их появления на вашей панели аддонов. 🙂 Там же нам понадобится файл ‘index.js’, в котором, собственно, мы и будем настраивать наши истории.
В папке storybook создайте папку stories и добавьте в нее файл index.js. Сюда мы будем импортировать компоненты формы наших историй.
Мы собираемся установить:
-
@storybook/addon-actions — дает возможность отображать полученные данные, что позволяет регистрировать события/действия внутри историй в Storybook;
-
@storybook/addon-ondevice-actions — обертка для действий;
-
@storybook/addon-links — навигация между историями;
-
@storybook/addon-knobs — позволяет динамически редактировать реквизиты с помощью пользовательского интерфейса Storybook;
-
@storybook/addon-ondevice-knobs — позволяет редактировать аргументы компонента динамически с помощью графического пользовательского интерфейса без необходимости написания кода;
//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';
Теперь пришло время написать базовую конфигурацию, которая позволит нам отображать storybook UI на устройствах типа эмуляторов. Кроме того, мы должны добавить декоратор для ручек, чтобы иметь возможность манипулировать состоянием компонентов.
//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,
});
Теперь давайте создадим историю для нашего компонента Text 🙂
//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;
Теперь нам нужно импортировать его в папку stories:
//src/storybook/stories/index.js
import "../../src/components/atoms/Text/Text.stories";
Последний пункт — добавить наш контейнер storybook в файл App.tsx — таким образом, вместо запуска приложения expo будет запускать storybook.
//App.tsx
import React from "react";
import StorybookApp from "./storybook";
const Storybook = () => {
return <StorybookApp />;
};
export default Storybook;
Я думаю, что нам не нужно публиковать истории компонентов, поэтому в конце просто исключите их в ‘.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"
И вуаля!
Теперь измените поле ‘main’ в ‘package.json’, чтобы оно стало точкой входа для приложения expo и запустите expo start
. Наслаждайтесь вашим пакетом со сборником рассказов! 🙂
Git repo
У вас есть вопросы? Свяжитесь со мной на Linkedin