Советы по планированию и тестированию вашего компонента Vue с помощью Vitest и Vue Test Utils

В этом посте мы рассмотрим советы и практики, которые мы можем использовать для упрощения тестирования компонентов Vue с помощью таких инструментов, как Vitest.

Прежде чем мы начнем, давайте посмотрим, как быстро настроить Vitest для нашего проекта.

Настройка Витест

Vitest — это быстрая среда модульного тестирования на базе Vite и для проектов на базе Vite. Он предлагает функции и синтаксис, аналогичные Jest, с поддержкой TypeScript/JSX из коробки. Многие разработчики считают Vitest быстрее, чем Jest, особенно в режиме горячего просмотра.

Чтобы настроить Vitest в Vue, вы можете выбрать его как часть конфигурации нового проекта, сгенерированного Vite, или вручную добавить его в зависимости с помощью следующей команды:

npm install -D vitest

//OR
yarn add -D vitest

Затем в vite.config.js мы добавляем следующий объект test в конфигурацию, где мы решаем использовать jsdom в качестве нашей среды DOM:

test: {
   environment: "jsdom",
}

Мы также можем установить global: true, чтобы нам не приходилось явно импортировать каждый метод, такой как expect, describe, it или vi, из пакета vitest в каждый тестовый файл.

После завершения работы с vite.config.js нам нужно добавить новую команду scripts в файл package.json нашего проекта для запуска тестов:

"scripts": {
  "test": "vitest - root src/",
}

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

Разделите свой компонент на составные (единичный блок кода)

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

Возьмем, к примеру, следующее Movies. Этот компонент отображает список фильмов со следующими функциями:

  • Компонент получает список movies из внешнего источника.
  • Пользователи могут искать фильм по названию.
  • Необязательно: должно быть состояние загрузки при извлечении фильмов и состояние ошибки, когда во время выборки происходит что-то не так.

На приведенном ниже снимке экрана показано, как компонент должен выглядеть в пользовательском интерфейсе:

А шаблон нашего компонента будет содержать следующий код:

<template>
 <div>
  <h1>Movies</h1>
  <div>
    <div>
        <label for="search">Search:</label>
        <input type="text" id="search" v-model="searchTerm" />
    </div>
    <ul>
      <li v-for="movie in filteredMovies" :key="movie.id">
          <article>
              <h2>{{ movie.Title }}</h2>
              <img :src="movie.Poster" :alt="movie.Title" width="100" />
              <p>Released on: {{ movie.Year }}</p>
          </article>
      </li>
    </ul>
  </div>
 </div>
</template>

Вы можете подумать, что мы можем написать логику компонента следующим образом:

import { ref, onBeforeMount } from 'vue';

const movies = ref([]);

const searchTerm = ref("");
const filteredItems = computed(() => {
    return items.value.filter((item) => {
        return filters.some((filter) => {
        return item[filter]
            .toLowerCase()
            .includes(searchTerm.value.toLowerCase());
        });
    });
});

onBeforeMount(async () => {
    const response = await fetch("https://swapi.dev/api/films");

    if (!response.ok) {
    throw new Error("Failed to fetch movies");
    }

    const data = await response.json();

    movies.value = data.results;
});

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

  1. useMovies — этот composable извлечет список фильмов из внешнего API и вернет их готовыми для использования в компоненте. Он также может возвращать состояние загрузки и ошибки, когда это применимо.
  2. useSearch — Этот composable добавит в компонент функцию поиска. Он получает исходный список элементов и возвращает реактивный searchTerm, отфильтрованный список и метод для соответствующего обновления термина.

Теперь логика нашего компонента будет намного короче, как вы можете видеть ниже:

<script setup>
  import { useMovies } from "../composables/useMovies.js";
  import { useSearch } from "../composables/useSearch.js";
  
  const { items: movies } = useMovies();
  const { searchTerm, filteredItems: filteredMovies } = useSearch(movies);
</script>

И мы можем написать тесты, посвященные useSearch, useMovies, и интеграционные тесты для рендеринга компонента (когда он находится в процессе загрузки, в состоянии ошибки, вводе изменений пользователем и т. д.). Это позволяет нам разделить любую потенциальную ошибку (если она есть) на соответствующий раздел и устранить (или исправить) ее, не затрагивая другие разделы логики.

Пример тестового примера, которым может быть useSearch, показан ниже:

import { expect, describe, it } from "vitest";
import { useSearch } from "./useSearch";

describe("useSearch", () => {
  it("should return default value of searchTerm and original items as filtered items", () => {
    //...
  });

  it("should update searchTerm and return filtered items by title accordingly", () => {
    //...
  });

  it("should return filtered items by description field and searchTerm accordingly", () => {
    //...
  })
});

Модульное тестирование с помощью Vitest (или Jest) для компоновки Vue может быть простым без дополнительной настройки. Однако во многих случаях, когда компонуемый объект содержит крючки жизненного цикла, нам нужен компонент тестирования Vue, который мы рассмотрим далее.

Приложение Mount Mocked для Composable, использующее хуки жизненного цикла

Vitest или любая другая библиотека для тестирования не связывает контекст Vue и не содержит его. Автономные API-интерфейсы композиции, такие как ref(), reactive(), computed() и т. д., могут действовать как независимые API для создания реактивных переменных без участия жизненного цикла Vue. Одного только Vitest достаточно для тестирования компонуемости с использованием только этих API.

Однако, как только компонуемый компонент запускает какие-либо хуки жизненного цикла Vue ( onBeforeMount, onMounted и т. д.), нам нужно обернуть composable внутри компонента Vue, имитируя желаемый жизненный цикл для его тестирования.

Чтобы создать компонент Vue с использованием хука setup() для тестирования, мы создаем служебную функцию с именем withSetup следующим образом:

export function withSetup(hook) {
    let result;

    const app = createApp({
        setup() {
            result = hook();
            return () => {};
        },
    });

    app.mount(document.createElement("div"));

    return [result, app];
}

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

Возьмите наш компонуемый useMovies со следующей реализацией, которая использует onBeforeMount, например:

import { ref, onBeforeMount } from 'vue';

export const useMovies = () => {
  const items = ref([]);

  const fetchItems = async () => {
    try {
      const response = await fetch("https://swapi.dev/api/films");

      if (!response.ok) {
        throw new Error("Failed to fetch items");
      }

      const data = await response.json();

      items.value = data.results;

    } catch (err) {
      //do something
    } finally {
      //do something
    }
  };

  onBeforeMount(fetchItems);

  return {
    items,
  };
};

При тестировании useMovies мы можем вызвать withSetup с composable в качестве параметра, как показано ниже:

it("should fetch movies", () => {
  const [results, app] = withSetup(useMovies);

  //Assert results
});

И после того, как мы закончим наш тестовый пример, нам нужно размонтировать app:

it("should fetch movies", () => {
  //...
  app.unmount()
});

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

Тем не менее, для useMovies у нас все еще есть одна проблема. Vue запускает onBeforeMount (или любые перехватчики жизненного цикла) в синхронном порядке, а наш обратный вызов fetchItems является асинхронным. Vue продолжает процесс создания, не дожидаясь разрешения обратного вызова. А поскольку Vue не знает, когда разрешится асинхронность, использование $nextTick() не гарантирует применения новых изменений данных к результатам composable. В таких случаях нам понадобится помощь пакета @vue/test-utils, как в следующем разделе.

Flush Promises для Composable с асинхронным вызовом

vue/test-utils, или Vue Test Utils, является официальной библиотекой для тестирования компонента Vue. Он предоставляет набор служебных функций для упрощения тестирования компонентов Vue. Вы можете использовать его функции для монтирования и моделирования взаимодействия компонентов Vue. Одна из функций, которую мы будем использовать сегодня для нашего теста useMovies, — это flushPromises.

import { flushPromises } from "@vue/test-utils";

flushPromises сбрасывает все разрешенные обработчики Promise, гарантируя завершение всех асинхронных вызовов до того, как мы начнем утверждение. В нашем предыдущем тесте нам нужно изменить наш тест на async и убедиться, что мы await завершаем flushPromises, прежде чем подтверждать результат, как в следующем коде:

it("should fetch movies", async () => {
    const [results, app] = withSetup(useMovies);

    await flushPromises();

    //Assert results

    app.unmount()
});

Достаточно прямолинейно. Теперь у нас есть асинхронный тестовый пример составного объекта с хуком жизненного цикла.

Вместо этого мы должны издеваться (или шпионить).

Шпионьте и имитируйте все, что вам не нужно тестировать (или уже протестировали)

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

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

fetch() принимает путь URL к запрашиваемому ресурсу как обязательный и возвращает Promise, который разрешается в Response для этого запроса. В нашем примере кода useMovies мы будем имитировать global.fetch, встроенный метод отправки HTTP-запросов, с vi.spyOn(), где vi — экземпляр Vitest, импортированный из пакета vitest.

vi.spyOn(global, 'fetch')

vi.spyOn(), как и jest.spyOn(), принимает два обязательных аргумента: объект и строку, указывающую метод целевого объекта, за которым мы хотим следить. Он возвращает новый макет с набором методов для имитации реализации или возвращаемого значения.

Здесь мы будем имитировать возвращаемое значение fetch() для тестового примера useMovies. Мы можем выполнить это действие перед началом всех тестов (beforeAll), перед каждым тестом (beforeEach) или в самом тестовом примере. В приведенном ниже примере кода мы будем имитировать возвращаемое значение fetch() с помощью метода mockReturnValue перед началом всех тестов:

describe('useMovies', () => {
    beforeAll(() => {
        const mockFetch = vi.spyOn(global, 'fetch');

        mockFetch.mockReturnValue(createMockResolveValue({
            results: [
                {
                    title: "A New Hope",
                },
            ]
        }));
    })
})

Где createdMockResolveValue — это служебная функция, которая берет желаемые результаты и оборачивает их правильным форматом ответа (с методом json() и статусом ok) следующим образом:

function createMockResolveValue(data) {
    return { 
        json: () => new Promise((resolve) => resolve(data)), 
        ok: true 
    };
}

И с приведенным выше кодом у нас теперь есть макет fetch() с назначенным возвращаемым значением, что позволяет нашему коду работать независимо.

Мы также можем выполнять слежку за пределами области beforeAll, но в рамках набора тестов (в рамках обратного вызова describe), что позволяет нам повторно использовать фиктивный экземпляр в других тестовых случаях, если это необходимо.

describe('useMovies', () => {
    const mockFetch = vi.spyOn(global, 'fetch');
    
    it("should fetch movies", async () => {
        mockFetch.mockReturnValue(createMockResolveValue({
            results: [
                {
                    title: "A New Hope",
                },
            ]
        }));

        //...
    })
})

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

Чтобы избежать таких обстоятельств, мы вызываем метод mockClear() в конце каждого теста (afterEach), как показано ниже:

describe('useMovies', () => {
    const mockFetch = vi.spyOn(global, 'fetch');
    
    //tests

    afterEach(() => {
        mockFetch.mockClear();
    })
})

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

Использование vi.spyOn() позволяет вам имитировать метод и отслеживать любые его вызовы. Например, если мы хотим убедиться, что наш fetch() в useMovies запускается с правильным URL-адресом и только один раз, мы можем выполнить следующее:

expect(mockFetch).toHaveBeenCalledTimes(1)
expect(mockFetch).toHaveBeenCalledWith("https://swapi.dev/api/films")

Альтернативой шпионажу и насмешкам является использование global.fetch = vi.fn(). Однако я не рекомендую использовать этот подход, особенно если у вас большая кодовая база и вы не можете контролировать, какой компонент или компонуемый объект также может использовать fetch().

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

  • Один для пользовательского интерфейса Movies, когда имитированное возвращаемое значение isLoading из useMovies равно true.
  • Другой тест для useMovies, где мы утверждаем, что значение isLoading равно true перед разрешением ответа.

Это кажется достаточно простым.

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

Проверка покрытия

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

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

Для Vitest, чтобы использовать инструмент покрытия Istanbul, вам необходимо установить дополнительный пакет @vitest/coverage-istanbul с помощью следующей команды:

yarn add -D @vitest/coverage-istanbul 

//OR 
npm i -D @vitest/coverage-istanbul

Затем в своем vitest.config.js добавьте новое поле coverage в объект test следующим образом:

test: {
  globals: true,
  environment: "jsdom",
    coverage: {
    provider: 'istanbul'
  }
}

Вы также можете настроить дополнительный инструмент репортера, например, в HTML, как показано ниже:

test: {
    globals: true,
    environment: "jsdom",
    coverage: {
        provider: 'istanbul',
        reporter: ['html']
    }
}

Затем мы добавляем новую команду scripts для отчета о покрытии в package.json вашего проекта:

"scripts": {
    "coverage": "vitest run --coverage"
},

После запуска команды yarn coverage (или npm coverage), помимо отчета о выходе терминала, в каталоге вашего проекта будет создана папка coverage с выделенными html файлами отчетов для папок, как показано ниже:

Когда вы откроете coverage/composables/index.html в браузере, он покажет вам статус отчета по каждому составному тестовому покрытию в папке в гораздо более читаемом формате HTML:

Наконец, мы можем настроить пороги покрытия тестами нашего проекта (в более простом объяснении — минимальный процент покрытия кода, которого мы хотим достичь нашими тестами), назначив значения полям branches, functions или lines или statements в объекте coverage в файл vite.test.js следующим образом:

coverage: {
    branches: 80,
    functions: 80,
    lines: 80,
    statements: 10,
}

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

Отлично, правда? Вот и все. Теперь вы можете приступить к эффективному тестированию компонентов.

Полный демо-код доступен здесь.

Краткое содержание

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

И, наконец, не забывайте имитировать и имитировать ответственно свои тесты.

Что дальше? Пора идти и писать тесты 😉. Может быть, вы можете протестировать функции вашего компонента, предназначенные только для пользовательского интерфейса, такие как поддержка специальных возможностей, с помощью axe-core?

Если вы хотите иногда быть со мной в курсе, подпишитесь на меня в Твиттере | Фейсбук.

Узнайте о разработке Vue из моей новой книги Learning Vue. Ранний выпуск уже доступен!

Первоначально опубликовано на https://mayashavin.com.