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

Введение

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

Существует две основные библиотеки для тестирования:

  • Jest - она включает почти все аспекты тестирования (assertion, mocking, and coverage solution);
  • Mocha - это только библиотека для написания тестов, к ней обычно подключается библиотеки для (assertion, mocking, and coverage solution). Например Chai - assertion, Sinom - mocking и Istambul - coverage solution.

По существу, получается, что Mocha более сложная, но и более гибкая, Jest - проще и имеет все из коробки.

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

Описание приложения и его структура

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

Какие компоненты мы будем разрабатывать в данной части:

  • Компонент шапки приложения;
  • Компонент отдельного автомобиля;
  • Компонент списка автомобилей;
  • Компонент фильтров.

Так как в рамках данной статьи мы не будем затрагивать разработку backend части, то все данные мы будем сохранять в локальном хранилище.

Вот такое приложение у нас должно получится в конце данной части:

Настройка приложения

Для начала воспользуемся простым шаблоном vue для генерации начальной структуры приложения: vue init webpack-simple.

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

Добавим в файл package.json:

  • в секцию dependencies библиотеки: vuex и buefy;
  • в секцию devDependencies: babel-jest, @vue/test-utils, jest, jest-vue-preprocessor.

После внесения записей запускаем команду npm i и ждем пока все нужные пакеты установятся.

Теперь необходимо настроить библиотеку для тестирования jest. Для этого в файл package.json добавим новую секцию сразу после devDependencies:

  "jest": {
    "verbose": true,
    "moduleFileExtensions": [
      "js",
      "json",
      "vue"
    ],
    "transform": {
      ".*\\.(vue)$": "<rootDir>/node_modules/jest-vue-preprocessor",
      "^.+\\.js$": "<rootDir>/node_modules/babel-jest"
    },
    "moduleNameMapper": {
      "^@/(.*)$": "<rootDir>/src/$1"
    }
  }

И добавим новую простую команду для npm в секцию scripts: "test": "jest".

Настройка проекта закончена, приступаем к разработке...

Разработка приложения

Первым делом поменяем файл App.vue и проверим, что настроили пакеты приложения правильно написав простой тест.

Вот так выглядит наш файл App.vue после удаления лишних тегов:

<template>
  <div id="app">
  </div>
</template>
<script>
export default {
  name: 'app',
}
</script>

Добавим файл App.test.js со следующим тестом:

import { shallowMount } from '@vue/test-utils'
import App from './App.vue'

describe('test App', () => {
  it('works', () => {
    const wrapper = shallowMount(App)
  })
})

И выполним команду в терминале npm run test. Как мы увидим тест завершился удачно.

Давайте приступим к разработке компонентов приложения. Первым делом соорудим простой презентационный компонент для отображения шапки с названием и напишем к нему тесты. В папке components добавляем папку AppHeader и создаем в ней два файла AppHeader.vue и AppHeader.test.js.

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

<template>
  <h1 class="has-background-light">{{ title }}</h1>
</template>

<script>
  export default {
    name: 'AppHeader',
    props: {
      title: String
    }
  }
</script>

Как мы видим, это простейший компонент для отображения названия приложения, принимает одно свойство title и выводит его в заголовке h1. Рассмотрим файл с тестами к такому компоненту.

Код теста:

import { mount } from '@vue/test-utils'
import AppHeader from './AppHeader.vue'

describe('test AppHeader', () => {
  it('test title', () => {
   const wrapper = mount(AppHeader, {
     propsData: {
       title: 'Мой автопарк',
     },
    })
    expect(wrapper.html().includes('Мой автопарк')).toBe(true)
  })

  it('test class', () => {
    const wrapper = mount(AppHeader, {
      propsData: {
        title: 'Мой автопарк',
      },
    })
     expect(wrapper.classes('has-background-light')).toBe(true)
  })
})

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

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

Получается, в нашем примере мы смонтировали наш компонент в переменную wrapper, передав нужное значение свойства title, а затем проверили наличие этого текста в html коде, который выдал нам этот компонент после рендеринга. Вот так просто! Во втором тесте мы проверили наличие класса у компонента.

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

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

<template>
  <div :class="['card', isActive ? 'active' : '']">
    <div class="card-image">
      <figure class="image is-4by3">
        <img :src="photo" alt="Car image" />
      </figure>
    </div>
    <div class="card-content">
      <div class="media">
        <div class="media-content">
          <p class="is-size-6 title">{{ title }}</p>
        </div>
      </div>
      <div class="content">
        <p>{{ description }}</p>
        <ul class="options is-size-7">
          <li><strong>Max скорость: </strong>{{ maxSpeed }} км/ч</li>
          <li><strong>Пробег: </strong>{{ currentRun }} тыс. км.</li>
        </ul>
      </div>
    </div>
    <footer class="card-footer">
      <a href="#" :class="['card-footer-item', isActive ? 'has-text-danger' : 'has-text-success']"
         @click.prevent="toggleBooking"
      >
        {{ linkText }}
      </a>
    </footer>
  </div>
</template>
 
<script>
  export default {
    name: 'CarCard',
    props: {
      title: {
        type: String,
        required: true
      },
      description: {
        type: String,
        required: true
      },
      photo: {
        type: String,
        default: 'https://bulma.io/images/placeholders/1280x960.png'
      },
      maxSpeed: {
        type: Number,
        required: true
      },
      currentRun: {
        type: Number,
        required: true
      },
      isActive: {
        type: Boolean,
        default: false
      }
    },
    computed: {
      linkText() {
        return this.isActive ? 'Отказаться' : 'Забронировать'
      }
    },
    methods: {
     toggleBooking() {
        this.$emit('toggle-booking', !this.isActive)
      }
    },
  }
</script>
 
<style lang="scss" scoped>
  .options {
    list-style-type: none;
    padding: 0;
    margin: 0;
  }
  .active {
    border: 2px solid hsl(141, 71%, 48%)
  }
</style>

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

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

const cardFactory = (propsData = {}) => {
  const props = {
    title: 'BMW X5',
    description: 'Super Car!',
    maxSpeed: 250,
    currentRun: 20,
  }
  return mount(CarCard, {
    propsData: {
      ...props,
      ...propsData
    },
  })
}

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

  it('test required props', () => {
    const wrapper = cardFactory()
    expect(wrapper.find('.content > p').text().includes('Super Car!')).toBe(true)
    expect(wrapper.find('.media-content > p').text().includes('BMW X5')).toBe(true)
    expect(wrapper.find('li:first-of-type').html()).toContain('<strong>Max скорость: </strong>250 км/ч')
    expect(wrapper.find('li:nth-of-type(2)').html()).toContain('<strong>Пробег: </strong>20 тыс. км.')
    expect(wrapper.find('div.card').classes('active')).toBe(false)
  })

Просто ищем функцией find нужный узел и проверяем либо html, либо класс узла, который он должен содержать. Хочется отметить, что функция find довольно удобная и позволяет найти любой компонент или тег, так как принимает на свой вход как селектор в различных вариациях или вообще название компонента. Виды селекторов можно подсмотреть в документации - https://vue-test-utils.vuejs.org/ru/api/selectors.html.

Окей, мы проверили, что наш компонент правильно выдает структуру html, как мы и хотели. Теперь давайте протестируем логику работы свойства isActive. Оно отвечает за то, выбран ли автомобиль в определенный момент или нет.

  it('test isActive property', () => {
    const wrapper = cardFactory({isActive: true})
 
    expect(wrapper.find('div.card').classes('active')).toBe(true)
 
    const link = wrapper.find('.card-footer > a')
    expect(link.exists()).toBe(true)
    expect(link.classes('has-text-danger')).toBe(true)
    expect(link.text()).toContain('Отказаться')
 
    wrapper.setProps({isActive: false})
    expect(link.classes('has-text-success')).toBe(true)
    expect(link.text()).toContain('Забронировать')
  })

Код теста довольно простой, но стоит отметить пару новых моментов. На этот раз мы создали объект-обертку сразу передав компоненту свойство isActive = true с помощью нашей функции фабрики. Затем проверили наличие класса active у карточки. После этого мы ищем кнопку в футере и проверяем наличие класса и текст внутри кнопки. После этих проверок, мы с помощью функции setProps опять меняем свойство isActive и проверяем класс кнопки и текст внутри. Все довольно просто.

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

  it('test computed property', () => {
    const localThis = { isActive: false }
    const fn = CarCard.computed.linkText
 
    expect(fn.call(localThis)).toBe('Забронировать')
 
    localThis.isActive = true
    expect(fn.call(localThis)).toBe('Отказаться')
  })

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

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

Для этого в нашей обертке существует полезные методы emitted и emittedByOrder, которые спешат к нам на помощь в данной ситуации. Также, в нашем распоряжении есть экземпляр самого компонента после монтирования в свойстве vm. Нам надо проверить, что событие вызывалось при клике на кнопку и проверить вызывалось ли оно с правильными данными.

  it('test emit events', () => {
    const wrapper = cardFactory()
    wrapper.vm.toggleBooking()

    let event = wrapper.emitted('toggle-booking')
    expect(event).toBeTruthy()
    expect(event.length).toBe(1)
    expect(event[0]).toEqual([true])

    wrapper.setProps({isActive: true})
    wrapper.vm.toggleBooking()
    event = wrapper.emitted('toggle-booking')
    expect(event.length).toBe(2)
    expect(event[1]).toEqual([false])
  })

Разберем наш тест. Как обычно вначале мы создали нашу обертку wrapper, затем вызвали метод toggleBooking нашего компонента, после этого воспользовались методом emitted для получения нужного нам события передав его название и проверили: вызывалось ли оно, сколько раз вызывалось и что вернуло. Во второй части теста мы изменили свойство компонента isActive и провели проверки еще раз.

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

Код теста:

  it('test booking without mount', () => {
    const events = {}
    const $emit = (event, ...args) => { events[event] = [...args] }

    CarCard.methods.toggleBooking.call({ $emit })
    expect(events['toggle-booking']).toEqual([true])

    CarCard.methods.toggleBooking.call({ isActive: true, $emit })
    expect(events['toggle-booking']).toEqual([false])
  })

Делается это довольно просто. Нам необходимо сохранить объект events в замыкании и далее создать сымитировать функцию $emit для компонента, добавив ее в объект в контексте которого мы хотим вызвать тестируемую функцию. 

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

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

<template>
  <div>
    <div class="columns">
      <div class="column">
        <h2 class="subtitle">{{ title }}</h2>
      </div>
    </div>
    <div class="columns is-multiline">
      <template v-if="cars.length > 0">
        <div class="column is-one-quarter" v-for="car in cars">
          <car-card
                  :key="car.id"
                  :title="car.title"
                  :description="car.description"
                  :max-speed="car.maxSpeed"
                  :current-run="car.currentRun"
                  :photo="car.photo"
                  :is-active="car.isActive"
                  @toggle-booking="book => onToggleBook(book, car)"
          ></car-card>
        </div>
      </template>
      <div class="column is-full" v-else>
        <h2 class="is-centred">{{ emptyText }}</h2>
      </div>
    </div>
  </div>
</template>

<script>

  import CarCard from '../CarCard/CarCard.vue'

  export default {
    name: 'CarCardList',
    components: {
      CarCard
    },
    props: {
      cars: {
        type: Array,
        default: () => {
          return []
        }
      },
      title: {
        type: String,
        default: 'Список машин'
      },
      emptyText: {
        type: String,
        default: 'У вас пока нет машин...'
      }
    },
    methods: {
      onToggleBook(book, car) {
	// будем тут выбирать машинку...
      }
    }
  }
</script>

<style lang="scss">
</style>

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

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

Второй способ - это явно указать какие компоненты мы хотим привратить в заглушки при рендеренге родительского компонента.

  it('test cars count', () => {
    const cars = [
      { id: 1, title: '11', description: '11', maxSpeed: 50, currentRun: 10  },
      { id: 1, title: '11', description: '11', maxSpeed: 50, currentRun: 10  },
    ]

    const wrapper = mount(CarCardList, {
      propsData: {
        cars,
        emptyText: 'Нет записей...',
      },
      stubs: {
        CarCard: true,
      },
    })

    expect(wrapper.findAll(CarCard).length).toBe(2)
  })

Нашим следующим шагом будет рассмотрение работы компонентов ввода данных пользователем. А сделаем мы это на примере компонента фильтров. Сначала создадим базовый компонент select для выбора фильтра. Рассмотрим код компонента:

<template>
  <b-select
          ref="selectElement"
          v-bind="selectOptions"
          :value="value"
          @change="selectItem"
  >
    <option
            v-for="item in selectItems"
            :key="item[idField]"
            :label="item[nameField]"
            :value="item[idField]"
    >
      {{ item[nameField] }}
    </option>
  </b-select>
</template>

<script>

  export default {
    name: 'BaseSelect',
    props: {
      idField: {
        type: String,
        default: 'id'
      },
      nameField: {
        type: String,
        default: 'name'
      },
      items: {
        type: Array,
        required: true
      },
      value: {
        type: [Number, String],
      },
      withEmpty: {
        type: Boolean,
        default: false
      },
      options: {
        type: Object,
        default: function () {
          return {}
        }
      }
    },
    data() {
      const selectItems = [...this.items]
      if (this.withEmpty) {
        selectItems.unshift({
          [this.idField]: 0,
          [this.nameField]: '',
        })
      }
      return {
        selectItems
      }
    },
    computed: {
      selectOptions() {
        const defOptions = {
          size: 'is-medium',
          expanded: true
        }
        return { ...defOptions, ...this.options }
      },
    },
    methods: {
      selectItem(value) {
        this.$emit('select-item', value)
      },
      focus() {
        if (this.$refs.selectElement && this.$refs.selectElement.focus) {
          this.$refs.selectElement.focus()
        }
      }
    }
  }
</script>

Код компонента довольно большой, содержит некоторые интересные момент, которые стоит рассмотреть и нам необходимо покрыть данный компонент тестами, конечно. 

Итак, первое что стоит отметить, что мы прописали у компонента свойство value и добавили свой обработчик на выбор значения. Да, сейчас мы не можем использовать на нем директиву v-model, но при переходе в дальнейшем на vuex так будет удобнее определять обработчики события, так как привязка значений инпутов через v-model при использовании vuex требует написания геттеров и сеттеров свойства, так как менять свойства вне мутаций vuex запрещает. Но это тема уже следующей части.

Рассмотрим один интересный момент при тестировании данного компонента (остальные тесты на этот компонент вы можете посмотреть в репозитории). Так как при создании этого компонента мы использовали стороннюю библиотеку компонентов, в частности buefy, то ее необходимо будет подключить, чтобы рендеринг компонента проходил без ошибок. Еще иногда, для того чтобы узнать внутреннюю структуру стороннего компонента при рендеринге, ну например, для поиска различных узлов при тестах, можно его вывести в консоль вызвав у его обертки метод html: console.log(wrapper.html())

Рассмотрим один из тестов этого компонента:

  it('test emit events', () => {
    const wrapper = selectFactory()

    const select = wrapper.find('select')
    expect(select.exists()).toBe(true)

    select.setValue(2)
    let event = wrapper.emitted('select-item')
    expect(event).toBeTruthy()
    expect(event.length).toBe(1)
    expect(event[0]).toEqual([2])

    select.setValue(1)
    event = wrapper.emitted('select-item')
    expect(event.length).toBe(2)
    expect(event[1]).toEqual([1])
  })

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

Еще в данном компоненте можно протестировать вычисляемое свойство selectOptions, html-структуру, установку свойства withEmpty. Все эти тесты вы можете найти в исходных кодах проекта.

Итоги

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

Если подводить итоги относительно того, чему мы научились, то:

  1. Изучили метод монтирования компонента в тестах;
  2. Рассмотрели методы компонента-обертки, получаемые функциями mount и shallowMount;
  3. Научились тестировать методы и вычисляемые свойства компонента;
  4. Рассмотрели варианты установки заглушек при тестировании компонента.

«А что дальше?» - спросите Вы. Как говорится в одной рекламе, «А дальше — больше» =). В следующей части статьи мы воспользуемся библиотекой для управления состоянием нашего приложения vuex, рассмотрим способы тестирования мутаций, действий и геттеров нашего приложения. А также, бонусом будет рассмотрена работа с порталами и их применение для создания модальных окон.

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

  Jest, vue.js

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