В этой статье рассматривается информация о модульном тестировании только для Vue.js v2 и более ранних версий. Если вы пытаетесь использовать VueJS v3, эта заметка не будет полезной.
Зачем нужно модульное тестирование?
Когда речь идет о создании надежных приложений, тесты могут сыграть решающую роль в способности человека или команды создавать новые функции, рефакторить код, исправлять ошибки и многое другое.
Юнит-тестирование — это метод тестирования программного обеспечения, при котором набор программных компонентов или модулей тестируется по отдельности.
Преимущества:
- тестирование отдельных частей кода в полной изоляции
- картирует структуру системы и создает документацию. Это очень помогает понять интерфейс модуля.
- раннее обнаружение ошибок и более легкое выявление проблем
- экономит время благодаря автоматизации и позволяет избежать ручного тестирования
Vue Test Utils (VTU)
VTU — это набор утилит, упрощающих тестирование компонентов VueJS. Пакет предоставляет API для монтажа и взаимодействия с компонентами Vue самостоятельно.
Установка
Существуют различные варианты установки VTU. Пожалуйста, обратитесь к официальной документации VTU для получения подробной информации о том, как установить и настроить VTU.
Проекты VueJS уже имеют установленный бандлер для разработки. Поэтому я бы посоветовал при установке не устанавливать разные компиляторы или системы трансформации для тестов и исходного кода. Это только увеличит сложность проекта и зависимость пакетов. Например: если вы используете babel
для исходного кода, используйте его и для тестов.
Написание тестов
С помощью VTU мы можем писать тесты, используя describe
, it
, test
. Аналогично, хуки могут быть реализованы под before
, beforeEach
, after
и afterEach
. А для утверждений expect
также уже в комплекте. Отлично!
import {mount} from "@vue/test-utils"
// Normally a component to be tested is imported from elsewhere
const FabButton = {
template: "<button type='button' :disabled='disabled'>{{text}}</button>",
props: ["disabled", "text"]
}
describe("Fab button component", () => {
describe("when prop 'disabled' is set to 'disabled'", () => {
it("should be disabled", () => {
const wrapper = mount(FabButton, {
propsData: {
disabled: "disabled",
text: "My Button"
}
})
// assertions after loading the component
expect(wrapper.attributes('type').toBe('button'))
expect(wrapper.attributes('disabled').toBe('disabled'))
expect(wrapper.text()).toBe("My Button")
})
})
})
Знание того, что тестировать
В наших тестовых файлах может быть задействовано множество логики. Однако не все нужно тестировать во время модульного тестирования.
Не забывайте, что мы пишем тесты только для определенного компонента. Поэтому мы должны тестировать только функции, предоставляемые этим конкретным модулем.
Так нужно ли тестировать каждую функцию компонента?
Для приведенного выше компонента у нас есть два атрибута внутри элемента button, а именно type
и disabled
. Мы видим, что атрибут type
установлен в статическое значение button
, а атрибут disabled
связан с компонентом prop disabled
. Таким образом, мы можем избежать проверки статических атрибутов и проверить только вычисляемые свойства.
it("should be disabled", () => {
const wrapper = mount(FabButton, {
propsData: {
disabled: "disabled",
text: "My Button"
}
})
// assertions after loading the component
expect(wrapper.attributes('disabled').toBe('disabled'))
expect(wrapper.text()).toBe("My Button")
})
Некоторые моменты, о которых следует помнить:
- не тестируйте функциональность других компонентов, иначе ваши тесты не будут являться юнит-тестами
- не тестируйте функциональность зависимостей вашего проекта
- не тестируйте статические значения, так как они остаются статическими в любой ситуации
- не фокусируйтесь на полном покрытии на основе строк, так как оно имеет тенденцию фокусироваться на деталях внутренней реализации компонентов, что может привести к хрупким тестам
mount
и shallowMount
Я в основном использую shallowMount
для модульного тестирования, потому что он не беспокоится о других импортированных или внешних компонентах, используемых в тестируемом компоненте. Кроме того, он позволяет нам утверждать реквизиты, предоставляемые этим внешним компонентам. Но если мы хотим протестировать функциональность компонента, мы можем использовать mount
вместо него. Например, если нам нужно нажать на какой-то элемент внутри компонента, мы можем выбрать mount
вместо shallowMount
.
Заглушки
При тестировании я стараюсь как можно чаще ставить заглушки на компонент. Например, если я пишу модульный тест для компонента, который использует какой-то другой компонент, я могу заглушить эту зависимость, а затем протестировать компонент.
При работе с shallowMount
иногда я получаю реквизиты или атрибуты, заданные как [Object][Object]
. Я не могу разобрать это до объекта и дальнейшие утверждения не могут быть сделаны. Чтобы решить эту проблему, я использую заглушки более точно. Предоставьте фактический компонент для заглушек, а не просто булево значение.
// Incorrect: this may not always work
shallowMount(Component, {
stubs: {
// default stub
FabButton: true
}
})
// Correct: stub with the actual component
import { createLocalVue, shallowMount } from '@vue/test-utils'
import FabButton from "@/somelib/component/FabButton"
// if it should be used by vue
localVue.use(FabButton)
shallowMount(Component, {
localVue,
stubs: {
// specific implementation
'FabButton': FabButton
}
})
Моки
Моки очень полезны при модульном тестировании. Наши компоненты обычно используют различные методы из других компонентов, миксинов, библиотек и т.д. Они не входят в сферу нашей ответственности. Поэтому нам необходимо их моделировать.
Мокинг очень прост. Нам нужно запомнить несколько вещей:
- подражать до
монтирования
компонента - сбрасывать или восстанавливать моки после тестирования
- предпочитайте подражать возвращаемым значениям методов, а не самим методам.
В jest мокинг выполняется следующим образом:
// mocks a module with an auto-mocked version
// 'factory' and 'options' parameters are optional
jest.mock(moduleName, factory, options)
// mock internal private functions
const myMockFn = jest.fn()
.mockReturnValue(true) // default return value
.mockReturnValueOnce(1) // return value for first call
.mockReturnValueOnce(2) // return value for second call
// 'first call', 'second call', 'default', 'default'
console.log(myMockFn(), myMockFn(), myMockFn(), myMockFn());
// mock external library
jest.mock('@nextcloud/axios')
// mock external library methods
jest.mock('lodash', () => ({
...jest.requireActual('lodash'),
debounce: fn => { fn.cancel = jest.fn(); return fn }
}))
Шпионаж
Он создает имитационную функцию, подобную jest.fn
, но также записывает вызовы имитационной функции.
По умолчанию jest.spyOn
также вызывает метод spied. Но если мы хотим перезаписать исходную функцию, мы можем использовать:
jest.spyOn(object, methodName).mockImplementations(() => customImplementation)
Взаимодействие с пользователем
Они хорошо описаны в документации к vue-test-utils.
Некоторые моменты, которые я хотел бы упомянуть:
- всегда используйте
await
при выполнении пользовательских взаимодействий
await wrapper.find('button').trigger('click')
- всегда используйте
wrapper.vm
для доступа к экземпляру компонента
expect(wrapper.vm.searchResults).toEqual([])
- обязательно используйте
wrapper.vm.$nextTick
для ожидания завершения асинхронных операций, если это необходимо
await wrapper.find('button').trigger('click')
await wrapper.vm.$nextTick()
expect(wrapper.find('.content').exists()).toBeTruthy()
- не забудьте повторно запросить элементы, состояния которых изменяются после некоторых взаимодействий:
Предположим, есть компонент с кнопкой, которая переключает наличие содержимого в DOM.
// Incorrect way:
const content = wrapper.find('.content')
await wrapper.find('button').trigger('click')
expect(fab.exists()).toBeTruthy()
Здесь элемент content
запрашивается перед нажатием на кнопку. Поэтому на данном этапе содержимое отсутствует. Теперь, даже если мы нажмем на тумблер, элемент content
останется для нас неизменным. Поэтому мы должны повторно запросить этот элемент.
// Correct way:
const content = wrapper.find('.content')
expect(content.exists()).toBeFalsy()
await wrapper.find('button').trigger('click')
expect(content.exists()).toBeTruthy()
Снимки
Снимки очень полезны при написании тестов. Мы можем использовать их для проверки DOM-структуры компонента или любых других данных, таких как объекты, массивы и т.д.
Например, допустим, у нас есть такой компонент:
<template>
<div class="card">
<div class="title">{{card.title}}</div>
<div class="subtitle">{{card.subtitle}}</div>
<div class="author">{{card.author.username}}</div>
<div class="actions">
<button class="delete" :disabled="!card.canDelete()">Delete</button>
<button class="edit" :disabled="!card.canEdit()">Edit</button>
</div>
</div>
</template>
<script>
export default {
props: {
card: {
type: Object,
required: true
}
}
}
</script>
Было бы немного утомительнее искать и ожидать каждую деталь от компонента.
it('should render the card correctly', () => {
// mount the component with the card data
const title = wrapper.find('.title').text()
const subtitle = wrapper.find('.subtitle').text()
const author = wrapper.find('.author').text()
const deleteButton = wrapper.find('button.delete')
const editButton = wrapper.find('button.edit')
expect(title).toEqual('Hello World')
expect(subtitle).toEqual('This is a subtitle')
expect(author).toEqual('John Doe')
expect(deleteButton.attributes().disabled).toBeTruthy()
expect(editButton.attributes().disabled).toBeFalsy()
})
Это утомительно и трудно поддерживать. Поэтому мы можем использовать моментальные снимки для проверки всей DOM-структуры компонента.
it('should render the card correctly', () => {
// mount the component with the card data
const card = wrapper.find('.card')
expect(card).toMatchSnapshot()
})
Вот и все. Все данные карты теперь проверены, и это намного проще в обслуживании. Если что-то изменится в компоненте, нам просто нужно будет обновить снимок.
Любопытно, как сохраняются и поддерживаются снимки?
Когда мы вводим какие-либо тестовые случаи, использующие снимки, при первом запуске теста программа запуска теста создаст и сохранит снимок(и) экрана в папке
__snapshots__
там же, где находится файл теста.Теперь, когда мы снова запускаем тест, программа сравнивает сохраненный снимок с текущей структурой DOM и терпит неудачу, если они отличаются.
Для обновления текущего снимка мы можем использовать флаг
--updateSnapshot
или просто использовать интерактивный режим jest.jest --updateSnapshot
Это также полезно для assert
для больших наборов данных, например:
expect(response).toMatchObject([
{id: 1, name: 'Rose', color: {r: 255, g: 0, b: 0}},
{id: 2, name: 'Lily', color: {r: 0, g: 255, b: 0}},
{id: 3, name: 'Sunflower', color: {r: 0, g: 0, b: 255}}
])
можно записать как:
expect(response).toMatchSnapshot()
Это позволит сохранить объект ответа в виде снимка, и наш тест будет выглядеть более аккуратно. Кроме того, если что-то изменится в ответе, нам просто нужно будет обновить снимок.
Заключительные размышления
В двух словах, модульное тестирование компонентов Vue с помощью Jest и vue-test-utils — это весело. Не пытайтесь добиться 100% покрытия, лучше старайтесь тестировать реальные возможности компонента. Сообщество Vue имеет хорошую документацию и руководства по тестированию компонентов Vue. Таким образом, у вас, скорее всего, будет меньше проблем с тестированием компонентов и обеспечением безопасности.