Навіщо та як тестувати фронтенд на прикладі бібліотеки Vue Test Utils

Тестування коду — один із ключових підходів у розробці, яким повинен володіти кожен розробник

Тестування коду у фронтенді

Тестування коду — один із ключових підходів у розробці, яким повинен володіти кожен розробник.

Довгий час фронтенд не потребував тестування, адже більшість задач на JavaScript зводилася до маніпуляцій із DOM та додавання мінімальної інтерактивності. Сьогодні ж значна частина логіки виконується на стороні клієнта. У міру зростання складності додатків ми повинні бути впевнені в стабільності нашого коду. Додавання нового методу чи класу не повинно порушувати роботу існуючих модулів.

У цій статті ми зосередимося на юнит-тестуванні — важливому елементі неперервної інтеграції (CI) та сучасної розробки.

Навіщо потрібні юніт-тести

  • Вони допомагають переконатися, що код виконує очікувану логіку.
  • Дають змогу виявляти баги на ранніх етапах.
  • Покращують читабельність коду.
  • Дають впевненість, що код можна повторно використовувати.
  • Служать документацією — за тестами легко зрозуміти призначення методу.

Хороші практики юніт-тестування:

  • принцип єдиної відповідальності;
  • передбачуваність;
  • слабка зв’язаність (low coupling).

Якими мають бути хороші тести

  • Зрозумілими й читабельними.
  • Ізольованими.
  • Такими, що перевіряють одне конкретне очікуване поведінкове правило.

Як слід писати тести

  • тести важливо писати під час розробки, а не після.

Це найменш витратний спосіб, оскільки дає змогу суттєво зменшити ймовірність повернення до старого коду — відповідно, ви витратите щонайменше вдвічі менше часу.

  • тестами слід «покривати» публічний інтерфейс.

Оскільки в цій статті йдеться про фронтенд-розробку, під публічним інтерфейсом мається на увазі той, з яким взаємодіє користувач. Тести мають імітувати дії реальних користувачів вашого сайту або застосунку. Ви повинні впевнитися, що публічний API не буде зламано. Те, що відбувається «під капотом», слід перевіряти опосередковано — важливо лише, щоб ваш API залишався надійним.

  • необхідно окремо тестувати логіку й окремо — представлення.

У нашій компанії для фронтенд-розробки ми використовуємо фреймворк Vue.js, тому в цій статті я коротко розповім, як тестувати логіку й представлення компонентів Vue.js — декларативних багаторазових сутностей, у яких шари представлення й управління даними розділено.

Тестування компонентів користувацького інтерфейсу — завдання не з простих, але розділення шарів дає змогу точніше контролювати цей процес.

Що будемо використовувати для тестування

Як інструменти для тестування ми використовуватимемо офіційну бібліотеку модульного тестування для Vue.jsVue Test Utils і JavaScript-фреймворк для тестування від Facebook — Jest.

Vue Test Utils дає змогу ізольовано монтувати компоненти Vue та імітувати взаємодію з користувачем. У ній є всі необхідні утиліти для тестування однофайлових компонентів, зокрема тих, що використовують Vue Router або Vuex.

Jest — це повнофункціональний запускник тестів, який майже не потребує налаштувань.

У документації Vue Test Utils не рекомендується покривати тестами кожен рядок коду, адже це надто зосереджує увагу на деталях внутрішньої реалізації компонентів і може призвести до створення крихких (нестійких) тестів.

Як уже зазначалося раніше, один конкретний тест має перевіряти, що певні вхідні дані (взаємодія користувача зі сторінкою або зміна вхідних параметрів компонента), передані компоненту, приводять до очікуваного результату.

У цій статті ми розглянемо просте налаштування тестового середовища за допомогою Vue CLI, способи тестування рендерингу компонентів, а також кілька сценаріїв, що виникають під час тестування:

  • передача вхідних параметрів;
  • використання обчислюваних властивостей;
  • виклик методів;
  • використання життєвих хуків;
  • симуляція дій користувача;
  • використання моків (фіктивних реалізацій інтерфейсів) для відповідей API;
  • використання знімків (snapshots).

Ми не тестуватимемо обчислювані властивості або методи безпосередньо. Їхній результат перевірятиметься через тестування публічного інтерфейсу.

Що ми протестуємо

Ми створимо Vue-компонент Youtube-виджета, схожий на розділ “Відео” будь-якого YouTube-каналу. Код доступний на GitHub та NPM.

Основні функції компонента:

  • Завантаження відео з конкретного каналу.
  • Отримання мета-інформації про канал (назва, логотип, кількість підписників).
  • Можливість підписатися на канал.
YouTubeWidget

Основні його функції:

  • отримання та можливість додаткового завантаження відеозаписів потрібного каналу;
  • отримання метаінформації про канал: назви, логотипу, кількості підписників;
  • можливість підписатися на канал.

Встановленя та налаштування середовища

Ця стаття передбачає, що ви маєте базові знання та досвід у налаштуванні й створенні нового проєкту з використанням Vue CLI.

Давайте підготуємо проєкт:

  1. Встановіть глобально пакет Vue CLI, якщо він ще не встановлений:
npm install -g @vue/cli
  1. Створіть новий Vue-проєкт:
vue create <project-name>
  1. Встановіть бібліотеку модульного тестування Vue-застосунків Vue Test Utils:
npm i @vue/test-utils
  1. Встановіть плагін Jest для юніт-тестування Vue-застосунків:
vue add unit-jest
  1. Сконфігуруйте Jest:
  • у корені проєкту створіть файл jest.config.js;
  • укажіть у ньому опції, які дадуть Jest зрозуміти, що для тестування вашого застосунку буде використовуватися плагін @vue/cli-plugin-unit-jest:
//jest.config.js

module.exports = {
 preset: '@vue/cli-plugin-unit-jest'
}
  1. Крім того, я встановлю 2 пакети для використання препроцесорів SASS/SCSS:
npm install -D sass-loader node-sass
  1. Також нам знадобиться HTTP-клієнт axios для створення HTTP-запитів до YouTube API 3:
npm i axios

Зверніть увагу на ваш файл package.json у корені проєкту.

Тепер в опції scripts, окрім стандартних команд serve, lint і build, з’явилася ще одна — test:unit, саме її ми й будемо використовувати для тестування:

"scripts": {
 "serve": "vue-cli-service serve",
 "build": "vue-cli-service build",
 "test:unit": "vue-cli-service test:unit",
 "lint": "vue-cli-service lint"
}

Крім того, в корені проєкту була згенерована папка tests/unit з файлом example.spec.js для тестування всередині.

Перш ніж ми перейдемо до написання й тестування нашого компонента, виконаємо ще кілька кроків:

  • видаліть компонент HelloWorld.vue у папці src/components;
  • видаліть імпорт цього компонента у файлі App.vue у папці src:
//App.vue

<template>
 <div id="app"></div>
</template>

<script>
export default {
 name: 'App',
 components: {}
}
</script>
  • створіть у папці src файл main.scss з такими стилями:
//main.scss

@import url('https://fonts.googleapis.com/css2?family=Roboto:wght@400;700&display=swap');

.youtube-widget {
 font-family: 'Roboto', sans-serif;

 &__header {
   display: flex;
   flex-wrap: wrap;
   justify-content: space-between;
   align-items: center;
   padding: 24px;
   background: #FAFAFA;
 }

 &__info {
   display: flex;
   flex-wrap: wrap;
   align-items: center;
 }

 &__logo {
   border-radius: 100%;
 }

 &__data {
   margin-left: 15px;
 }

 &__title {
   margin: 0;
 }

 &__subscribers-count {
   color: #606060;
 }

 &__subscribe-btn,
 &__load-more-btn {
   padding: 10px 16px;
   border-radius: 2px;
   border: none;
   font-weight: bold;
   text-transform: uppercase;
   text-decoration: none;
 }

 &__subscribe-btn {
   background: #CC0000;
   color: #FFFFFF;
 }

 &__inner {
   display: flex;
   flex-wrap: wrap;
   margin-top: 24px;
 }

 &__load-more-btn {
   display: block;
   margin: 32px auto 0 auto;
   background: #FAFAFA;
   color: #606060;
   cursor: pointer;
 }
}

Нагадую, що код усіх файлів ви можете знайти в репозиторії на GitHub.

  • імпортуйте цей файл стилів у компонент:
//YouTubeWidget.vue

<template>
 <div id="app"></div>
</template>

<script>
export default {
 name: 'App',
 components: {}
}
</script>

<style lang="scss">
   @import "../main.scss";
</style>
  • видаліть файл example.spec.js у папці tests/unit.

Створення та тестування компонента

Створення компонента “YouTubeWidget”

Приступимо до створення компонента:

  1. Створіть файл компонента YouTubeWidget.vue у папці src/components зі таким вмістом:
//YouTubeWidget.vue

<template>
   <div class="youtube-widget"></div>
</template>

<script>
   export default {}
</script>

<style lang="scss"></style>
  1. Імпортуємо його в головний файл застосунку App.vue:
//YouTubeWidget.vue

<template>
   <div class="youtube-widget"></div>
</template>

<script>
   export default {}
</script>

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

Тестування рендерингу “YouTubeWidget”

На цьому етапі ми вже можемо написати наш перший юніт-тест, який перевіряє:

  • чи є створений компонент екземпляром Vue (тобто чи справді це об’єкт, створений через Vue);
  • чи є цей компонент саме YouTubeWidget.vue.

Нам потрібно створити файл із розширенням .spec.js, щоби Jest розпізнав його як тестовий.

Створимо в папці tests/unit файл YouTubeWidget.spec.js.

Перше, що потрібно зробити, — імпортувати дві функції з бібліотеки Vue Test Utils: createLocalVue і mount.

createLocalVue — повертає клас Vue, який ви можете налаштовувати незалежно від глобального класу Vue: додавати плагіни, міксіни тощо.

mount – творює обгортку, яка містить змонтований і відрендерений компонент Vue.

Також потрібно імпортувати сам компонент YouTubeWidget.vue:

//YouTubeWidget.spec.js

import { createLocalVue, mount } from '@vue/test-utils';
import YouTubeWidget from '../../src/components/YouTubeWidget.vue';

Тепер ми готові написати тест відповідно до конвенцій Jest і Vue Test Utils.

На цьому етапі ми використаємо дві функції з обгортки Wrapper:

  • isVueInstance — перевіряє, що обгортка є екземпляром Vue;
  • is — перевіряє, що обгортка відповідає заданому селектору.
// YouTubeWidget.spec.js

import { createLocalVue, mount } from '@vue/test-utils';
import YouTubeWidget from '../../src/components/YouTubeWidget.vue';

// Групуємо логічно пов’язані тести в блок “YouTubeWidget.vue testing”
describe('YouTubeWidget.vue testing', () => {

  // Створюємо окремий екземпляр Vue
  const vueInstance = createLocalVue();

  // Монтуємо компонент із використанням створеного екземпляра
  const wrapper = mount(YouTubeWidget, {
    vueInstance
  });

  // Пишемо сам тест
  it('initialized correctly', () => {
    // Перевіряємо, що компонент є екземпляром Vue
    expect(wrapper.isVueInstance()).toBe(true);

    // Перевіряємо, що обгортка відповідає компоненту YouTubeWidget
    expect(wrapper.is(YouTubeWidget)).toBe(true);
  });
});

Запустіть тест, ввівши в консолі команду:

npm run test:unit
Тестування рендерингу YouTubeWidget

Наш тест успішно пройдено!

До речі, з API Vue Test Utils і методами ви можете ознайомитись тут.

Тестування вхідних параметрів “YouTubeWidget”

У попередньому розділі ми протестували рендеринг компонента.

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

//YouTubeWidget.vue

<template>
   <div class="youtube-widget">
       <div class="youtube-widget__header">
           <div class="youtube-widget__info">
               <img :src="channelAvatar" alt="" class="youtube-widget__logo">
               <div class="youtube-widget__data">
                   <h2 class="youtube-widget__title">{{ channelTitle }}</h2>
                   <div class="youtube-widget__subscribers-count">{{ subscribersCount }} {{ subscribersCountText }}</div>
               </div>
           </div>
           <a :href="'http://www.youtube.com/channel/' + channelId + '?sub_confirmation=1'" class="youtube-widget__subscribe-btn">{{ subscribeBtnText }}</a>
       </div>
       <div class="youtube-widget__inner"></div>
       <button class="youtube-widget__load-more-btn">{{ loadMoreBtnText }}</button>
   </div>
</template>

<script>

   export default {
       props: {
           channelId: {
               type: String,
               required: true
           },
           subscribersCountText: {
               type: String,
               required: false,
               default: 'subscribers'
           },
           subscribeBtnText: {
               type: String,
               required: false,
               default: 'Subscribe'
           },
           loadMoreBtnText: {
               type: String,
               required: false,
               default: 'Load more'
           }
       },
       data() {
           return {
               subscribersCount: 10000,
               channelAvatar: 'https://i.ibb.co/wdbv1xY/imgonline-com-ua-Resize-w-FKj-Qa7-D6l-Y.png',
               channelTitle: 'NASA',
               videos: []
           }
       }
   }

</script>

<style lang="scss">
   @import "../main.scss";
</style>

Для тестування я обрав канал NASA. Поки що ми не виконуємо запит до YouTube API для отримання списку відео та іншої інформації, а спробуємо протестувати наш компонент із довільними даними.

Для цього в методі data я додав довільні значення у змінні subscribersCount, channelAvatar та channelTitle, а масив videos залишив порожнім.

//YouTubeWidget.vue

 data() {
   return {
     subscribersCount: 10000,
     channelAvatar: 'https://i.ibb.co/wdbv1xY/imgonline-com-ua-Resize-w-FKj-Qa7-D6l-Y.png',
     channelTitle: 'NASA',
     videos: []
   }
 }

Крім того, наш компонент приймає вхідні параметри, необхідні для його роботи:

channelId — обов’язковий параметр, що відповідає за ID YouTube-каналу. Потрібен для створення запиту до YouTube API, але поки що ми використовуємо його лише для кнопки підписки на канал, яка вимагає цей ID.

subscribersCountText — додатковий текст, що відображає кількість підписників каналу. Цей параметр я виніс у публічний інтерфейс на випадок, якщо знадобиться локалізувати цей текст.

subscribeBtnText — текст кнопки підписки на канал. Цей параметр також винесено у публічний інтерфейс для можливості локалізації.

loadMoreBtnText — текст кнопки додаткового завантаження відео. Він винесений у публічний інтерфейс для можливості локалізації відповідного тексту.

Переходимо до файлу App.vue і передаємо в компонент ті параметри, які ми очікуємо.

//App.vue

<template>
 <div id="app">
   <YouTubeWidget
           channelId="UCLA_DiR1FfKNvjuUpBHmylQ"
            subscribersCountText=”подписчиков"
           subscribeBtnText="Подписаться"
           loadMoreBtnText="Загрузить еще"
   ></YouTubeWidget>
 </div>
</template>

<script>
 import YouTubeWidget from "./components/YouTubeWidget";

 export default {
   name: 'App',
   components: {
     YouTubeWidget
   }
 }
</script>

Запускаємо сервер для розробки:

npm run serve

Зараз наш компонент має виглядати приблизно так.

Ми отримали локалізовані дані віджета,

YouTubeWidget із вхідними параметрами

а кнопка підписки при кліку веде на YouTube-канал NASA, на якому з’являється спливаюче вікно підтвердження.

Спливаюче вікно підтвердження підписки на YouTube

Тепер ми можемо протестувати публічний інтерфейс компонента з вхідними параметрами.

Перше, що нам потрібно зробити — створити об’єкт із мок-даними, які будемо передавати в локально створену обгортку у файлі YouTubeWidget.spec.js.

//YouTubeWidget.spec.js

const mockData = {
   channelId: 'UCLA_DiR1FfKNvjuUpBHmylQ',
   resultsPerRequest: 1,
   subscribersCountText: "підписників",
   subscribeBtnText: "Підписатися",
   loadMoreBtnText: "Завантажити ще"
};

Далі ми повинні передати ці дані в об’єкт propsData обгортки, який встановлює вхідні параметри екземпляра компонента під час монтування:

//YouTubeWidget.spec.js

const wrapper = mount(YouTubeWidget, {
   vueInstance,
   propsData: {
       channelId: mockData.channelId,
       subscribersCountText: mockData.subscribersCountText,
       subscribeBtnText: mockData.subscribeBtnText,
       loadMoreBtnText: mockData.loadMoreBtnText
   }
});

Напишемо наш наступний тест, у якому використаємо дві функції обгортки Wrapperprops та find:

  • props — повертає об’єкт із вхідними параметрами обгортки.
  • find — повертає перший DOM-вузол або Vue-компонент, що відповідає селектору.

Дотримуючись принципу єдиної відповідальності, ми створюємо нову функцію it всередині блоку describe, яка буде відповідати за тестування вхідних параметрів та їх відображення в розмітці.

//YouTubeWidget.spec.js

it('renders props when passed', () => {
//Чекаємо, що вхідний параметр "channelId" ідентичний тому, що ми передали 
expect(wrapper.props().channelId).toBe(mockData.channelId);

//Чекаємо, що вхідний параметр subscribersCountText ідентичний тому, що ми передали 
expect(wrapper.props().subscribersCountText).toBe(mockData.subscribersCountText);

//Чекаємо, що вхідний параметр subscribeBtnText ідентичний тому, що ми передали 
expect(wrapper.props().subscribeBtnText).toBe(mockData.subscribeBtnText);

//Чекаємо, що вхідний параметр “loadMoreBtnText” ідентичний тому, що ми передали
expect(wrapper.props().loadMoreBtnText).toBe(mockData.loadMoreBtnText);

//Тут же перевіримо, чи відображаються передані дані в розмітці

//Чекаємо, що атрибут “href” кнопки-підписки на канал ідентичний тому, що ми передали
expect(wrapper.find('.youtube-widget__subscribe-btn').attributes('href')).toEqual('http://www.youtube.com/channel/' + mockData.channelId + '?sub_confirmation=1');

//Чекаємо, що текст кнопки-підписки на канал ідентичний тому, що ми передали 
expect(wrapper.find('.youtube-widget__subscribe-btn').text()).toEqual(mockData.subscribeBtnText);

//Чекаємо, що текст кнопки додаткового завантаження відео ідентичний тому, що ми передали 
expect(wrapper.find('.youtube-widget__load-more-btn').text()).toEqual(mockData.loadMoreBtnText);
});

Так виглядає зараз “YouTubeWidget.spec.js” в цілому:

//YouTubeWidget.spec.js

import { createLocalVue, mount } from '@vue/test-utils';
import YouTubeWidget from '../../src/components/YouTubeWidget.vue';

const mockData = { 
channelId: 'UCLA_DiR1FfKNvjuUpBHmylQ', 
результатівPerRequest: 1, 
subscribersCountText: "передплатників", 
subscribeBtnText: "Підписатися", 
loadMoreBtnText: "Завантажити ще"
};

describe('YouTubeWidget.vue', () => { 
const vueInstance = createLocalVue(); 

const wrapper = mount(YouTubeWidget, { 
vueInstance, 
propsData: { 
channelId: mockData.channelId, 
subscribersCountText: mockData.subscribersCountText, 
subscribeBtnText: mockData.subscribeBtnText, 
loadMoreBtnText: mockData.loadMoreBtnText 
} 
}); 

it('initialized correctly', () => { 
expect(wrapper.isVueInstance()).toBe(true); 
expect(wrapper.is(YouTubeWidget)).toBe(true); 
}); 

it('renders props when passed', () => { 
expect(wrapper.props().channelId).toBe(mockData.channelId); 
expect(wrapper.props().subscribersCountText).toBe(mockData.subscribersCountText); 
expect(wrapper.props().loadMoreBtnText).toBe(mockData.loadMoreBtnText); 
expect(wrapper.props().subscribeBtnText).toBe(mockData.subscribeBtnText); 

expect(wrapper.find('.youtube-widget__subscribe-btn').attributes('href')).toEqual('http://www.youtube.com/channel/' + mockData.channelId + '?sub_confirmation=1'); 
expect(wrapper.find('.youtube-widget__subscribe-btn').text()).toEqual(mockData.subscribeBtnText); 
expect(wrapper.find('.youtube-widget__load-more-btn').text()).toEqual(mockData.loadMoreBtnText); 
});
});

Запускаємо тест:

Тестирование входных параметров YouTubeWidget

Тести проходять!

Тестування асинхронної логіки та хуків життєвого циклу

Ми вже протестували рендеринг компонента та його вхідні параметри — тепер настав час попрацювати з асинхронною логікою.

Зараз ми налаштуємо HTTP-запити до YouTube API для отримання відеозаписів каналу, його назви, логотипа та кількості підписників, а потім запустимо тести, щоб переконатися, що при певній відповіді сервера наш компонент працює так, як очікується.

Для початку створіть у папці src нову папку з назвою api, а в ній — файл YouTubeWidget.js зі следующим вмістом:

//YouTubeWidget.js

import axios from "axios";

export const getChannelTitleAndAvatar = async function(channelId, apiKey) {
   const response = await axios.get(`https://www.googleapis.com/youtube/v3/channels?part=snippet&id=${channelId}&key=${apiKey}`);

   return response.data;
};

export const getSubscribersCount = async function(channelId, apiKey) {
   const response = await axios.get(`https://www.googleapis.com/youtube/v3/channels?part=statistics&id=${channelId}&key=${apiKey}`);

   return response.data;
};

export const getVideos = async function(channelId, apiKey, resultsCount) {
   const response = await axios.get(`https://www.googleapis.com/youtube/v3/search?key=${apiKey}&channelId=${channelId}&part=snippet,id&order=date&maxResults=${resultsCount}`);

   return response.data;
};

export const loadMoreVideos = async function(channelId, apiKey, resultsCount, nextPageToken) {
   const response = await axios.get(`https://www.googleapis.com/youtube/v3/search?key=${apiKey}&channelId=${channelId}&part=snippet,id&order=date&pageToken=${nextPageToken}&maxResults=${resultsCount}`);

   return response.data;
};

Ми імпортуємо HTTP-клієнт axios для створення HTTP-запитів до сервера YouTube API, а потім експортуємо назовні функції для отримання:

  • назви каналу та його логотипа
  • кількості підписників каналу
  • відеозаписів каналу
  • додаткових відеозаписів

Переходимо до компонента YouTubeWidget.vue і трансформуємо його:

//YouTubeWidget.vue

<template>
    <div class="youtube-widget">
        <div class="youtube-widget__header">
            <div class="youtube-widget__info">
                <img :src="channelAvatar" alt="" class="youtube-widget__logo">
                <div class="youtube-widget__data">
                    <h2 class="youtube-widget__title">{{ channelTitle }}</h2>
                    <div class="youtube-widget__subscribers-count">{{ subscribersCount }} {{ subscribersCountText }}</div>
                </div>
            </div>
            <a :href="'http://www.youtube.com/channel/' + channelId + '?sub_confirmation=1'" class="youtube-widget__subscribe-btn">{{ subscribeBtnText }}</a>
        </div>
        <div class="youtube-widget__inner">
            <iframe v-for="video in videos" :key="video.id.videoId" :src="'https://www.youtube.com/embed/' + video.id.videoId" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>
        </div>
        <button class="youtube-widget__load-more-btn">{{ loadMoreBtnText }}</button>
    </div>
</template>

<script>

    import { getChannelTitleAndAvatar, getSubscribersCount, getVideos, loadMoreVideos } from "../api/YouTubeWidget";

    export default {
        props: {
            apiKey: {
                type: String,
                required: true
            },
            channelId: {
                type: String,
                required: true
            },
            resultsPerRequest: {
                type: Number,
                required: false,
                default: 1
            },          
            subscribersCountText: {
                type: String,
                required: false,
                default: 'subscribers'
            },
            subscribeBtnText: {
                type: String,
                required: false,
                default: 'Subscribe'
            },
            loadMoreBtnText: {
                type: String,
                required: false,
                default: 'Load more'
            }
        },
        data() {
            return {
                subscribersCount: null,
                channelAvatar: '',
                channelTitle: '',
                videos: [],
                totalVideosCount: null,
                nextPageToken: null,
                isSending: false
            }
        },
        methods: {
            async getChannelTitleAndAvatar() {
                this.isSending = true;

                try {
                    const response = await getChannelTitleAndAvatar(this.channelId, this.apiKey);

                    this.channelAvatar = response["items"][0].snippet.thumbnails.default.url;
                    this.channelTitle = response["items"][0].snippet.localized.title;
                }
                catch(error) {
                    console.log(error);
                }
                finally {
                    this.isSending = false;
                }
            },
            async getSubscribersCount() {
                this.isSending = true;

                try {
                    const response = await getSubscribersCount(this.channelId, this.apiKey);

                    this.subscribersCount = response["items"][0].statistics.subscriberCount;
                    this.totalVideosCount = response["items"][0].statistics.videoCount;
                }
                catch(error) {
                    console.log(error);
                }
                finally {
                    this.isSending = false;
                }
            },
            async getVideos() {
                this.isSending = true;

                try {
                    const response = await getVideos(this.channelId, this.apiKey, this.resultsPerRequest);

                    this.videos = response.items;
                    this.nextPageToken = response.nextPageToken;
                }
                catch(error) {
                    console.log(error);
                }
                finally {
                    this.isSending = false;
                }
            }
        },
        mounted() {
            this.getChannelTitleAndAvatar();
            this.getSubscribersCount();
            this.getVideos();
        }
    }

</script>

<style lang="scss">
    @import "../main.scss";
</style>

Ми імпортуємо (шляхом деструктуризації) кожну з функцій із файлу api/YouTubeWidget.js і використовуємо їх у полі methods, записуючи результат виконання кожної функції у відповідні змінні об’єкта data. Функцію loadMoreVideos ми також імпортуємо, але поки що не використовуємо.

Зверніть увагу, що у вхідних параметрах з’явилися ще два нових:

  • apiKey — обов’язковий ключ Google API, необхідний для HTTP-запитів до YouTube API
  • resultsPerRequest — кількість відеозаписів, яку ми хочемо завантажити за один запит

Зазначимо, що результат виконання методів getChannelTitleAndAvatar, getSubscribersCount та getVideos нам потрібно отримати одразу під час рендерингу компонента, тому ми викликаємо їх у хуку життєвого циклу mounted.

Переходимо до App.vue і трансформуємо його до наступного вигляду:

//App.vue

<template>
 <div id="app">
   <YouTubeWidget
           apiKey="ВАШ_API_КЛЮЧ"
           channelId="UCLA_DiR1FfKNvjuUpBHmylQ"
           :resultsPerRequest="1"
           subscribersCountText="подписчиков"
           subscribeBtnText="Подписаться"
           loadMoreBtnText="Загрузить еще"
   ></YouTubeWidget>
 </div>
</template>

<script>
 import YouTubeWidget from "./components/YouTubeWidget";

 export default {
   name: 'App',
   components: {
     YouTubeWidget
   }
 }
</script>

Зверніть увагу: вам необхідно передати дійсний Google API ключ у параметрі apiKey.

Звернувшись до YouTube API, ми динамічно отримали назву каналу, логотип, кількість підписників і завантажили одне відео:

YouTubeWidget з даними, отриманими асинхронно

Переходимо до тестування.

Перше, що нам потрібно зробити — оновити наш об’єкт із мок-даними, доповнивши його новими вхідними параметрами та даними, які імітуватимуть відповідь сервера.

//YouTubeWidget.spec.js

const mockData = {
   apiKey: 'yourapikey',
   channelId: 'UCLA_DiR1FfKNvjuUpBHmylQ',
   subscribersCountToFixed: 1,
   subscribersCountText: "подписчиков",
   subscribeBtnText: "Подписаться",
   loadMoreBtnText: "Загрузить еще",
   httpResponse: {
       data: {
           items: [
               {
                   id: {
                       videoId: '1'
                   },
                   snippet: {
                       thumbnails: {
                           default: {
                               url: 'avatar-url'
                           }
                       },
                       localized: {
                           title: 'NASA'
                       }
                   },
                   statistics: {
                       subscriberCount: 100,
                       videoCount: 100
                   }
               }
           ],
           nextPageToken: 'E7GFKSDGKLF22f'
       }
   }
};

Доповнимо об’єкт вхідних параметрів обгортки:

//YouTubeWidget.spec.js

const wrapper = mount(YouTubeWidget, {
   vueInstance,
   propsData: {
       apiKey: mockData.apiKey,
       channelId: mockData.channelId,
       subscribersCountToFixed: mockData.subscribersCountToFixed,
       subscribersCountText: mockData.subscribersCountText,
       subscribeBtnText: mockData.subscribeBtnText,
       loadMoreBtnText: mockData.loadMoreBtnText
   }
});

Далі нам необхідно задіяти спеціальну mock-функцію для бібліотеки “axios” та її методу “get”:

//YouTubeWidget.spec.js

jest.mock('axios', () => ({
   get: () => Promise.resolve(mockData.httpResponse)
}));

Ми повідомляємо локальному середовищу, що всі GET-запити мають повертати відповідь, збережену в об’єкті з мок-даними.

Фактично, коли ми виконаємо GET-запит за допомогою axios, у відповідь отримаємо цей об’єкт, дані з якого надалі буде передано у шаблон для відображення.

//YouTubeWidget.spec.js

httpResponse: {
   data: {
       items: [
           {
               id: {
                   videoId: '1'
               },
               snippet: {
                   thumbnails: {
                       default: {
                           url: 'avatar-url'
                       }
                   },
                   localized: {
                       title: 'NASA'
                   }
               },
               statistics: {
                   subscriberCount: 100,
                   videoCount: 100
               }
           }
       ],
       nextPageToken: 'E7GFKSDGKLF22f'
   }
}

Перед написанням тестів варто звернути увагу на важливе зауваження.

По-перше, не слід забувати, що методи getChannelTitleAndAvatar, getSubscribersCount та getVideos є асинхронними, отже написання тестів має містити колбеки з ключовим словом async. Крім того, важливо розуміти, що для таких операцій, як асинхронні виклики або розв’язання промісів, необхідно використовувати метод Vue.js nextTick.

//YouTubeWidget.spec.js

import { createLocalVue, mount } from '@vue/test-utils';
import YouTubeWidget from '../../src/components/YouTubeWidget.vue';

const mockData = {
   apiKey: 'ВАШ_API_КЛЮЧ',
   channelId: 'UCLA_DiR1FfKNvjuUpBHmylQ',
   resultsPerRequest: 1,
   subscribersCountToFixed: 1,
   subscribersCountText: "подписчиков",
   subscribeBtnText: "Подписаться",
   loadMoreBtnText: "Загрузить еще",
   httpResponse: {
       data: {
           items: [
               {
                   id: {
                       videoId: '1'
                   },
                   snippet: {
                       thumbnails: {
                           default: {
                               url: 'avatar-url'
                           }
                       },
                       localized: {
                           title: 'NASA'
                       }
                   },
                   statistics: {
                       subscriberCount: 100,
                       videoCount: 100
                   }
               }
           ],
           nextPageToken: 'E7GFKSDGKLF22f'
       }
   }
};

jest.mock('axios', () => ({
   get: () => Promise.resolve(mockData.httpResponse)
}));

describe('YouTubeWidget.vue', () => {
   const vueInstance = createLocalVue();

   const wrapper = mount(YouTubeWidget, {
       vueInstance,
       propsData: {
           apiKey: mockData.apiKey,
           channelId: mockData.channelId,
           resultsPerRequest: mockData.resultsPerRequest,
           subscribersCountText: mockData.subscribersCountText,
           subscribeBtnText: mockData.subscribeBtnText,
           loadMoreBtnText: mockData.loadMoreBtnText
       }
   });

   it('initialized correctly', () => {
       expect(wrapper.isVueInstance()).toBe(true);
       expect(wrapper.is(YouTubeWidget)).toBe(true);
   });

   it('renders props when passed', () => {
       expect(wrapper.props().apiKey).toBe(mockData.apiKey);
       expect(wrapper.props().channelId).toBe(mockData.channelId);
       expect(wrapper.props().resultsPerRequest).toBe(mockData.resultsPerRequest);
       expect(wrapper.props().subscribersCountText).toBe(mockData.subscribersCountText);
       expect(wrapper.props().subscribeBtnText).toBe(mockData.subscribeBtnText);
       expect(wrapper.props().loadMoreBtnText).toBe(mockData.loadMoreBtnText);

       expect(wrapper.find('.youtube-widget__subscribe-btn').attributes('href')).toEqual('http://www.youtube.com/channel/' + mockData.channelId + '?sub_confirmation=1');
       expect(wrapper.find('.youtube-widget__subscribe-btn').text()).toEqual(mockData.subscribeBtnText);
       expect(wrapper.find('.youtube-widget__load-more-btn').text()).toEqual(mockData.loadMoreBtnText);
   });

   it("fetches channel avatar src", async () => {
       wrapper.vm.$nextTick(() => {
       
         // очікуємо, що після виконання запиту до сервера, у методі data змінна “channelAvatar” буде ідентична тому, що ми передали
         expect(wrapper.vm.channelAvatar).toEqual(mockData.httpResponse.data["items"][0].snippet.thumbnails.default.url);
       
         // очікуємо, що після виконання запиту до сервера, атрибут “src” елемента з селектором “.youtube-widget__logo'” буде ідентичний тому, що ми передали                                               
         expect(wrapper.find('.youtube-widget__logo').attributes('src')).toEqual(mockData.httpResponse.data["items"][0].snippet.thumbnails.default.url);
       })
   });

   it("fetches channel title", async () => {
       wrapper.vm.$nextTick(() => {
           
           // очікуємо, що після виконання запиту до сервера, у методі data змінна “channelTitle” буде ідентична тому, що ми передали
           expect(wrapper.vm.channelTitle).toEqual(mockData.httpResponse.data["items"][0].snippet.localized.title);
           
           // очікуємо, що після виконання запиту до сервера, текст елемента з селектором “.youtube-widget__title'” буде ідентичний тому, що ми передали    
           expect(wrapper.find('.youtube-widget__title').text()).toEqual(mockData.httpResponse.data["items"][0].snippet.localized.title);
       })
   });

   it("fetches channel subscribers count", async () => {
       wrapper.vm.$nextTick(() => {
           
           // очікуємо, що після виконання запиту до сервера, у методі data змінна “subscribersCount” буде ідентична тому, що ми передали
           expect(wrapper.vm.subscribersCount).toEqual(mockData.httpResponse.data["items"][0].statistics.subscriberCount);
          
           // очікуємо, що після виконання запиту до сервера, текст елемента з селектором “.youtube-widget__subscribers-count'” буде ідентичний тому, що ми передали   
           expect(wrapper.find('.youtube-widget__subscribers-count').text()).toEqual(Number(mockData.httpResponse.data["items"][0].statistics.subscriberCount) + ' ' + mockData.subscribersCountText);
       })
   });

   it("fetches 1 channel video when loaded", async () => {
       wrapper.vm.$nextTick(() => {
           
           // очікуємо, що після виконання запиту до сервера, кількість відеозаписів у змінній “videos” методу “data” буде рівною 1
           expect(wrapper.vm.videos.length).toBe(1);
           
           // очікуємо, що після виконання запиту до сервера, масив відеозаписів у змінній “videos” буде ідентичний тому масиву, що ми передали
           expect(wrapper.vm.videos).toEqual(mockData.httpResponse.data.items);

           // очікуємо, що після виконання запиту до сервера, кількість елементів з селектором “'iframe'” буде рівною 1
           expect(wrapper.findAll('iframe').length).toBe(1);
       })
   });
});

Запускаємо тест:

Тестирование асинхронной логики и хуков жизненного цикла

Тест проходить!

Тестування користувацьких дій

У нашому випадку користувальницькою дією є клік на кнопку “Завантажити ще”, що спровокує додаткове завантаження відеозаписів.

Давайте зовсім трохи оновимо компонент: додамо подію кліка та відповідний метод, а в полі “methods” створимо відповідну логіку:

<template>
   <div class="youtube-widget">
       <div class="youtube-widget__header">
           <div class="youtube-widget__info">
               <img :src="channelAvatar" alt="" class="youtube-widget__logo">
               <div class="youtube-widget__data">
                   <h2 class="youtube-widget__title">{{ channelTitle }}</h2>
                   <div class="youtube-widget__subscribers-count">{{ subscribersCount }} {{ subscribersCountText }}</div>
               </div>
           </div>
           <a :href="'http://www.youtube.com/channel/' + channelId + '?sub_confirmation=1'" class="youtube-widget__subscribe-btn">{{ subscribeBtnText }}</a>
       </div>
       <div class="youtube-widget__inner">
           <iframe v-for="video in videos" :key="video.id.videoId" :src="'https://www.youtube.com/embed/' + video.id.videoId" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>
       </div>
       <button v-if="totalVideosCount > videos.length" class="youtube-widget__load-more-btn" @click="loadMore" :disabled="isSending">{{ loadMoreBtnText }}</button>
   </div>
</template>

<script>

   import { getChannelTitleAndAvatar, getSubscribersCount, getVideos, loadMoreVideos } from "../api/YouTubeWidget";

   export default {
       props: {
           apiKey: {
               type: String,
               required: true
           },
           channelId: {
               type: String,
               required: true
           },
           resultsPerRequest: {
               type: Number,
               required: false,
               default: 1
           },
           subscribersCountText: {
               type: String,
               required: false,
               default: 'subscribers'
           },
           subscribeBtnText: {
               type: String,
               required: false,
               default: 'Subscribe'
           },
           loadMoreBtnText: {
               type: String,
               required: false,
               default: 'Load more'
           }
       },
       data() {
           return {
               subscribersCount: null,
               channelAvatar: '',
               channelTitle: '',
               videos: [],
               totalVideosCount: null,
               nextPageToken: null,
               isSending: false
           }
       },
       methods: {
           async getChannelTitleAndAvatar() {
               this.isSending = true;

               try {
                   const response = await getChannelTitleAndAvatar(this.channelId, this.apiKey);

                   this.channelAvatar = response["items"][0].snippet.thumbnails.default.url;
                   this.channelTitle = response["items"][0].snippet.localized.title;
               }
               catch(error) {
                   console.log(error);
               }
               finally {
                   this.isSending = false;
               }
           },
           async getSubscribersCount() {
               this.isSending = true;

               try {
                   const response = await getSubscribersCount(this.channelId, this.apiKey);

                   this.subscribersCount = response["items"][0].statistics.subscriberCount;
                   this.totalVideosCount = response["items"][0].statistics.videoCount;
               }
               catch(error) {
                   console.log(error);
               }
               finally {
                   this.isSending = false;
               }
           },
           async getVideos() {
               this.isSending = true;

               try {
                   const response = await getVideos(this.channelId, this.apiKey, this.resultsPerRequest);

                   this.videos = response.items;
                   this.nextPageToken = response.nextPageToken;
               }
               catch(error) {
                   console.log(error);
               }
               finally {
                   this.isSending = false;
               }
           },
           async loadMore() {
               this.isSending = true;

               try {
                   const response = await loadMoreVideos(this.channelId, this.apiKey, this.resultsPerRequest, this.nextPageToken);

                   this.videos = [...this.videos, ...response.items];
                   this.nextPageToken = response.nextPageToken;
               }
               catch(error) {
                   console.log(error);
               }
               finally {
                   this.isSending = false;
               }
           },
       },
       mounted() {
           // Виконуємо асинхронне отримання назви каналу та аватарки після рендеру компонента
           this.getChannelTitleAndAvatar();

           // Виконуємо асинхронне отримання кількості підписників та загальної кількості відео
           this.getSubscribersCount();

           // Виконуємо асинхронне отримання відеозаписів каналу
           this.getVideos();
       }
   }

</script>

<style lang="scss">
   @import "../main";
</style>

Ми додали умовний вираз у кнопці, який говорить, що кнопка має бути відрендерована тільки в тому випадку, якщо загальна кількість відеозаписів каналу більша за кількість, яка вже була нами завантажена. Крім того, ми додали атрибут “disabled” для блокування натискання під час запиту. “Вішаємо” на кнопку подія “кліку”, при якому викликаємо метод “loadMore”, реалізований в “methods”.

Натискаємо на “Завантажити ще”:

YouTubeWidget с дополнительно загруженными видеозаписями канала

Чудово, компонент працює! Давайте тестувати.

//YouTubeWidget.spec.js

import { createLocalVue, mount } from '@vue/test-utils';
import YouTubeWidget from '../../src/components/YouTubeWidget.vue';

const mockData = {
   apiKey: 'ВАШ_API_КЛЮЧ',
   channelId: 'UCLA_DiR1FfKNvjuUpBHmylQ',
   resultsPerRequest: 1,
   subscribersCountToFixed: 1,
   subscribersCountText: "подписчиков",
   subscribeBtnText: "Подписаться",
   loadMoreBtnText: "Загрузить еще",
   httpResponse: {
       data: {
           items: [
               {
                   id: {
                       videoId: '1'
                   },
                   snippet: {
                       thumbnails: {
                           default: {
                               url: 'avatar-url'
                           }
                       },
                       localized: {
                           title: 'NASA'
                       }
                   },
                   statistics: {
                       subscriberCount: 100,
                       videoCount: 100
                   }
               }
           ],
           nextPageToken: 'E7GFKSDGKLF22f'
       }
   }
};

jest.mock('axios', () => ({
   get: () => Promise.resolve(mockData.httpResponse)
}));

describe('YouTubeWidget.vue', () => {
   const vueInstance = createLocalVue();

   const wrapper = mount(YouTubeWidget, {
       vueInstance,
       propsData: {
           apiKey: mockData.apiKey,
           channelId: mockData.channelId,
           resultsPerRequest: mockData.resultsPerRequest,
           subscribersCountText: mockData.subscribersCountText,
           subscribeBtnText: mockData.subscribeBtnText,
           loadMoreBtnText: mockData.loadMoreBtnText
       }
   });

   it('initialized correctly', () => {
       expect(wrapper.isVueInstance()).toBe(true);
       expect(wrapper.is(YouTubeWidget)).toBe(true);
   });

   it('renders props when passed', () => {
       expect(wrapper.props().apiKey).toBe(mockData.apiKey);
       expect(wrapper.props().channelId).toBe(mockData.channelId);
       expect(wrapper.props().resultsPerRequest).toBe(mockData.resultsPerRequest);
       expect(wrapper.props().subscribersCountText).toBe(mockData.subscribersCountText);
       expect(wrapper.props().subscribeBtnText).toBe(mockData.subscribeBtnText);
       expect(wrapper.props().loadMoreBtnText).toBe(mockData.loadMoreBtnText);

       expect(wrapper.find('.youtube-widget__subscribe-btn').attributes('href')).toEqual('http://www.youtube.com/channel/' + mockData.channelId + '?sub_confirmation=1');
       expect(wrapper.find('.youtube-widget__subscribe-btn').text()).toEqual(mockData.subscribeBtnText);
       expect(wrapper.find('.youtube-widget__load-more-btn').text()).toEqual(mockData.loadMoreBtnText);
   });

   it("fetches channel avatar src", async () => {
       wrapper.vm.$nextTick(() => {
           expect(wrapper.vm.channelAvatar).toEqual(mockData.httpResponse.data["items"][0].snippet.thumbnails.default.url);
           expect(wrapper.find('.youtube-widget__logo').attributes('src')).toEqual(mockData.httpResponse.data["items"][0].snippet.thumbnails.default.url);
       })
   });

   it("fetches channel title", async () => {
       wrapper.vm.$nextTick(() => {
           expect(wrapper.vm.channelTitle).toEqual(mockData.httpResponse.data["items"][0].snippet.localized.title);
           expect(wrapper.find('.youtube-widget__title').text()).toEqual(mockData.httpResponse.data["items"][0].snippet.localized.title);
       })
   });

   it("fetches channel subscribers count", async () => {
       wrapper.vm.$nextTick(() => {
           expect(wrapper.vm.subscribersCount).toEqual(mockData.httpResponse.data["items"][0].statistics.subscriberCount);
           expect(wrapper.find('.youtube-widget__subscribers-count').text()).toEqual(Number(mockData.httpResponse.data["items"][0].statistics.subscriberCount) + ' ' + mockData.subscribersCountText);
       })
   });

   it('викликає метод "loadMore" після кліку на кнопку "Завантажити ще" і отримує ще одне відео', async () => {

       // знаходимо кнопку за селектором
       const loadMoreBtn = wrapper.find(".youtube-widget__load-more-btn");

       // імітуємо клік користувача
       await loadMoreBtn.trigger("click");

       wrapper.vm.$nextTick(() => {

           // очікуємо, що після виконання запиту до сервера, кількість відеозаписів у змінній “videos” методу “data” дорівнюватиме 2
           expect(wrapper.vm.videos.length).toBe(2);

           // очікуємо, що після виконання запиту до сервера, кількість елементів з селектором “'iframe'” дорівнюватиме 2
           expect(wrapper.findAll('iframe').length).toBe(2);
       })
   });
});

Запускаємо тест:

Тестирование пользовательских действий

Тести проходять!

Тестування за допомогою знімків

Тестування з використанням знімків це дуже корисний інструмент у ситуаціях, де ви хочете бути впевнені, що ваш інтерфейс користувача не змінюється несподіваним чином. Ми не можемо виключити людський фактор, бувають випадковості, коли ми раптом виявимо, що структура нашого компонента не збігається з очікуваннями. Змінами у разі може бути як html-разметка, а й будь-які атрибути елементів: “class”, “href”, “src” та інші.

Типовий тест знімка відображає елемент інтерфейсу користувача, робить знімок екрана і потім порівнює його зі зв’язаним зображенням, що зберігається поряд з тестом. Тест провалиться якщо ці два зображення не збігаються: або зміна непередбачено, або знімок екрана потребує оновлення для збігу з новою версією елемента інтерфейсу.

Давайте розглянемо процес тестування знімками з Vue Test Utils та Jest.

Додамо та запустимо наступний тест:

//YouTubeWidget.spec.js

it('should match snapshot', () => {
   expect(wrapper.html()).toMatchSnapshot();
})

Щойно був створений знімок компонента YouTubeWidget.vue.

Зверніть увагу на папку tests/unit. У ній автоматично згенерувалася папка snapshots з файлом YouTubeWidget.spec.js.snap, який і є самим знімком.

Вміст файлу YouTubeWidget.spec.js.snap — це покроковий формат HTML-розмітки компонента YouTubeWidget.vue. Тепер при кожному запуску тестів ми будемо перевіряти, чи збігається поточна структура HTML-розмітки компонента з тією, що збережена у знімку.

Якщо структура буде відрізнятися, Vue Test Utils та Jest вкажуть на конкретне місце змін.

У разі, якщо зміни були зроблені свідомо, ми просто видаляємо старий знімок і створюємо новий, повторно запустивши тести. Після цього ідентичність структури буде контролюватися порівнянням з новим знімком.

Висновок

У цій статті ми розглянули, як легко тестувати Vue.js компоненти за допомогою бібліотеки Vue Test Utils та фреймворку Jest. Ці інструменти значно полегшують та роблять цікавішим тестування фронтенду. Вони мають набагато ширші можливості, ніж було показано тут, тому рекомендую детальніше ознайомитись із їхньою документацією за адресами vue-test-utils.vuejs.org та jestjs.io.

Сподіваюся, ця стаття була для вас корисною.