Это руководство было написано для Vue.js 2 и Vue Test Utils v1.

Версия для Vue.js 3 здесь.

# Vue Router

Так как роутер обычно включает в себя несколько компонентов, работающих вместе, тесты для роутера занимают место в пирамиде тестирования на уровне e2e/интеграционных тестов. Тем не менее, иметь несколько модульных тестов вокруг роутинга не помешает.

Как обсуждалось в предыдущих секциях, есть два способа тестирования компонентов, которые работают с роутером:

  1. использовать настоящий экземпляр роутера
  2. замокать глобальные объекты $route и $router

Так как большинство Vue приложений использует официальную библиотеку Vue Router, в этом руководстве фокусируемся на ней.

Исходный код для тестов на этой странице можно найти здесь и здесь.

# Создание компонентов

Создадим простой <App>, у которого есть путь /nested-child. Перейдя на /nested-child, будет отрисован компонент <NestedRoute>. Сделаем файл App.vue и вставим следующий минимальный компонент:

<template>
  <div id="app">
    <router-view />
  </div>
</template>

<script>

export default {
  name: 'app'
}
</script>

<NestedRoute> также минимален:

<template>
  <div>Внутренний маршрут</div>
</template>

<script>
export default {
  name: "NestedRoute"
}
</script>

# Создание роутера и маршрутов

Теперь нам нужно несколько маршрутов для тестирования. Давайте начнём с такого маршрута:

import NestedRoute from "@/components/NestedRoute.vue"

export default [
  { path: "/nested-route", component: NestedRoute }
]

В настоящем приложении, вы обычно создаёте файл router.js и импортируете все созданные маршруты, а мы напишем так:

import Vue from "vue"
import VueRouter from "vue-router"
import routes from "./routes.js"

Vue.use(VueRouter)

export default new VueRouter({ routes })

Так как мы не хотим засорять глобальное пространство имён, вызывая Vue.use(...) в наших тестах, создадим роутер внутри теста. Это даст нам больше контроля над состоянием приложения в течение модульного тестирования.

# Написание теста

Давайте посмотрим на код, а затем обсудим, что он делает. Мы тестируем App.vue, поэтому в App.spec.js напишем следующее:

import { shallowMount, mount, createLocalVue } from "@vue/test-utils"
import App from "@/App.vue"
import VueRouter from "vue-router"
import NestedRoute from "@/components/NestedRoute.vue"
import routes from "@/routes.js"

const localVue = createLocalVue()
localVue.use(VueRouter)

describe("App", () => {
  it("отрисовывает дочерний компонент с помощью роутинга", async () => {
    const router = new VueRouter({ routes })
    const wrapper = mount(App, { 
      localVue,
      router
    })

    router.push("/nested-route")
    await wrapper.vm.$nextTick()
    
    expect(wrapper.findComponent(NestedRoute).exists()).toBe(true)
  })
})
  • Обратите внимание, что тесты помечены как await и вызываютnextTick. Смотрите здесь для получения дополнительной информации о том, почему.

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

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

Даже если вы поместите роутер в блок it, он все равно будет указывать на предыдущий путь. Вы можете попробовать это на примере ниже:

import { shallowMount, mount, createLocalVue } from "@vue/test-utils"
import App from "@/App.vue"
import VueRouter from "vue-router"
import NestedRoute from "@/components/NestedRoute.vue"
import routes from "@/routes.js"

const localVue = createLocalVue()
localVue.use(VueRouter)

describe("App", () => {
  it("отрисовывает дочерний компонент с помощью роутинга", async () => {
    const router = new VueRouter({ routes })
    const wrapper = mount(App, { 
      localVue,
      router
    })

    router.push("/nested-route")
    await wrapper.vm.$nextTick()
    
    expect(wrapper.findComponent(NestedRoute).exists()).toBe(true)
  });

  it("должен иметь другой маршрут /nested-route", async () => {
    const router = new VueRouter({ routes })
    const wrapper = mount(App, { 
      localVue,
      router
    })
    // Этот тест упадёт, потому что мы всё ещё на /nested-route
    expect(wrapper.findComponent(NestedRoute).exists()).toBe(false)
    console.log(router.currentRoute)
  })
})

Решение состоит в том, чтобы определить режим как history или abstract.

const router = new VueRouter({ routes, mode: 'abstract' });

Теперь текущий путь будет домашним.

{
  name: null,
  meta: {},
  path: '/',
  hash: '',
  query: {},
  params: {},
  fullPath: '/',
  matched: []
}

Ещё одна важная часть: мы используем mount вместо shallowMount, как было в других тестах. Если мы применим shallowMount, тогда <router-link> заменится на заглушку и вне зависимости от текущего маршрута будет отображена эта бесполезная заглушка.

# Обход отрисовки большого дерева при использовании mount

Использовать mount хорошо в некоторых случаях, но иногда — это не идеально. Например, когда вы отрисовываете весь компонент <App>, содержащий большое дерево с множеством компонентов, у которых есть дочерние компоненты и т.д. У всех компонентов сработают хуки жизненного цикла, где могут быть обращения к API и т.д.

Если вы используете Jest, его мощная система для моков позволяет элегантно решить эту проблему. Вы можете просто использовать мок для дочерних компонентов, в нашем случае для <NestedRoute>. Используем следующий мок и тесты всё ещё пройдут проверку:

jest.mock("@/components/NestedRoute.vue", () => ({
  name: "NestedRoute",
  render: h => h("div")
}))

# Используем мок для роутера

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

import { shallowMount } from "@vue/test-utils"
import NestedRoute from "@/components/NestedRoute.vue"
import routes from "@/routes.js"

describe("NestedRoute", () => {
  it("отрисовывает имя пользователя из строки запроса", () => {
    const username = "alice"
    const wrapper = shallowMount(NestedRoute)

    expect(wrapper.find(".username").text()).toBe(username)
  })
})

У нас ещё нет <div class="username">, поэтому запуск теста выдаст ошибку:

FAIL  tests/unit/NestedRoute.spec.js
  NestedRoute
    ✕ отрисовывает имя пользователя из строки запроса (25ms)

  ● NestedRoute › отрисовывает имя пользователя из строки запроса

    [vue-test-utils]: find did not return .username, cannot call text() on empty Wrapper

Обновим <NestedRoute>:

<template>
  <div>
    Nested Route
    <div class="username">
      {{ $route.params.username }}
    </div>
  </div>
</template>

Теперь тест упадёт со следующей ошибкой:

FAIL  tests/unit/NestedRoute.spec.js
  NestedRoute
    ✕ отрисовывает имя пользователя из строки запроса (17ms)

  ● NestedRoute › отрисовывает имя пользователя из строки запроса

    TypeError: Cannot read property 'params' of undefined

Это потому, что $route не существует. Мы можем использовать настоящий роутер, но в этом случае легче использовать mocks в опции монтирования:

it("отрисовывает имя пользователя из строки запроса", () => {
  const username = "alice"
  const wrapper = shallowMount(NestedRoute, {
    mocks: {
      $route: {
        params: { username }
      }
    }
  })

  expect(wrapper.find(".username").text()).toBe(username)
})

Теперь тест проходит проверку. В этом случае нам не нужно переходить куда-то или делать что-либо связанное с реализацией роутера, поэтому использование mocks оправдано. Нам не важно откуда берётся username в строке поиска, нам важно только то, что оно там есть.

Часто сервер обеспечивает маршрутизацию, в отличие от клиентской маршрутизации, с помощью Vue Router. В таких случаях использовать mocks для установки строки поиска в тестах, является хорошей альтернативой использования реального экземпляра Vue Router.

# Стратегия для тестирования роутер хуков

Vue Router предоставляет несколько типов хуков, которые называются навигационными хуками . Два таких примера:

  1. Глобальные хуки завершения перехода (router.beforeEach). Объявляются в экземпляре роутера.
  2. Хуки для конкретных компонентов, такие как beforeRouteEnter. Объявляются в компонентах.

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

# Глобальные хуки завершения перехода

Представим: у вас есть функция bustCache, которая должна вызываться на каждом маршруте, содержащим мета поле shouldBustCache. Ваши пути выглядели бы примерно так:

import NestedRoute from "@/components/NestedRoute.vue"

export default [
  {
    path: "/nested-route",
    component: NestedRoute,
    meta: {
      shouldBustCache: true
    }
  }
]

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

import Vue from "vue"
import VueRouter from "vue-router"
import routes from "./routes.js"
import { bustCache } from "./bust-cache.js"

Vue.use(VueRouter)

const router = new VueRouter({ routes })

router.beforeEach((to, from, next) => {
  if (to.matched.some(record => record.meta.shouldBustCache)) {
    bustCache()
  }
  next()
})

export default router

В нашем модульном тесте, вы можете импортировать экземпляр роутера и попытаться вызвать beforeEach, написав router.beforeHooks[0](). Это приведёт к ошибке в строке с next, так как вы не передали правильные аргументы. Вместо этого применяется стратегия, при которой нужно отделить и независимо экспортировать хук навигации beforeEach, перед его подключением к роутеру. Как насчёт такого:

export function beforeEach(to, from, next) {
  if (to.matched.some(record => record.meta.shouldBustCache)) {
    bustCache()
  }
  next()
}

router.beforeEach((to, from, next) => beforeEach(to, from, next))

export default router

Теперь писать тесты легко, хоть и немного долго:

import { beforeEach } from "@/router.js"
import mockModule from "@/bust-cache.js"

jest.mock("@/bust-cache.js", () => ({ bustCache: jest.fn() }))

describe("beforeEach", () => {
  afterEach(() => {
    mockModule.bustCache.mockClear()
  })

  it("обнуляет кэш при переходе на /user", () => {
    const to = {
      matched: [{ meta: { shouldBustCache: true } }]
    }
    const next = jest.fn()

    beforeEach(to, undefined, next)

    expect(mockModule.bustCache).toHaveBeenCalled()
    expect(next).toHaveBeenCalled()
  })

  it("обнуляет кэш при переходе на /user", () => {
    const to = {
      matched: [{ meta: { shouldBustCache: false } }]
    }
    const next = jest.fn()

    beforeEach(to, undefined, next)

    expect(mockModule.bustCache).not.toHaveBeenCalled()
    expect(next).toHaveBeenCalled()
  })
})

Главное что нас интересует - это то, что мы применяем мок для всего модуля с помощью jest.mock, и сбрасываем мок с помощью хука afterEach. Экспортируя beforeEach, как обычную, разделённую функцию JavaScript, это становится банально тестировать.

Чтобы убедиться, что хук на самом деле вызывает bustCache и отображает самые последние данные, можно использовать инструмент тестирования e2e, такой, как Cypress.io, который поставляется с приложениями, созданными с использованием vue-cli.

# Хуки для конкретных компонентов

Хуки для конкретных компонентов также легко тестировать, если рассматривать их, как обычные функции JavaScript. Допустим, мы добавили хук beforeRouteLeave к <NestedRoute>:

<script>
import { bustCache } from "@/bust-cache.js"
export default {
  name: "NestedRoute",

  beforeRouteLeave(to, from, next) {
    bustCache()
    next()
  }
}
</script>

Мы можем тестировать это так же, как и глобальные хуки:

// ...
import NestedRoute from "@/components/NestedRoute.vue"
import mockModule from "@/bust-cache.js"

jest.mock("@/bust-cache.js", () => ({ bustCache: jest.fn() }))

it("вызывает bustCache и next при переходе из маршрута", async () => {
  const wrapper = shallowMount(NestedRoute);
  const next = jest.fn()
  NestedRoute.beforeRouteLeave.call(wrapper.vm, undefined, undefined, next)
  await wrapper.vm.$nextTick()

  expect(mockModule.bustCache).toHaveBeenCalled()
  expect(next).toHaveBeenCalled()
})

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

# Заключение

В этом руководстве рассмотрели:

  • тестирование компонентов, которые отрисовываются по условию с помощью Vue Router
  • использование моков для Vue компонентов, с помощью jest.mock и localVue
  • отсоединение глобальных навигационных хуков от маршрутизатора и их независимое тестирование
  • использование jest.mock для моков модулей

Исходный код для тестов на этой странице можно найти здесь и здесь.