Мы разрабатываем новые функции, исправляем ошибки и, самое главное, ежедневно рефакторим большое количество кода. Все приложения, от небольших до крупных, требуют автоматического тестирования, чтобы убедиться, что они не сломаются, когда другие разработчики добавляют новые функции.

Приложения React Native ничем не отличаются, и есть несколько доступных библиотек, которые сделают процесс тестирования радостным и интересным. Используя в качестве примера демонстрационное приложение Semaphore с открытым исходным кодом, вы узнаете:

  1. Какие инструменты использовать для реализации автоматизированного тестирования в вашем приложении React Native.
  2. Как настроить инструменты и запустить тесты.
  3. Как писать модульные, интеграционные и сквозные тесты.

Требования

Чтобы понять тестирование пользовательского интерфейса для приложения React Native, мы разработали небольшое приложение для поиска и хранения данных о стране.

В этом приложении используются реагирование-навигация, реагирование-нативные-векторные-иконки и асинхронное-хранилище. Вот несколько скриншотов нашего пользовательского интерфейса.

Настройка проверки качества с типизацией и линтингом

По мере увеличения размера приложения выполнение наших тестов будет становиться все более затратным. Таким образом, лучше планировать заранее, чтобы сэкономить время и убедиться, что в нашем коде нет каких-либо фундаментальных проблем, таких как опечатки.

Статическая типизация и линтинг могут помочь нам избежать таких проблем, избавив нас от большого количества обработки во время сборки и тестирования. TypeScript и Eslint могут быть очень полезными инструментами для настройки типов и линтинга для наших приложений. Благодаря замечательным людям, работающим над react-native-cli и react-native-typescript-template, у нас есть отличный готовый набор инструментов для запуска проекта React Native.

Модульное тестирование

Модульные тесты гарантируют, что отдельные модули вашего приложения работают должным образом изолированно.

В нашем приложении React Native мы используем Jest в качестве среды тестирования. Jest — это восхитительная платформа для тестирования JavaScript с упором на простоту. Благодаря возможностям настройки и распараллеливания без настройки Jest является одной из наиболее широко используемых сред тестирования. Он предварительно настроен для приложений React Native.

Добавление библиотеки тестирования React Native

Как обсуждалось выше, наше тестирование будет больше сосредоточено на поведении пользователей, чем на деталях реализации. React-test-renderer может помочь нам визуализировать объекты чистого JavaScript наших компонентов, не завися от DOM или любого другого средства визуализации. Тестирование поведения пользователя потребует от нас взаимодействия с этими компонентами. Библиотека тестирования React-Native Testing Library (RNTL), построенная поверх react-test-renderer, предоставляет простой API для выполнения взаимодействия с пользователем с компонентами, созданными для реагирования.

Вот как мы можем добавить RNTL в наше приложение:

yarn add -D @testing-library/react-native

Написание тестов

Самыми базовыми единицами в тестировании пользовательского интерфейса являются компоненты. Чтобы представить API и понять, чего мы хотим достичь, мы поделимся тестом для простого компонента Button. Вот тест для него.

import 'react-native';
import React from 'react';
import {render, fireEvent} from '@testing-library/react-native';

// import { render } from 'utils/testWrapper';
import Button from '../index';

// Describing a test suite
describe('<Button />', () => {
  // Describing our test
  it('Calls onPress', async () => {
    // Mocking onPress method so we can check if its called or not
    const onPress = jest.fn();

    // test id to be applied on our button component
    const testID = 'button';

    // Rendering Button component using react-native-test-renderer.
    const {getByTestId} = await render(
      <Button testID={testID} onPress={onPress} label="Button" />,
    );

    // Grabbing our button component to perform actions on it.
    const button = getByTestId(testID);

    /**
     * RNTL gives us API to fire events on node
     * Here we are firing on press event
     */
    fireEvent.press(button);

    // Asserting if given mock method is called or not.
    expect(onPress).toHaveBeenCalledTimes(1);
  });
});

Здесь ничего нового: для начала мы визуализировали наш компонент фиктивным методом в пропсах, используя рендерер RNTL. Затем мы запустили событие Press для нашего компонента и, таким образом, утвердили его, а затем вызвали фиктивный метод, который мы предоставили через свойства. Если этот тест пройден, мы можем быть уверены, что наша кнопка работает должным образом.

Давайте перейдем к реальному примеру. Как упоминалось ранее, приложение позволит пользователям выбирать свою страну. Для этой цели у нас есть компонент CountriesAutocomplete, который предоставляет TextInput и список. Как и любое другое автозаполнение, оно будет отображать результаты на основе ввода пользователя. Вот как выглядит набор тестов для этого компонента:

import 'react-native';
import React from 'react';
import {render, fireEvent} from '@testing-library/react-native';

import CountriesAutocomplete from '../index';

const COUNTRY_NAME = 'Serbia';

// Describing a test suite
describe('<CountriesAutocomplete />', () => {
  // Describing our test
  it('Displays Searched Item', async () => {
    // Mocking onPress method so we can check if its called or not
    const onSelect = jest.fn();

    // Rendering Button component using RNTL.
    const autocomplete = await render(
      <CountriesAutocomplete onSelect={onSelect} />,
    );

    // Grabbing our input to perform actions on it.
    const inputTestID = 'countriesAutocompleteInput';
    const textInput = autocomplete.getByTestId(inputTestID);

    /**
     * RNTL gives us API to fire events on node
     * Here we are firing on changeText event
     */
    fireEvent(textInput, 'focus');
    fireEvent.changeText(textInput, COUNTRY_NAME);
    expect(textInput.props.value).toBe(COUNTRY_NAME);

    // Grabbing our input to perform actions on it.
    const listItemTestID = `listItem-${COUNTRY_NAME}`;
    const firstListItem = autocomplete.getByTestId(listItemTestID);
    expect(firstListItem).toBeTruthy();
  });

  it('onSelect is called when item is pressed', async () => {
    // Mocking onPress method so we can check if its called or not
    const onSelect = jest.fn();

    // Rendering Button component using react-native-test-renderer.
    const {getByTestId} = await render(
      <CountriesAutocomplete onSelect={onSelect} />,
    );

    // Grabbing our input to perform actions on it.
    const inputTestID = 'countriesAutocompleteInput';
    const textInput = getByTestId(inputTestID);

    /**
     * RNTL gives us API to fire events on node
     * Here we are firing on focus & changeText event
     */
    fireEvent(textInput, 'focus');
    fireEvent.changeText(textInput, COUNTRY_NAME);

    // Grabbing our input to perform actions on it.
    const listItemTestID = `listItem-${COUNTRY_NAME}`;
    const firstListItem = getByTestId(listItemTestID);
    fireEvent.press(firstListItem);

    expect(onSelect).toHaveBeenCalledTimes(1);
  });
});

В комплекте два теста. Первый утверждает, что когда пользователь вводит текст, появляется список компонентов. Также, что результат должен соответствовать вводу. Второй тест утверждает, что когда пользователь выбирает элемент из списка, он должен вызвать фиктивную функцию, которую мы предоставили через свойства.

Выполнение тестов

Jest ищет все тестовые файлы и выполняет их одной командой. И вот каким будет наше выполнение и результаты:

В сфере пользовательского интерфейса модульных тестов может быть недостаточно, чтобы убедиться, что ваши пользователи не столкнутся с какими-либо ошибками. Но практические модульные тесты могут помочь вам сконцентрироваться на интеграции компонентов с большей уверенностью.

Интеграционное тестирование

Как только мы убедимся, что наши компоненты работают должным образом, мы можем проверить, хорошо ли они работают с другими. Интеграционное тестирование является наиболее важной частью тестирования пользовательского интерфейса и может дать вам большую уверенность в том, что функции вашего приложения работают хорошо.

Написание тестового примера

Для интеграционного тестирования функции выбора страны мы протестируем наш экран поиска. На этом экране должно отображаться автозаполнение стран и выбранная страна. Он также сохраняет страну, выбранную в AsyncStorage, предоставленном React Native. Кроме того, повторный выбор выбранной страны должен удалить ее из AysncStorage.

Оснащение окружающей среды

RNTL визуализирует компоненты React в автономном режиме, что означает, что нативные библиотеки не будут доступны во время выполнения. Однако Jest позволяет нам имитировать методы из нативных библиотек. Вот наш фиктивный файл:

/** 
 * Mocking all required for react-navigation
 */
import 'react-native-gesture-handler/jestSetup';

jest.mock('react-native-iphone-x-helper', () => ({
  getStatusBarHeight: jest.fn(),
  getBottomSpace: jest.fn(),
}));

jest.mock('@react-native-community/masked-view', () => ({}));

/* Silence the warning: Animated: `useNativeDriver` is
 * not supported because the native animated module is missing
 */
jest.mock('react-native/Libraries/Animated/NativeAnimatedHelper');

/**
 * Mocking Async Storage
 */
jest.mock('@react-native-async-storage/async-storage', () => ({
  setItem: jest.fn(),
  getItem: jest.fn(),
  removeItem: jest.fn(),
}));

Как видите, мы имитировали методы setItem, getItem, removeItem AsyncStorage, чтобы сделать наши интеграционные тесты независимыми от платформы.

Написание интеграционных тестов

Мы напишем наши интеграционные тесты с RNTL, разделив наш набор функций на три небольших теста.

import 'react-native';import React from 'react';
import {render, fireEvent} from '@testing-library/react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';

import SearchScreen, {COUNTRY_LOCAL_STORAGE_KEY} from '../index';

const COUNTRY_NAME = 'Serbia';
const COUNTRY_DETAILS =
  '{"name":"Serbia","native":"Србија","phone":"381","continent":"EU","capital":"Belgrade","currency":"RSD","languages":["sr"],"emoji":"🇷🇸","emojiU":"U+1F1F7 U+1F1F8"}';

// Describing a test suite
describe('<SearchScreen />', () => {
  it('Displays selected country', async () => {
    /**
     * Rendering screen
     */
    const screen = await render(<SearchScreen />);

    /*
     * Grabbing our input to perform actions on it.
     */
    const inputTestID = 'countriesAutocompleteInput';
    const textInput = screen.getByTestId(inputTestID);

    /**
     * Here we are firing on focus & changeText event
     */
    fireEvent(textInput, 'focus');
    fireEvent.changeText(textInput, COUNTRY_NAME);

    /**
     * Selecting item from list
     */
    const listItemTestID = `listItem-${COUNTRY_NAME}`;
    const firstListItem = screen.getByTestId(listItemTestID);
    fireEvent.press(firstListItem);

    /**
     * Grabbing & asserting selected item's name
     */
    const selectedCountryName = screen.getByTestId('selectedItemName');
    expect(selectedCountryName).toBeTruthy();
    expect(selectedCountryName.children).toContain(COUNTRY_NAME);
  });

  it('Stores selected country in local storage', async () => {
    /**
     * Rendering screen
     */
    const screen = await render(<SearchScreen />);

    /**
     * Here we are searching & selecting country
     */
    const inputTestID = 'countriesAutocompleteInput';
    const textInput = screen.getByTestId(inputTestID);

    fireEvent(textInput, 'focus');
    fireEvent.changeText(textInput, COUNTRY_NAME);

    const listItemTestID = `listItem-${COUNTRY_NAME}`;
    const firstListItem = screen.getByTestId(listItemTestID);
    fireEvent.press(firstListItem);

    /**
     * Asserting country storage.
     */
    expect(AsyncStorage.setItem).toHaveBeenCalledWith(
      COUNTRY_LOCAL_STORAGE_KEY,
      COUNTRY_DETAILS,
    );
  });

  it('Removes selected country from local storage', async () => {
    /**
     * Rendering screen
     */
    const screen = await render(<SearchScreen />);

    /**
     * Here we are searching, selecting country & removing
     */
    const inputTestID = 'countriesAutocompleteInput';
    const textInput = screen.getByTestId(inputTestID);

    fireEvent(textInput, 'focus');
    fireEvent.changeText(textInput, COUNTRY_NAME);

    const listItemTestID = `listItem-${COUNTRY_NAME}`;
    const firstListItem = screen.getByTestId(listItemTestID);
    fireEvent.press(firstListItem);

    const selectedItem = screen.getByTestId('selectedItem');
    fireEvent.press(selectedItem);

    /**
     * Asserting country deletion.
     */
    expect(AsyncStorage.removeItem).toHaveBeenCalledWith(
      COUNTRY_LOCAL_STORAGE_KEY,
    );
  });
});

Вот краткое изложение каждого теста:

  1. Отображает выбранную страну: мы утверждали, что когда пользователь касается элемента на экране поиска, название страны автоматически заполняется, и на экране элемент отображается как выбранный.
  2. Сохраняет выбранную страну в локальном хранилище: мы утверждали, что экран поиска должен сохранять выбранный элемент в локальном хранилище, вызывая метод setItem AsyncStorage.
  3. Удаляет выбранную страну из локального хранилища: мы утверждали, что когда пользователь нажимает на выбранный элемент, он удаляется из локального хранилища с помощью метода removeItem AsyncStorage.

Выполнение интеграционных тестов

Теперь пришло время проверить, работают ли все наши компоненты вместе; выполнение будет таким же, как и модульные тесты. Результат можно увидеть ниже.

Сквозное тестирование с Detox

Теперь, когда мы убедились, что наши различные компоненты нормально работают вместе, мы можем начать сквозное тестирование (E2E). Проще говоря, E2E-тестирование похоже на разработку роботов, которые выполняют все возможные действия, которые реальный пользователь выполнил бы с вашим приложением.

E2E стремится воспроизвести реальные пользовательские сценарии, чтобы проверить наше приложение для реальных вариантов использования. Чтобы достичь этого, мы должны выполнять наши тесты в среде, аналогичной среде конечного пользователя. Для мобильных приложений это означает, что мы должны проводить тесты на реальных устройствах или симуляторах.

Существует несколько инструментов для E2E-тестирования мобильных приложений. Мы выбрали Detox: библиотеку сквозного тестирования и автоматизации для мобильных приложений Серый ящик. Detox был создан для реактивных нативов и обеспечивает отличный опыт разработчика, что является ключевым фактором при его выборе. API-интерфейсы Detox для взаимодействия с пользователем упрощают тестирование приложений так, как их фактически используют пользователи.

Настройка платформы

Давайте начнем настройку Detox, добавив пакет и его типы в наше приложение.

yarn add detox @types/detox -D

Как и другие зависимости для тестирования, мы добавим Detox и типы в качестве зависимостей для разработчиков. После установки пришло время настроить Detox. Мы коснемся лишь нескольких основ в этой статье, чтобы запустить наши тесты, но вы можете получить более подробную информацию в подробном руководстве, если хотите действительно погрузиться в него.

Ниже мы определили наши конфигурации Detox в .detoxrc.js:

module.exports = {  "configurations": {
    "ios.sim.debug": {
      "binaryPath": "ios/build/Build/Products/Debug-iphonesimulator/ReactNativeSemaphoreNew.app",
      "build": "xcodebuild ONLY_ACTIVE_ARCH=YES -workspace ios/ReactNativeSemaphoreNew.xcworkspace -scheme ReactNativeSemaphoreNew -configuration Debug -sdk iphonesimulator -derivedDataPath ios/build -UseModernBuildSystem=YES",
      "type": "ios.simulator",
      "name": "iPhone 11"
    },
    "ios.sim.release": {
      "binaryPath": "ios/build/Build/Products/Release-iphonesimulator/ReactNativeSemaphoreNew.app",
      "build": "xcodebuild ONLY_ACTIVE_ARCH=YES -workspace ios/ReactNativeSemaphoreNew.xcworkspace -scheme ReactNativeSemaphoreNew -configuration Release -sdk iphonesimulator -derivedDataPath ios/build -UseModernBuildSystem=YES",
      "type": "ios.simulator",
      "name": "iPhone 11"
    },
    "android.emu.debug": {
      "binaryPath": "android/app/build/outputs/apk/debug/app-debug.apk",
      "build": "cd android && ./gradlew assembleDebug assembleAndroidTest -DtestBuildType=debug && cd ..",
      "type": "android.emulator",
      "name": "Pixel_4_API_28"
    },
    "android.emu.release": {
      "binaryPath": "android/app/build/outputs/apk/release/app-release.apk",
      "build": "cd android && ./gradlew assembleRelease assembleAndroidTest -DtestBuildType=release && cd ..",
      "type": "android.emulator",
      "name": "Nexus_S_API_24"
    }
  },
  "test-runner": "jest"
};

Как видите, мы разделили наши конфигурации на четыре разных раздела: два для Android и два для iOS. Обе ОС имеют конфиги для тестирования отладочной и релизной версий. Каждая из этих конфигураций имеет команду сборки, двоичный путь, тип и имя.

Испытательная установка

Переходя к нашему набору тестов, мы будем использовать Jest так же, как мы делали это при модульном и интеграционном тестировании. Мы создадим каталог e2e в корне нашего проекта, который будет содержать configs.json, который Detox читает для настройки среды JS. Вот как это выглядит:

{    
    "setupFilesAfterEnv": ["./init.js"],
    "testEnvironment": "node",
    "reporters": ["detox/runners/jest/streamlineReporter"],
    "verbose": true,
    "preset": "ts-jest"
}

Detox выполнит файл инициализации, определенный в конфигах, в самом начале выполнения теста. В нашем случае это init.js, как показано ниже:

const detox = require('detox');const adapter = require('detox/runners/jest/adapter');
const specReporter = require('detox/runners/jest/specReporter');

const config = require('../.detoxrc.js');

// Set the default timeout
jest.setTimeout(1000000);
jasmine.getEnv().addReporter(adapter);

// This takes care of generating status logs on a per-spec basis. By default, jest only reports at file-level.
// This is strictly optional.
jasmine.getEnv().addReporter(specReporter);

/**
 * beforeAll
 * This will be executed before our testing begins,
 * We have initialized detox with our configs here.
 */
beforeAll(async () => {
  await detox.init(config);
});

/**
 * beforeEach
 * This will be executed before each of our tests suite,
 * It can be used for several cleanup tasks.
 */
beforeEach(async () => {
  await adapter.beforeEach();
});

/**
 * afterAll
 * This will be executed after all of our tests suite,
 * Here we have add detox cleanup to.
 */
afterAll(async () => {
  await adapter.afterAll();
  await detox.cleanup();
});

Этот файл определяет задачи, которые должны выполняться до, между и после наших наборов тестов. Мы также можем использовать каждый хук для множества различных задач по очистке и настройке.

Написание тестов E2E

Наконец, пришло время провести несколько реальных испытаний. Мы поиграем мускулами с помощью простого набора тестов, который проверяет поведение пользовательского интерфейса и кнопок-переключателей на главном экране.

import {expect, device, element, by} from 'detox';
describe('HomeScreen UI', () => {
  /**
   * beforeEach
   */
  beforeEach(async () => {
    await device.reloadReactNative();
  });

  /**
   * Most basic test
   */
  it('should show app screen text', async () => {
    await expect(element(by.text('Step One'))).toBeVisible();
    element(by.id('homeScreen')).scroll(200, 'down');
    await expect(element(by.text('See Your Changes'))).toBeVisible();
  });

  /**
   * Tests toggle behavior
   */
  it('should show switch and toggle it on', async () => {
    const isAndroid = device.getPlatform() === 'android';

    await expect(element(by.id('toggle'))).toBeVisible();
    if (!isAndroid) {
      await expect(element(by.id('toggle'))).toHaveValue('0');
      await element(by.id('toggle')).longPress();
      await expect(element(by.id('toggle'))).toHaveValue('1');
    }
  });
});

Мы постараемся упростить знакомство с API Detox. Ниже вы можете увидеть краткое изложение того, что мы сделали в этом пакете.

  1. Detox будет запускать beforeEachhook перед каждым тестом, определенным в пакете. Поскольку все тесты в этом наборе независимы, мы перезагрузим React Native, чтобы получить новый запуск.
  2. Первый тест проверяет содержимое экрана. Для устройств меньших размеров нам нужно немного прокрутить экран, чтобы убедиться, что нужный контент виден.
  3. Мы утверждаем, что значение переключателя должно переключаться, когда пользователь нажимает его.

Теперь мы добавим набор для проверки потока выбора страны. Как было определено ранее, эта функция позволяет нашим пользователям выбирать свою страну. Приложение сохраняет его, поэтому, когда пользователи снова откроют приложение, они смогут увидеть выбранную ими страну.

import {expect, device, element, by} from 'detox';
const COUNTRY_NAME = 'Serbia';
const OTHER_COUNTRY_NAME = 'United States';

describe('Select Country', () => {
  /**
   * Check for UI
   */
  it('should show app search screen button', async () => {
    await expect(element(by.id('searchButton'))).toBeVisible();
  });

  /**
   * Verify navigation
   */
  it('should navigate to search screen', async () => {
    await element(by.id('searchButton')).tap();
    await expect(element(by.id('searchScreen'))).toBeVisible();
  });

  /**
   * Search & Select
   */
  it('should search & select country', async () => {
    await element(by.id('countriesAutocompleteInput')).typeText(COUNTRY_NAME);
    await element(by.id(`listItem-${COUNTRY_NAME}`)).tap();
    await expect(element(by.id('selectedItem'))).toBeVisible();
  });

  /**
   * Relaunch app & verify saved data
   */
  it('should show selected country on next launch', async () => {
    await device.reloadReactNative();
    await element(by.id('searchButton')).tap();
    await expect(element(by.id('selectedItemName'))).toHaveLabel(COUNTRY_NAME);
  });

  /**
   * Remove selected item
   */
  it('should remove selected country on press', async () => {
    await element(by.id('selectedItem')).tap();
    await expect(element(by.id('countriesAutocompleteInput'))).toBeVisible();
  });

  /**
   * Re-Iterate selection and removal
   */
  it('should select & remove country again', async () => {
    await element(by.id('countriesAutocompleteInput')).typeText(
      OTHER_COUNTRY_NAME,
    );
    await element(by.id(`listItem-${OTHER_COUNTRY_NAME}`)).tap();
    await expect(element(by.id('selectedItemName'))).toHaveLabel(
      OTHER_COUNTRY_NAME,
    );
    await element(by.id('selectedItem')).tap();
    await expect(element(by.id('countriesAutocompleteInput'))).toBeVisible();
  });

  /**
   * Relaunch app & verify data is removed
   */
  it('should persist removal of country on next launch', async () => {
    await device.reloadReactNative();
    await element(by.id('searchButton')).tap();
    await expect(element(by.id('countriesAutocompleteInput'))).toBeVisible();
  });
});

Здесь мы разделили поток тестирования на небольшие шаги, чтобы сделать его более читабельным.

  • Проверьте пользовательский интерфейс: мы утверждали, что на главном экране есть кнопка для навигации по экрану поиска.
  • Проверить навигацию: мы утверждали, что экран поиска должен появляться, когда пользователь нажимает нужную кнопку.
  • Найти и выбрать: мы искали и выбирали страну, нажимая на результаты поиска. Мы утверждали, что выбранная страна отображается по желанию.
  • Перезапустите приложение и проверьте сохраненные данные: чтобы имитировать то, что происходит при повторном запуске приложения, мы перезагрузили React Native. После перехода к экрану поиска мы утверждали, что он отображает страну, которую мы выбрали на предыдущем шаге.
  • Удалить выбранный элемент: мы утверждали, что выбранный элемент удаляется нажатием на него, что позволяет пользователю снова выполнить поиск страны.
  • Повторите выбор и удаление: чтобы быть более уверенными в функциональности одного сеанса, мы повторили процесс выбора и удаления для другой страны.
  • Перезапустите приложение и проверьте удаление данных: точно так же, как когда мы перезапустили наше приложение, чтобы проверить, постоянно ли оно сохраняет выбранную нами страну, и мы утверждали то же самое для удаления.

Этот краткий набор тестов охватывает большинство пограничных случаев.

Выполнение тестов E2E

Чтобы выполнить наши тесты, мы создадим приложение и запустим тесты, используя Detox:

detox build --configuration ios.sim.debug
detox test --configuration ios.sim.debug

🥳 это работает! Разве это не хорошо?

В двух словах

Разработчики, плохо знакомые с разработкой через тестирование (TDD), не знакомы с автоматизированным тестированием. Обычно они сомневаются в ценности, которую создает тестирование, по сравнению со временем, потраченным на написание тестов. Большинство этих аргументов коренится в нескольких мифах, циркулирующих в сообществе. Но реальность такова, что автоматическое тестирование — единственный способ убедиться, что программное обеспечение не сломается по мере его роста.

Как выразился Алан Пейдж:

"Если мы хотим серьезно относиться к качеству, пора перестать искать ошибки и в первую очередь начать предотвращать их появление".

Вы можете найти репозиторий нашего примера приложения здесь.

Мы будем рады узнать, какие еще функции вы хотели бы, чтобы мы протестировали для вас. Пожалуйста, не стесняйтесь открывать вопросы и отправлять PR.

Удачного тестирования 🚀

Первоначально опубликовано на https://semaphoreci.com 10 июня 2021 г.