Введение в тестирование Vue компонентов с помощью Jest. Часть 2

Введение

В прошлой части мы создали скелет нашего приложения по управлению личным автопарком и рассмотрели методы написания тестов на компоненты приложения. Но наше приложение на данный момент мало интерактивное. 

Давайте добавим ему  интерактивности! 

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

Подключение менеджера состояния vuex

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

npm i vuex --save

Для начала создадим следующую структуру файлов нашего хранилища данных:

 - store
   -- cars
       -- actions.js
       -- getters.js
       -- mutations.js
       -- mutation-types.js
       -- index.js
   index.js

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

Далее в файле /store/index.js добавим следующие строки:

import Vue from 'vue'
import Vuex from 'vuex'
import createLogger from 'vuex/dist/logger'
import cars from './cars'

Vue.use(Vuex)
const debug = process.env.NODE_ENV !== 'production'

const store = new Vuex.Store({
  /**
   * Assign the modules to the store.
   */
  modules: {
    cars,
  },

  /**
   * If strict mode should be enabled.
   */
  strict: debug,

  /**
   * Plugins used in the store.
   */
  plugins: debug ? [createLogger()] : [],
})

export default store

После этого импортируем наш store в главном файле приложения main.js, и добавляем  в конструктор экземпляра приложения:

new Vue({
  /**
   * Bind the Vue instance to the HTML.
   */
  el: '#app',

  /**
   * The Vuex store.
   */
  store,

  /**
   * Will render the application.
   *
   * @param {Function} h Will create an element.
   */
  render: h => h(App),
})

Работа со стейтом приложения в плане написания простейших геттеров на свойства стейта, а также мутаций  и экшенов порождает много однотипного кода. Для уменьшения количества такого кода можно использовать библиотеку vuex-pathify. Сначала установим ее командой

npm install vuex-pathify --save-dev

Чтобы ее подключить нам необходимо ее импортировать в файле ./store/index.js и добавить ее в массив плагинов.

...
plugins: debug ? [createLogger()] : [pathify.plugin],
...

Тестирование мутаций

Заготовка нашего хранилища данных выполнена. Далее нам необходимо определится со структурой стейта (state) нашего модуля cars. Для реализации наших функций нам понадобится список машин, список выбранных машин по датам, список значений для фильтра по скорости и по пробегу, выбранные значения фильтров скорости и пробега, флаг загрузки данных и выбранная текущая дата. Получается вот такой вот state:

export default {
  cars: [],
  bookedCars: [],
  maxSpeedItems: [],
  runItems: [],
  speedValue: 0,
  runValue: 0,
  isLoading: false,
  currentDate: null
}

В массиве cars будут хранится наши объекты с параметрами наших автомобилей. В массиве bookedCars будут хранится объекты с датой и идентификатором выбранной машины. А остальные поля нашего фильтра относятся к фильтрации машин. Теперь давайте добавим мутации для установки этих свойств в файле mutations.js. 

import {
  SET_STATE,
  SET_CARS,
  SET_SPEED_ITEMS,
  SET_RUN_ITEMS,
  SET_FILTER_VALUE,
  RESET_FILTERS,
  ADD_CAR_BOOKING,
  CANCEL_CAR_BOOKING,
  CHANGE_CURRENT_DAY,
} from './mutation-types'

export default {
  [SET_STATE] (state, value) {
    state.isLoading = value
  },
  [SET_CARS] (state, cars) {
    state.cars = cars
  },
  [SET_SPEED_ITEMS] (state, items) {
    state.maxSpeedItems = items
  },
  [SET_RUN_ITEMS] (state, items) {
    state.runItems = items
  },
  [SET_FILTER_VALUE] (state, { name, value }) {
    state[name] = value
  },
  [RESET_FILTERS] (state) {
    state.speedValue = null
    state.runValue = null
  },
  [ADD_CAR_BOOKING] (state, payload) {
    state.bookedCars.push(payload)
  },
  [CANCEL_CAR_BOOKING] (state, { id, date }) {
    const item = state.bookedCars.find(item => item.id === id && item.date === date)
    state.bookedCars = state.bookedCars.filter(car => car !== item)
  },
  [CHANGE_CURRENT_DAY] (state, day) {
    state.currentDate = day
  }
}

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

import * as types from './mutation-types'
import mutations from "./mutations.js"

describe('CARS MUTATIONS', () => {
  it('set state', () => {
    const state = {isLoading: false}
    mutations[types.SET_STATE](state, true)
    expect(state).toEqual({isLoading: true})

    mutations[types.SET_STATE](state, false)
    expect(state).toEqual({isLoading: false})
  })
})

Код теста подразумевает создание объекта state с начальными данными, затем вызов метода мутации и проверка объекта state на соответствие ожидаемому результату. Вот мы и написали свой первый тест мутации vuex. Остальные тесты установки свойств объекта состояния state аналогичны данному тесту. Вы можете посмотреть их в репозитории в ветке master или ветке part2.

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

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

const data = require('../data')

export function loadData(timeout = 200) {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve(data)
    }, timeout)
  })
}

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

import { loadData } from '@/api'
import * as types from './mutation-types'

export const LoadInfo = async ({ commit }) => {
  commit(types.SET_STATE, true)
  const { cars, speedItems, runItems } = await loadData(1000)
  commit(types.SET_CARS, cars)
  commit(types.SET_SPEED_ITEMS, speedItems)
  commit(types.SET_RUN_ITEMS, runItems)
  commit(types.SET_STATE, false)
}

export default {
  LoadInfo,
}

Тестирование действий в изоляции сравнимо с тестированием мутаций. В большинстве случаев нам необходимо протестировать следующие 3 факта:

 

  1. Проверить url, на который будет отправлен запрос;
  2. Проверить правильность структуру отправляемых данных;
  3. Проверить были ли вызваны соответствующие мутации после обработки запроса.

 

В основном при тестировании действий применяется техника установки заглушек на библиотеку запросов. Например axios. Библиотека тестирования Jest позволяет устанавливать такие заглушки глобально на все тесты с помощью папку __mocks__. В случае с нашим же приложением мы просто произведем вызов нашего действия loadInfo и далее проверим что происходили вызовы нужных нам мутаций.

Код теста: 

import actions from './actions'
import * as types from './mutation-types'
import data from '@/data'

describe('CARS ACTIONS', () => {
  it('LoadInfo test', async () => {
    const commit = jest.fn()
    const { cars, speedItems, runItems } = data

    await actions.LoadInfo({ commit })

    expect(commit).toHaveBeenCalledWith(types.SET_STATE, true)
    expect(commit).toHaveBeenCalledWith(types.SET_CARS, cars)
    expect(commit).toHaveBeenCalledWith(types.SET_SPEED_ITEMS, speedItems)
    expect(commit).toHaveBeenCalledWith(types.SET_RUN_ITEMS, runItems)
    expect(commit).toHaveBeenCalledWith(types.SET_STATE, false)
  })
})

Мы уже рассмотрели как можно тестировать мутации и действия. Остались геттеры. Для начала проведем небольшой рефакторинг нашего компонента фильтров:

Часть кода компонента:

...
  export default {
    name: 'CarsFilter',
    components: {
      BaseSelect
    },
    props: {
      speedRange: {
        type: Array,
        default: () => {
          return []
        }
      },
      runRange: {
        type: Array,
        default: () => {
          return []
        }
      },
      speed: {
        type: [Number, String],
        default: 0
      },
      run: {
        type: [Number, String],
        default: 0
      }
    },
    methods: {
      onResetFilter() {
        this.$emit('reset-filter')
      },
      onChangeMaxSpeed(value) {
        this.$emit('change-speed', value)
      },
      onChangeCurrentRun(value) {
        this.$emit('change-run', value)
      }
    },
  }
...

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

Мы добавили новые свойства данному компоненту speedRange,  runRange, speed и run, сделав его презентационным, а все данные передадим ему с компонента приложения App. 

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

function makeList(items) {
  return items.map(item => {
    const { id, min, max } = item
    const name = max ? `${min}-${max}` : `>${min}`
    return {
      id,
      name,
    }
  })
}

export default {
  speedRange(state) {
    return makeList(state.maxSpeedItems)
  },
  runRange(state) {
    return makeList(state.runItems)
  },
}

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

Код теста:

import getters from './getters.js'
import { makeList } from './getters.js'

const data = [
  { id: 1, min: 0, max: 10 },
  { id: 2, min: 11, max: 20 },
  { id: 3, min: 21 },
]

const result = [
  { id: 1, name: '0-10' },
  { id: 2, name: '11-20' },
  { id: 3, name: '>21' },
]

describe('CARS GETTERS', () => {
  it('test makeList', () => {
    const actual = makeList(data)
    expect(actual).toEqual(result)
  })

  it('test speedRange', () => {
    const state = { maxSpeedItems: data }
    const actual = getters.speedRange(state)
    expect(actual).toEqual(result)
  })

  it('test runRange', () => {
    const state = { runItems: data }
    const actual = getters.runRange(state)
    expect(actual).toEqual(result)
  })
})

Для получения отфильтрованного списка машин создадим еще один геттер и протестируем его. Данный геттер мы подключим в компоненте App.vue и передадим значения в наш компонент списка машин.

Код геттера:

…

export function carIsBooked(car, day, booked = []) {
  return booked.findIndex(item => item.id === car.id && item.date === day) !== -1
}
…

  carItems(state) {
    const { cars, bookedCars, speedValue, runValue, currentDate } = state
    let items = cars

    // Apply filters
    if (speedValue || runValue) {
      if (speedValue) {
        const { max = 1000, min } = state.maxSpeedItems.find(item => item.id === speedValue)
        items = state.cars.filter(item => item.maxSpeed >= min && item.maxSpeed <= max)
      }
      if (runValue) {
        const { max = 1000, min } = state.runItems.find(item => item.id === runValue)
        items = items.filter(item => item.currentRun >= min && item.currentRun <= max)
      }
    }

    // Apply booking
    const showedDay = moment(currentDate).format(DATE_FORMAT)
    items = items.map(car => ({
      ...car,
      isBooked: carIsBooked(car, showedDay, bookedCars)
    }))

    return items
  },
...

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

Код теста:

const data = [
  { id: 1, min: 0, max: 10 },
  { id: 2, min: 11, max: 20 },
  { id: 3, min: 21 },
]

const result = [
  { id: 1, name: '0-10' },
  { id: 2, name: '11-20' },
  { id: 3, name: '>21' },
]

const cars = [
  { id: 1, title: 'BMW X1', maxSpeed: 5, currentRun: 5, isBooked: false, },
  { id: 2, title: 'BMW X2', maxSpeed: 12, currentRun: 13, isBooked: false, },
  { id: 3, title: 'BMW X3', maxSpeed: 45, currentRun: 46, isBooked: false, },
]

  it('test carItems', () => {
    const state = { cars, runItems: data, maxSpeedItems: data, bookedCars: [] }
    let actual = getters.carItems(state)
    expect(actual).toEqual(cars)

    state.speedValue = 1
    actual = getters.carItems(state)
    expect(actual).toEqual([{ id: 1, title: 'BMW X1', maxSpeed: 5, currentRun: 5, isBooked: false, }])

    state.speedValue = 0
    state.runValue = 2
    actual = getters.carItems(state)
    expect(actual).toEqual([{ id: 2, title: 'BMW X2', maxSpeed: 12, currentRun: 13, isBooked: false, }])

    state.speedValue = 3
    state.runValue = 3
    actual = getters.carItems(state)
    expect(actual).toEqual([{ id: 3, title: 'BMW X3', maxSpeed: 45, currentRun: 46, isBooked: false, }])
  })

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

Теперь давайте немного модифицируем наш компонент AppHeader.vue. Сделаем так чтобы в правой части заголовка приложения мы могли видеть текущую дату и название забронированной машины, если она есть. 

Код компонента:

…
  import moment from 'moment'

  import { DATE_FORMAT } from '@/app.constants'
  import { today } from '@/helpers/utils'

  export default {
    name: 'AppHeader',
    props: {
      title: String,
      date: {
        type: String,
        default: ''
      },
      carName: {
        type: String,
        default: 'Нет брони'
      }
    },
    computed: {
      isEnabledPrev() {
        return moment(this.date).isAfter(today())
      }
    },
    methods: {
      prevDay() {
        const date = moment(this.date)
        this.$emit('change-date', date.add(-1, 'days').format(DATE_FORMAT))
      },
      nextDay() {
        const date = moment(this.date)
        this.$emit('change-date', date.add(1, 'days').format(DATE_FORMAT))
      }
    }
  }
...

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

Работа с порталами

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

Во-первых, установим нужный пакет:

npm i portal-vue --save

Далее подключим это плагин в файле main.js:

import PortalVue from portal-vue
...
Vue.use(PortalVue)

Вот теперь мы готовы открыть наш первый портал. 

Итак создадим компонент кнопки, по при нажатии на которую мы можем заказать определенную машину или отказаться от заказанной. Позже мы добавим эту кнопку в нижнюю часть карточки машины. Если машина доступна для заказа (т. е. не забронирована на сегодняшний день), то при нажатии на кнопку у машины будем показывать модальное окно, которое мы определим в другом компоненте. В данном модальном окне мы сможем выбрать дату бронирования авто. 

Вот код получившегося компонента:

<template>
  <div class="full-width">
    <a
            v-if="isAllowedBooking"
            href="#"
            class="car-booking-toggle-button card-footer-item has-text-success"
            @click.prevent="isVisible = true"
    >
      {{ linkText }}
      <portal to="modals">
        <template v-if="isVisible">
          <car-booking-modal-window
                  v-bind="modalProps"
                  @confirm="addCarBooking"
                  @close="hideBookingWindow"
          ></car-booking-modal-window>
        </template>
      </portal>
    </a>
    <a
            href="#"
            class="car-booking-toggle-button card-footer-item has-text-danger"
            v-else
            @click.prevent="cancelCarBooking"
    >
      {{ linkText }}
    </a>
  </div>
</template>

<script>

  import { CarBookingModalWindow } from '../CarBookingModalWindow'

  export default {
    name: 'ToggleCarBookingButton',
    components: {
      CarBookingModalWindow
    },
    data() {
      return {
        isVisible: false
      }
    },
    props: {
      carId: {
        type: Number,
        required: true
      },
      isAllowedBooking: {
        type: Boolean,
        required: true
      }
    },
    computed: {
      modalProps() {
        return {
          carId: this.carId,
          ...this.$attrs
        }
      },
      linkText() {
        return this.isAllowedBooking ? 'Забронировать' : 'Отказаться'
      }
    },
    methods: {
      hideBookingWindow() {
        this.isVisible = false
      },
      addCarBooking(date) {
        this.hideBookingWindow()
        this.$emit('add-car-booking', { id: this.carId, date })
      },
      cancelCarBooking() {
        this.$emit('cancel-car-booking', { id: this.carId })
      }
    }
  }
</script>

Также нам необходимо добавить сам компонент портала в файл App.vue:

<div id="app">
    ….
    <portal-target name="modals"></portal-target>
</div>

Например перед закрывающим тегом компонента. Важно, чтобы имя портала совпадало с тем, что вы укажете в компоненте, где портал используется, т. е. аттрибуты to и name были одинаковыми.

Тестирование Vuex в компонентах

До этого раздела мы писали тесты на компоненты и на составляющие хранилища в изоляции. Давайте рассмотрим каким образом можно протестировать getters, mutations и actions в рамках компонента, подключенного к store.

Так как у нас только один компонент подключен к store — это App.vue, то и тестировать будем его. 

Начнем с getters. Есть три способа чтобы протестировать работу getters.

1. Создать локальный экземпляр vue, подключить к нему store с нашим стейтом

2. Замокать store, указав ему нужные getters.

3. Сделать mock непосредственно самого gettres при рендеринге компонента.

Рассмотрим первый способ. Для того чтобы создать локальный экземпляр Vue в @vue/test-utils есть функция createLocalVue. Импортируем ее и создадим локальный экземпляр:

import Vuex from 'vuex'
import Buefy from 'buefy'
import PortalVue from 'portal-vue'

import cars from '@/store/cars'

import { shallowMount, createLocalVue } from '@vue/test-utils'
import { AppHeader } from './components/AppHeader'

import App from './App.vue'

const localVue = createLocalVue();
localVue.use(Vuex)
localVue.use(Buefy)
localVue.use(PortalVue)

const store = new Vuex.Store({
  modules: {
    cars
  }
})

describe('test App', () => {
  it('app works', () => {
    const wrapper = shallowMount(App, {
      localVue,
      store,
    })
    const header = wrapper.find(AppHeader)
    expect(header.attributes().date).toBe(moment().format('YYYY-MM-DD'))
    expect(header.attributes().carName).toEqual(undefined)
  })
})

Как видно из простого теста, где мы просто делаем shallow рендеринг нашего компонента вторым параметром мы передали как раз локальный экземпляр vue и наш store в виде объекта.

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

Второй вариант это замокать сам store. Например мы могли бы сделать так:

it("renders with mock store", () => {
  const wrapper = shallowMount(App, {
    mocks: {
      $store: {
        getters: {
          todayCar: {}
        }
      }
    }
  })
...
})

И третим способом мы могли бы передать в  shallow рендеринг компонента непосредственно функцию todayCar, как computed.

it("renders with mock computed", () => {
  const wrapper = shallowMount(App, {
    computed: {
	todayCar: () => ({})
    }
  })
...
})

Вот мы рассмотрели три основных способа протестировать геттеры. 

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

describe("test App", () => {

  it("commits a mutation when a button is clicked", () => {
    const wrapper = shallowMount(App, {
      store, localVue
    })

    wrapper.find(".commit").trigger("click")
    commit = jest.fn()
    expect(commit).toHaveBeenCalledWith(types.CANCEL_CAR_BOOKING, {})

  })
})

На мой субъективный взгляд для больших компонентов, которые подключаются к vuex и имеют множество геттеров, и действий предпочтительнее использовать способ с созданием локального экземпляра vue, подключить к нему store с нашим стейтом. А для небольших мокать store.

Итоги

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

P.S. Окончательный код проекта данной части Вы можете найти в репозитории в ветке part2 или ветке master https://github.com/dionic-i/private-carpark.git.

  Jest, vue.js

  Смотреть все посты