Создание приложения на Vue.js по TDD – обширное руководство для людей, у которых есть время – часть 3
Оригинальная статья: Daniel Kuroski — Working an application in Vue.js with TDD — An extensive guide for people who have time — part 3
Это третья часть в серии статей:
- Part 1: Настройка и первый тест
- Part 2: Улучшаем UserView
- Part 3: Тестирование store и остальных компонентов слоя презентации
- Part 4: Тестирование службы запросов API
- Part 5: Добавление и тестирование со сторонними зависимостями
В прошлой статье мы создали простые тесты, связанные с компонентом UserView. Самым интересным был проведенный нами рефакторинг кода, создавший простую базу, которая будет использоваться для тестирования любого компонента в этом проекте.
Теперь давайте углубимся во все тесты компонентов и завершим их, а также интегрируем все это с хранилищем store.
Интеграция хранилища с нашими тестами
Во-первых, давайте изменим наш файл хранилища: src/store/state.js
export default { user: {}, }
Давайте создадим файл фикстуры, который будет содержать пример того, как наши реальные данные будут использоваться в наших тестах.
tests/unit/fixtures/user.js
export default { "login": "octocat", "id": 1, "node_id": "MDQ6VXNlcjE=", "avatar_url": "https://github.com/images/error/octocat_happy.gif", "gravatar_id": "", "url": "https://api.github.com/users/octocat", "html_url": "https://github.com/octocat", "followers_url": "https://api.github.com/users/octocat/followers", "following_url": "https://api.github.com/users/octocat/following{/other_user}", "gists_url": "https://api.github.com/users/octocat/gists{/gist_id}", "starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}", "subscriptions_url": "https://api.github.com/users/octocat/subscriptions", "organizations_url": "https://api.github.com/users/octocat/orgs", "repos_url": "https://api.github.com/users/octocat/repos", "events_url": "https://api.github.com/users/octocat/events{/privacy}", "received_events_url": "https://api.github.com/users/octocat/received_events", "type": "User", "site_admin": false, "name": "monalisa octocat", "company": "GitHub", "blog": "https://github.com/blog", "location": "San Francisco", "email": "octocat@github.com", "hireable": false, "bio": "There once was...", "public_repos": 2, "public_gists": 1, "followers": 20, "following": 0, "created_at": "2008-01-14T04:33:35Z", "updated_at": "2008-01-14T04:33:35Z" }
В несем следующие изменения в tests/unit/UserView.spec.js
import { shallowMount, createLocalVue } from '@vue/test-utils' import Vuex from 'vuex' import UserView from '@/views/UserView' import VUserSearchForm from '@/components/VUserSearchForm' import VUserProfile from '@/components/VUserProfile' import initialState from '@/store/state' import userFixture from './fixtures/user' const localVue = createLocalVue() localVue.use(Vuex) describe('UserView', () => { let state const build = () => { const wrapper = shallowMount(UserView, { localVue, store: new Vuex.Store({ state }) }) return { wrapper, userSearchForm: () => wrapper.find(VUserSearchForm), userProfile: () => wrapper.find(VUserProfile) } } beforeEach(() => { state = { ...initialState } }) ... ... it('passes a binded user prop to user profile component', () => { // arrange state.user = userFixture const { userProfile } = build() // assert expect(userProfile().vm.user).toBe(state.user) }) })
Давайте разберемся, что мы здесь сделали.
С помощью shallowMount мы выполняем рендеринг только нашего компонента, без каких-либо сторонних зависимостей и дополнительных настроек.
Ранее наш компонент не знал, как использовать Vuex или какую-либо другую зависимость, которую мы можем использовать в main.js.
Благодаря vue-test-utils у нас есть способ создать локальный экземпляр vue для отправки нашему компоненту. Таким образом, в нашем тесте мы можем указать все зависимости, которые он использует глобально в локальном способе запуске.
- В строке 9 мы создаем этот локальный экземпляр, делая именно то, что мы обычно делаем в main.js во время запуска Vuex.
- В строке 13 мы создаем переменную state, которую можно изменять между тестами.
- В строках 17 и 18 мы отправляем компоненту наш локальный экземпляр vue, как в новое хранилище (опять же, это именно то, что было делается в main.js)
- В строке 28 мы помещаем beforeEach, как он всегда вызывается между каждым тестом, я пользуюсь возможностью «сбросить» используемые переменные в тестах для получения стандартного значения. В этом случае мы используем исходный файл нашего состояния и сохраняем копию этого файла в нашей переменной состояния между каждым тестом.
- В строке 37, наконец, мы можем начать видеть одно из преимуществ использования функцию build. Здесь мы гарантируем, что перед запуском этого теста у нас будет переменная состояния state сброшенного к ее первоначальному значению. Затем мы можем изменить его значение с помощью нашей fixture, содержащего «реальные» данные нашего разыскиваемого пользователя, и ТОЛЬКО ТОГДА мы используем build.
Этот способ дает нам большую гибкость, потому что мы можем контролировать наши тесты и вносить изменения перед созданием основного компонента, подлежащего тестированию.
И, наконец, мы можем удалить наш wrapper из теста, поскольку в строке 41 мы хотим гарантировать, что наш компонент отправляет присутствующего пользователя в state, а не в data.
Теперь наш тест не проходит, так как в нашем UserView.vue мы не отправляем через props в VUserProfile.vue пользователя в наше хранилище. Давайте исправим это.
src/views/UserView.vue
<script> import { mapState } from 'vuex' import VUserSearchForm from '@/components/VUserSearchForm' import VUserProfile from '@/components/VUserProfile' export default { name: 'UserView', components: { VUserSearchForm, VUserProfile, }, computed: { ...mapState({ user: 'user', }) } } </script> <template> <div> <VUserSearchForm /> <VUserProfile :user="user" /> </div> </template>
Теперь мы вставляем mapState, сопоставляем пользовательское свойство нашего магазина и отправляем его в VUserProfile.
Супер! Теперь мы гарантируем, что UserView отправляет правильное свойство в VUserProfile.
Последний тест UserView
Мы почти завершили с нашим компонентом. Не хватает только одного функционала.
Я хотел бы гарантировать, что всякий раз, когда VUserSearchForm запускает событие submitted, мы запускаем store action содержащим пользователя, который был набран в нем.
Внесем изменения в tests/unit/UserView.spec.js
jest.mock('@/store/actions') import { shallowMount, createLocalVue } from '@vue/test-utils' import Vuex from 'vuex' import UserView from '@/views/UserView' import VUserSearchForm from '@/components/VUserSearchForm' import VUserProfile from '@/components/VUserProfile' import initialState from '@/store/state' import actions from '@/store/actions' import userFixture from './fixtures/user' const localVue = createLocalVue() localVue.use(Vuex) describe('UserView', () => { let state const build = () => { const wrapper = shallowMount(UserView, { localVue, store: new Vuex.Store({ state, actions, }) }) return { wrapper, userSearchForm: () => wrapper.find(VUserSearchForm), userProfile: () => wrapper.find(VUserProfile) } } beforeEach(() => { jest.resetAllMocks() state = { ...initialState } }) ... ... it('searches for a user when received "submitted"', () => { // arrange const expectedUser = 'kuroski' const { userSearchForm } = build() // act userSearchForm().vm.$emit('submitted', expectedUser) // assert expect(actions.SEARCH_USER).toHaveBeenCalled() expect(actions.SEARCH_USER.mock.calls[0][1]).toEqual({ username: expectedUser }) }) })
Давайте сначала разберем наши изменения:
- Сначала мы создали переменную, содержащую пользователя, который должен быть отправлено через отправленное событие submitted
- Сразу после этого мы вручную генерируем настраиваемое событие компонента VUserSearchForm, которое и будет происходить в будущем.
- При этом в строках 50 и 51 мы надеемся, что action хранилища с именем SEARCH_USER было вызвано (запрошено) и что объект, содержащий нашего пользователя, был отправлен в качестве payload.
- Строка 51 может показаться странной, но это форма, которую мы имеем, чтобы гарантировать, что мы отправляем правильную полезную нагрузку в store actions, потому что мы не вызываем метод из store вручную
То, что мы делаем, это вызываем store.dispatch, а Vuex внутренне вызывает наше действие для нас. И это заканчивается тем, что в первый параметр вводится объект, в который мы получаем свойства из Vuex. И как второй параметр, это наша полезная нагрузка payload.
Затем мы должны вручную принять вызов store, получив второй параметр, который является payload (наше имя пользователя).
Теперь мы можем понять, как проходила подготовка к тесту.
В строке 1 мы используем очень приятный функционал jest. В основном используя jest.mock, jest возьмет файл в том же каталоге, что и наш импортированный файл, и выполнит поиск src/store/__mocks__/actions.js вместо исходного src/store/actions.js.
Это позволяет нам создавать наши mocks, чтобы они могли использоваться всем приложением.
Это также работает для сторонних зависимостей. Мы будет использовать mock вместо всех модулей зависимостей.
Затем в строке 8 мы импортируем actions нашего store, которые на самом деле являются mock файлом, который мы создадим.
Мы вставляем action в store в строке 22.
И, наконец, в строке 34 мы сбрасываем для каждого теста все mock функции в исходное состояние, чтобы ни один тест не влиял на результат других.
Теперь наш тест снова не проходит. Во-первых, нам нужно гарантировать, что мы работаем с mock функцией.
Для этого внесите следующие изменения в src/store/__mocks__/actions.js
import userFixture from '../../../tests/unit/fixtures/user' export default { SEARCH_USER: jest.fn().mockResolvedValue(userFixture) }
Здесь мы в основном возвращаем объект с нашей функцией SEARCH_USER, возвращаясь к «стандартному» значению, что было бы solved promise для нашего fixture пользователя.
Внесите следующие изменения в src/views/UserView.vue
<script> import { mapState } from 'vuex' import VUserSearchForm from '@/components/VUserSearchForm' import VUserProfile from '@/components/VUserProfile' export default { name: 'UserView', components: { VUserSearchForm, VUserProfile, }, methods: { searchUser(username) { this.$store.dispatch('SEARCH_USER', { username }) } }, computed: { ...mapState({ user: 'user', }) } } </script> <template> <div> <VUserSearchForm @submitted="searchUser" /> <VUserProfile :user="user" /> </div> </template>
Наконец, в нашем UserView мы можем услышать события, происходящие в VUserSearchForm и при вызове этого мы можем вызвать наши action.
Эти тесты, в основном, охватывают наш компонент UserView. 😄
Для наших следующих компонентов, которые являются только презентационными, мы собираемся немного ускориться, поскольку в основном мы будем тестировать те же самые вещи.
Я собираюсь опубликовать тестовые и производственные файлы полностью, но не буду описывать каждый шаг, чтобы не удлинять эту статью. Но мы будем следовать тем же пошаговым инструкциям, что и прежде.
Тестирование VUserProfile
tests/unit/VUserProfile.spec.js
import { shallowMount } from '@vue/test-utils' import VUserProfile from '@/components/VUserProfile' import user from './fixtures/user' describe('VUserProfile', () => { let props const build = () => { const wrapper = shallowMount(VUserProfile, { propsData: props }) return { wrapper, avatar: () => wrapper.find('.user-profile__avatar'), name: () => wrapper.find('.user-profile__name'), bio: () => wrapper.find('.user-profile__bio') } } beforeEach(() => { props = { user } }) it('renders the component', () => { // arrange const { wrapper } = build() // assert expect(wrapper.html()).toMatchSnapshot() }) it('renders main components', () => { // arrange const { avatar, name, bio } = build() // assert expect(avatar().exists()).toBe(true) expect(avatar().attributes().src).toBe(props.user.avatar_url) expect(name().exists()).toBe(true) expect(name().text()).toBe(props.user.name) expect(bio().exists()).toBe(true) expect(bio().text()).toBe(props.user.bio) }) })
Здесь у нас нет ничего нового. Поскольку VUserProfile является компонентом представления.
src/components/VUserProfile.vue
<script> export default { name: 'UserProfile', props: { user: { type: Object, required: true, } } } </script> <template> <div class="user-profile"> <img class="user-profile__avatar" :src="user.avatar_url" /> <div class="user-profile__name"> {{ user.name }} </div> <div class="user-profile__bio"> {{ user.bio }} </div> </div> </template>
Здесь мы добавляем только данные рендеринга, полученные через props.
Итак, мы можем идти дальше. 😏
Тестирование VUserSearchForm
tests/unit/VUserSearchForm.spec.js
import { shallowMount } from '@vue/test-utils' import VUserSearchForm from '@/components/VUserSearchForm' describe('VUserSearchForm', () => { const build = () => { const wrapper = shallowMount(VUserSearchForm) return { wrapper, input: () => wrapper.find('input'), button: () => wrapper.find('button'), } } it('renders the component', () => { // arrange const { wrapper } = build() // assert expect(wrapper.html()).toMatchSnapshot() }) it('renders main child components', () => { // arrange const { input, button } = build() // assert expect(input().exists()).toBe(true) expect(button().exists()).toBe(true) }) it('calls "submitted" event when submitting form', () => { // arrange const expectedUser = 'kuroski' const { wrapper, button, input } = build() input().element.value = expectedUser // act input().trigger('input') button().trigger('click') button().trigger('submit') // assert expect(wrapper.emitted().submitted[0]).toEqual([expectedUser]) }) })
Опять же, тесты здесь очень похожи.
Здесь мы вставляем вручную в input нашего искомого пользователя и затем запускаем события ввода submitted, указывая, что «мы написали» в input.
И, наконец, нажимаем кнопку submit, чтобы гарантировать, что событие отрабатываем в соответствии с введенным значением.
Таким образом, вы уже изучили основы тестирования компонентов во Vue. Весьма вероятно, что он будет развиваться таким образом и не станет намного сложнее 😅.
src/components/VUserSearchForm.vue
<script> export default { name: 'UserSearchForm', data() { return { username: '' } } } </script> <template> <form @submit.prevent="$emit('submitted', username)"> <input type="text" v-model="username" /> <button type="submit">Enviar</button> </form> </template>
Для производственного кода мы только визуализируем форму, а при отправке мы отправляем отправленное событие с именем пользователя.
Тестирование action
Теперь, когда мы рассмотрели все наши тестовые компоненты, мы можем перейти ко второй части, которая будет тестировать наш store, action и mutation.
Есть два способа тестирования store, и я приведу только один из них. Если вы хотите узнать больше, взгляните на книгу Эдда Йербурга Testing Vue.js Applications.
Давайте начнем с тестирования наших action.
tests/unit/actions.spec.js
jest.mock('@/api') import flushPromises from 'flush-promises' import actions from '@/store/actions' import api from '@/api' import userFixture from './fixtures/user' describe('store actions', () => { let commit beforeEach(() => { commit = jest.fn() }) it('searches for user', async () => { // arrange const expectedUser = 'kuroski' // act await actions.SEARCH_USER({ commit }, { username: expectedUser }) await flushPromises() // assert expect(api.searchUser).toHaveBeenCalledWith(expectedUser) expect(commit).toHaveBeenCalledWith('SET_USER', userFixture) }) })
В этом случае у нас есть только одно действие. Затем мы выполняем ручной вызов метода SEARCH_USER с поддельным коммитом, отправляющим username.
В строке 20 мы используем зависимость, которую мы скачали в начале проекта. Это гарантирует, что все promises были выполнены на данный момент.
Наконец, при вызове действия мы надеемся, что наша служба API была вызвана в функции searchUser и что наш пользователь был отправлен.
Кроме того, поскольку мы тестируем только успешный путь, по которому запрос будет успешно выполнен, мы надеемся, что наш коммит был вызван с нашей fixture, где он представляет собой макет ответа API Github.
src/__mocks__/api.js
import userFixture from '../../tests/unit/fixtures/user' export default { searchUser: jest.fn().mockResolvedValue(userFixture) }
src/api.js
export default {}
Поскольку мы используем jest.mock (‘@/api.js’), нам нужно создать наш mock файл. Позже мы собираемся создать нашу службу запросов, но сейчас для наших тестов мы можем использовать наш производственный файл только как export default.
Чтобы не делать нашу статью еще длиннее, я собираюсь только проверить успешный путь. Мы не будем здесь рассматривать альтернативные варианты, например, сбой запроса, ошибка сети и т. д.
Ну … мы только что достигли красного этапа:
По сути, наш метод все еще не существует. Наконец-то мы можем реализовать наш SEARCH_USER.
src/store/actions.js
import api from '@/api' export default { SEARCH_USER({ commit }, { username }) { return new Promise(async (resolve, reject) => { try { const user = await api.searchUser(username) commit('SET_USER', user) resolve(user) } catch(error) { reject(error) } }) } }
Мы можем сделать производственный код разными способами. В этом случае я решил вернуть promise.
И здесь мы делаем все, что мы требуем в нашем тесте. Во-первых, я вызываю наш api.searchUser, отправляя имя пользователя, которое используется в качестве параметра.
Сразу после получения ответа я совершаю мутацию, отправляю нашего пользователя и возвращаю службу API.
Отлично! Теперь все тесты проходят.
Тестирование mutation
Мутация должна быть самым простым случаем, с которыми мы имели дело до сих пор.
tests/unit/mutations.spec.js
import mutations from '@/store/mutations' import initialState from '@/store/state' import user from './fixtures/user' describe('mutations', () => { let state beforeEach(() => { state = { ...initialState } }) it('sets new user', () => { // arrange const expectedUser = user // act mutations.SET_USER(state, expectedUser) // assert expect(state.user).toEqual(expectedUser) expect(state.user).not.toBe(expectedUser) }) })
Здесь все просто. Перед каждым тестом мы сбрасываем наше локальное «состояние», и то, что мы делаем, напрямую вызываем нашу мутацию, отправляя это состояние, передавая нашего пользователя.
В конце концов, мы надеемся, что в state было передано нашего пользователя, и здесь я хочу убедиться, что это чистая функция. Итак, я хочу гарантировать, что значение состояния state является копией исходного пользователя.
Теперь, когда наш тест не проходит, давайте перейдем к реализации мутации.
src/store/mutations.js
export default { SET_USER(state, user) { state.user = { ...user } } }
Выполнено! Я знаю, что в случае с Vue.js мне не нужно беспокоиться о том, чтобы сделать копию объекта, из-за того, как он работает с реактивностью, но лично я хотел бы сохранить свои мутации как чистые функции. Что бы не было бы никаких проблем, если вы вдргу сделаете что-то вроде: state.user = user.
Просто не забудьте изменить тест, если вам потребуется сделать это таким образом.
Наш тест прошел 😄
Заключение
В этой третьей статье мы сделали:
- Завершили тестирование всех компонентов
- Создали интегрированные тесты со store
Далее мы завершим наш компонент с интеграцией с хранилищем.
Следующая часть
Твиттер автора оригинальной статьи : @DKuroski