Управление данными в системе автоматизации на Vue и Vuex и решение проблем тестирования с помощью Jest

Привет! Меня зовут Артём Карачёв, я фронтенд-разработчик в Sportmaster Lab. Сейчас мы пишем модуль автоматизации физической фотостудии, где работают несколько фотографов, менеджеров, фоторедакторов, кладовщиков и других. Все фото кроссовок, которые вы видите в интернет-магазинах Спортмастера, снимают и загружают в базу данных именно эти люди. И благодаря модулю автоматизации они смогут их выгружать быстрее и легче. Возможно, наш опыт организации vuex-хранилища и слоя получения данных, а также последующего интеграционно-компонентного тестирования окажется кому-то полезным.

ab33a06f2446279bf56e175edc952a61.jpg

По моему опыту для подобных систем чаще всего выбирают более «энтерпрайзные» фреймворки. В первую очередь это, конечно, Angular, но я видел и системы на Ext.js. Еще, конечно, это React — одна из самых популярных на сегодня фронтенд-технологий, без нее никуда.

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

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

Почему Vue

Ранее в компании для фронтенда использовался Angular. Потом мы провели RnD на эту тему и, выбирая между React и Vue, остановились на последнем. Во-первых, он более структурирован. На нем легче создать boilerplate, чтобы каждый человек в любой команде мог провести кросс-ревью, погрузиться в проект, и везде видел примерно одинаковую структуру кода.

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

Кроме того, у нас Agile, то есть приветствуются быстрые изменения. Как оказалось, для Vue требуется меньше времени, чтобы погрузиться в код, который вы писали три месяца назад, чем в React. Хотя, конечно, многое зависит от уровня разработчика, как пишущего, так и впоследствии читающего.

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

TypeScript

Пару слов о TypeScript. Кажется, что TS как раз и создавался для подобных приложений, но мы его не используем.  

Возможно, это связано с тем, что однажды мы попробовали писать на стеке Vue+TS, в то время, когда Vue только-только объявил о поддержке типизации. Внятной документации по этому поводу еще не было, и тот синтаксис с декораторами подкидывал нам немало проблем. Нужно было прописывать shim-файлы, добавлять глобальные интерфейсы и искать множество других воркэраундов для возникающих проблем. Всё удовольствие от простоты использования Vue моментально испарилось.

Сейчас, конечно, ситуация изменилась, и подключить типы к проекту на Vue стало гораздо проще (особенно с приходом Vue v3). Хотя React и Angular в этом плане выигрывают. В первый типизацию добавили гораздо раньше, а второй подразумевает использование TS по умолчанию.

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

В нашей практике гораздо чаще проблемы возникают из-за несоблюдения контракта «клиент-сервер», где TypeScript бессилен, и помочь может какой-нибудь GraphQL. Возможно, это только у нас так. 

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

Структура приложения

В интерфейсе нашего приложения реализовано множество фич. Во-первых, это полное движение товара: заказ, приемка, поиск по текущему статусу, все перемещения от поставщика к кладовщику, а потом до ассистента фотографа и обратно. Из-за этого наш модуль немного напоминает 1С. 

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

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

Проблемы и решения

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

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

Управление стейтом

Мы решили, что каждый объект стейта, который связан с загрузкой данных с бэкенда, будет выглядеть так:

{
  data: null,
  loading: false,
  error: null
}

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

Если в процессе запросов (как правило, это POST, PUT или DELETE) возникает необходимость описать некую асинхронную логику, мы делаем это внутри экшенов Vuex. При этом все методы взаимодействия с сервером вынесены в отдельный слой. Это, впрочем, достаточно распространенная практика. 

То есть, в файле endpoints.js мы храним все эндпойнты, к которым ходит наш фронтенд за данными, в виде списка URL-адресов с заменяемыми параметрами:

image-loader.svg

Затем мы передаем это в саму функцию запроса:

sendRequest(endpoints.item.ITEM.replace(‘{id}’, item.id));

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

image-loader.svg

Каждую такую функцию нужно вызвать с нужными параметрами, но это не отправит запрос. Запрос отправится позже внутри функции-хелпера отправки (об этом ниже), который вызовет возвращенную этим методом функцию.

Функция-хелпер

Мы обернули axios-инстанс в некую функцию-хелпер, которая возвращает промис, но позволяет декларативно описывать поведение в процессе и после запроса. Да, это похоже на $.ajax.

Что нам это дало? С помощью флага globalLoading мы можем показывать некий глобальный загрузчик, не давая в сложных случаях пользователю взаимодействовать с интерфейсом в пределах страницы. Кроме того, декларативность описания обработки успешного ответа и ошибки читается лучше, чем try-catch с async-await или then-catch c Promise. 

Также мы получили возможность автоматической обработки не ожидаемых сервером ошибок (без текста пояснения) и вывода их в небольших всплывающих надписях. Но при этом сохранили возможность вывода конкретных (handled) исключений в человекочитаемом виде в конкретных местах интерфейса. 

Более того, так как эта функция возвращает Promise, то и внутри компонента, где она вызывается, после ее отработки мы можем посмотреть на стейт и что-то с этим сделать (например, подсветить или сделать неактивными какие-то поля или дать рекомендации по воркэраунду).

Теперь наши экшены выглядят примерно так:

Код

vuexAction: ({ commit, dispatch }, dataToSend) => {
  commit('SET_DATA', {
    loading: false,
    error: null,
    data: null
  })

  // это та самая функция-обработчик запросов
  return requestHelper({
    store: { commit, dispatch },
    
    // вот здесь мы вызываем метод, который формирует,
    // но пока не отправляет запрос
    request: requestMethod(dataToSend),
    
    options: {
      after: (response) => {
        commit('DO_SIDE_EFFECT_MAYBE', response.data)
        commit('SET_DATA', {
          loading: false,
          error: null,
          data: response.data
        })
        dispatch('nextAction')
      },

      onError: (err) => {
        commit('SET_DATA', {
          loading: false,
          error: err.data,
          data: null
        })
      }
    }
})

И если упростить, мы можем представить компонент, использующий данный подход, примерно в таком виде:

Шаблон

Скрипт

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

Тестирование

Когда встал вопрос об автотестах на Vue, мы задумались. Внутри компонента у нас есть и верстка, и STL, и логика. При этом данные приходят из Vuex. 

Всё, что написано в документации или в статьях по теме тестирования Vue, не покрывало наших потребностей. Они предлагают просто все мокать — стейт, роутер и т.д. Но нам не хотелось мокать все экшены и тестировать их отдельно.

Нам помог Илья Климов и наработки GitLab: мы стали рассматривать компоненты Vue как функции, которые просто получают на вход не только свойства с пропсов, как в React. Они получают «параметры» со стейтов Vue.js, с provide/inject (хотя мы provide/inject почти не используем). Такой подход сложнее, но зато появляется смысл и понимание происходящего при написании тестов.

Сейчас мы мокаем практически только сами данные от сервера. После создания экземпляра Vue мы подключаем туда копии реального хранилища и роутера. Всё это интегрируем как есть, так приложение и работает.

Единственный нюанс в том, что мы делаем заглушкой свой SVG-плагин для webpack, чтобы он возвращал просто какую-то строку, потому что нам не нужно знать, в каком виде иконка вернулась. Тестируем мы компоненты полностью: так, как они работают. Хранилище каждый раз пустое, но с реальной структурой, со всеми модулями, потому что в этом компоненте может использоваться любой модуль. 

При этом мы тестируем только логику самого компонента, не трогая логику вложенных. Да, мы проверяем, правильные ли им переданы свойства и как наш компонент реагирует на ивенты, полученные от вложенных компонентов, но сами вложенные компоненты в рамках теста на родителя — черный ящик. То есть, мы создали как бы копию main.js файла, но с localVue внутри для тестов. Выглядит это так:

Тестируем логику

import Vuex from 'vuex'
import VueRouter from 'vue-router'
import Vuelidate from 'vuelidate'
import { cloneDeep } from 'lodash'
import { mount, createLocalVue, shallowMount } from '@vue/test-utils'
import VueI18n from 'vue-i18n'
import ru from '@/i18n/translations/ru'
import { storeConfig } from '@/store'
import permissionPlugin from '@/plugins/permissionPlugin'
import routes from '@/router/routes'
import uiKit from '@/plugins/uiKit'

const localVue = createLocalVue()

localVue.use(Vuex)
localVue.use(VueRouter)
localVue.use(uiKit)
localVue.use(Vuelidate)
localVue.use(VueI18n)

export const store = new Vuex.Store(cloneDeep(storeConfig))
export const router = new VueRouter({ routes })

localVue.use(permissionPlugin, { vuexInstance: store })

const messages = { ru }

export const i18n = new VueI18n({
  locale: 'ru',
  messages
})

const additionalOptions = [
  'mocks',
  'shallow'
]

/*
options могут содержать propsData, mocks etc...
*/

export const mountComponent = (component, options = {}) => {
  const mocks = cloneDeep(options.mocks)
  const { shallow } = options

  additionalOptions.forEach((option) => {
    delete options[option]
  })

  const mountFunc = shallow ? shallowMount : mount

  return mountFunc(component, {
    store,
    router,
    localVue,
    i18n,
    mocks: { ...mocks },
    ...options
  })
}

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

Теперь многие вызовы mountComponent будут пытаться «дёрнуть» экшен Vuex с настоящим запросом внутри (так как эти вызовы довольно часто находятся в created-хуке), но это — единственная проблема, потому что нам нужно дожидаться резолва этих промисов и тестировать правильную работу загрузчиков.

Так как для тестов и моков данных мы используем связку vue-jest +  библиотека axios-mock-adapter, то задействуем возможность axios-mock-adapter поставить небольшую задержку, имитируя задержку ответа от сервера:

mock = new MockAdapter(axiosInstance, { delayResponse: DELAY_TIME })

А также напишем небольшую рекурсивную функцию-хелпер, которая будет дожидаться нужного количества ответов:

Хелпер

const waitForResponse = async (requestQueueLength = 1) => {
  await new Promise((resolve) => {
    setTimeout(resolve, DELAY_TIME)
  })

  // после таймаута нужно также дождаться резолва промиса,
  // чтобы гарантировать, что результат получен
  await new Promise((resolve) => requestAnimationFrame(resolve))
  
  if (requestQueueLength > 1) {
    await waitForResponse(requestQueueLength - 1)
  }
}

requestQueueLength — это как раз то количество ответов, которое мы ждём, если необходимо подождать их несколько сразу, а не проверять статус компонента после каждого этапа. DELAY_TIME — константа задержки времени. Допустим, 100 миллисекунд.

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

Загружаем ресурс

import { mountComponent, waitForResponse } from '../../../helpers'
import MockAdapter from 'axios-mock-adapter'
import Fetcher from '@/api/fetcher'
import Component from '@/components/component
import endpoints from '@/api/endpoints'
import constants from '@/helpers/constants'
import testData from ‘@/json/data/testData.json’
const { HTTP_STATUSES } = constants

describe('component', () => {
  let wrapper
  let mock

  // функция, внутри которой вызывается запрос данных
  let actionDispatcher

  // функция для нахождения кнопки внутри компонента
  const actionBtn = () => wrapper.find('[data-test="some-button"]')

  // функция нахождения строк таблицы отображаемых данных
  const dataRows = () => wrapper.findAll('[data-test="data-row"]')

  // функция, создающая экземпляр компонента
  const createWrapper = () => {
    wrapper = mountComponent(Component)
  }

  // после каждого теста чистим wrapper и моки
  afterEach(async () => {
    wrapper.destroy()
    wrapper = null
    mock.restore()
    jest.clearAllMocks()
  })

  // перед запуском теста говорим axios, что отвечать на запросы
  // и создаем экземпляр компонента
  beforeEach(async () => {
    mock = new MockAdapter(Fetcher, { delayResponse: DELAY_TIME })
    mock.onGet(endpoints.module.URL.replace('{id}','value'))
      .reply(HTTP_STATUSES.OK, testData)

    // следим за вызываемой функцией запроса
    actionDispatcher = jest.spyOn(Component.methods, 'actionMethod')

    createWrapper()
  })

  it('test description', async () => {
    // проверяем, что ожидаемых данных нет
    expect(dataRows()).toHaveLength(0)

    // имитируем клик по кнопке, запускаем процесс загрузки
    await actionBtn().click()

    // проверяем, что запрос был отправлен
    expect(actionDispatcher).toHaveBeenCalledTimes(1) expect(actionDispatcher).toHaveBeenCalledWith(...someExpectedParameters)

    // проверяем, что отобразился загрузчик
    // при этом нам не интересно, что этот флаг наверное находится
    // в data самого компонента, нам интересно, что флаг правильно передался
    // компоненту загрузчика
    expect(wrapper.find('[data-test="data-preloader"]').vm.isShow).toBe(true)

    // ждем окончания загрузки данных
    await waitForResponse(1)

    // смотрим на изменившийся флаг загрузки
    expect(wrapper.find('[data-test="data-preloader"]').vm.isShow)
      .toBe(false)

    // находим отрисованные данные
    expect(dataRows()).toHaveLength(anyExpectedDataLength)
  })

Точно так же, только подставив в mock-adapter нужный ответ, мы можем протестировать ошибку при загрузке. 

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

А так как мы экспортируем объект создаваемого роутера, то мы можем тестировать и его вызовы. Либо наоборот, проверять реакцию компонента на изменения маршрута:

import { router } from '../../../helpers'

routerPushMethod = jest.spyOn(router, 'push')

expect(routerPushMethod).toHaveBeenCalledWith({ name: anyRouteName })

Заключение

Да, наши тесты стали очень похожи на e2e-тесты. Но, во-первых, они, знают о контрактах между компонентами и тестируют их. Во-вторых, они тестируют не в браузере, а в симуляции DOM, которую предоставляет Jest. И, в-третьих, это работает без бэкенда. Нам данный подход понравился ещё и потому, что на этапе внедрения у нас отсутствовали полноценные e2e-автотесты, внедренные в пайплайн, и хотелось тестировать интерфейс в максимально приближенном к «реальности» варианте, не забирая при этом у разработки очень уж много времени на написание таких тестов.  

Конечно, это не отменяет «обычное» unit-тестирование для сложных функций внутри проекта, к чему бы они ни относились, однако сводит к минимуму необходимость тестировать отдельно методы роутера и Vuex.

© Habrahabr.ru