С появлением vue-test-utils и упрощением тестирования я искал лучший способ структурировать свои приложения Vue и как тестировать большие приложения, управляемые главным образом сложным хранилищем Vuex.

В этом посте я создам… приложение для задач. Чтобы проиллюстрировать различные возможности vue-test-utils и способы тестирования Vuex, я буду интенсивно использовать множество различных частей Vue и Vuex API. Я охватываю как можно больше кода. Jest будет нашим тестовым исполнителем.

Структура приложения

Структура вдохновлена ​​этим постом о структурировании приложений React. По сути, у каждого компонента есть папка со стилями и index.vue и index.test. Вы также можете иметь большинство вложенных компонентов.

Я сделаю следующую структуру:

/src
  /App.vue  
    /components
    /TodoContainer
      /index.vue
      /index.test.js
      /components
        /TodoItem
          /index.vue
          /index.test.js
        /NewTodoForm
          /index.vue
          /index.test.js
    /TodoFilter
      /index.vue
      /index.test.js
  /store
    /index.js
    /index.test.js

Для начала создайте новый проект и установите дополнительные функции:

vue init webpack-simple vue-test-utils-demo
cd vue-test-utils-demo
npm install vue-test-utils jest-vue-preprocessor babel-jest vuex jest

Теперь в package.json добавьте следующее, чтобы разрешить Jest работать с .vue файлами. Также добавьте скрипт "test": "jest"

"jest": {
   "moduleFileExtensions": [
     "js",
     "json",
     "vue"
    ],
     "transform": {
      ".*\\.(vue)$": "<rootDir>/node_modules/jest-vue-preprocessor",
      "^.+\\.js$": "<rootDir>/node_modules/babel-jest"
    },
    "moduleNameMapper": {
      "^@/(.*)$": "<rootDir>/src/$1"
    },
  "mapCoverage": true
}

Наконец, обновите .babelrc:

{   
  "presets": [   
    ["env", { "modules": false }]   
  ],   
  "env": {  
    "test": {  
      "presets": [ 
        ["env", { "targets": { "node": "current" }}]       
      ]     
    }   
  } 
}

Хорошо. Чтобы убедиться, что все работает, создайте App.test.jsвнутри src. Обновите App.vue, чтобы он выглядел так:

<template>
  <div id="app">
    
  </div>
</template>
<script>
export default {
  name: 'app',
}
</script>

Внутри приложения App.test.js:

import { shallow } from 'vue-test-utils'
import App from './App'
describe('App', () => {
  it('works', () => {
    const wrapper = shallow(App) 
  })
})

Запустите npm test и вы должны получить:

PASS  src/App.test.js
  App
    ✓ works (9ms)
Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        1.197s

Ладно, пора развиваться. Создайте структуру папок, описанную выше.

Тестирование TodoItem

Первый компонент, который мы будем использовать для разработки TDD, - это TodoItem.vue. TodoItem получает todo как опору и, в зависимости от статуса complete, применяет правильный класс. Мы будем издеваться над todo - нам все равно, как и откуда оно взялось, просто оно передается TodoItem.vue

import {shallow} from 'vue-test-utils'
import TodoItem from './'
describe('TodoItem', () => {
  it('renders a todo item', () => {
    const wrapper = shallow(TodoItem, {
      propsData: {
        todo: {
          text: 'Do work',
          completed: false
        }
      }
    })
    expect(wrapper.html().includes('Do work')).toBe(true)
  })
})

Это достаточно легко реализовать, давайте перейдем к делу:

<template>
  <div class="todo">
    {{ todo.text }}
  </div>
</template>
<script>
  export default {
    name: 'index',
    props: {
      todo: {
        required: true,
        type: Object
      }
    }
  }
</script>
<style scoped>
</style>

И у нас есть прохождение теста! Как насчет completed todo? Я хочу применить класс completed и назначить стиль.

TodoItem - Неудачный тест

it('assigns `completed` class to a completed todo', () => {
    const wrapper = shallow(TodoItem, {
      propsData: {
        todo: {
          text: 'Do work',
          complete: true
        }
      }
    })
    expect(wrapper.hasClass('completed')).toBe(true)
})

Чтобы это произошло, мы можем привязать class задачи и применить text-decoration: line-through.

TodoItem - Прохождение теста

<template>
  <div :class="[todo.complete ? 'completed' : 'todo']">
    {{ todo.text }}
  </div>
</template>
<script>
/* */
</script>
<style scoped>
.completed {
  text-decoration: line-through;
}
</style>

Выглядит неплохо! TodoItem не имеет никаких методов, он полагается только на реквизиты, поэтому больше нечего тестировать. Это также известно как «тупой» или «презентационный» компонент. Перейдем к TodoContainer, в котором немного больше логики.

Прежде чем писать тест, давайте обсудим, как будет работать реализация:

TodoContainer будет иметь доступ к $store. $store.state будет содержать объект todos и массив ids. Прочтите больше об этой концепции здесь, но в принципе может быть полезно настроить ваше хранилище таким образом, а не использовать один массив todo-объектов.

TodoContainer будет перебирать ids и для каждого рендерить TodoItem. Этого достаточно, чтобы начать.

TodoContainer - Неудачный тест

import Vue from 'vue'
import Vuex from 'vuex'
import {shallow} from 'vue-test-utils'
import TodoContainer from './'
Vue.use(Vuex)
describe('TodoContainer', () => {
  let store
  beforeEach(() => {   
    store = new Vuex.Store({
      state: {
        todos: {
          '0': {
            text: 'Do some work',
            completed: false
          },
          '1': {
            text: 'Take a rest',
            completed: false
          }
        },
        ids: [0, 1]
      }
    })
  })
it('renders two todo items', () => {
    const wrapper = shallow(TodoContainer, {
      store,
      stubs: {
        TodoItem: '<TodoItem />'
      }
    })
    const todos = wrapper.findAll('TodoItem')
    expect(todos.length).toBe(2)
  })
})

Здесь много чего происходит. Давайте разберемся:

  1. Мы импортируем vue, vuex и делаем Vue.use(Vuex. Теперь мы можем использовать vuex в тесте.
  2. Перед каждым тестом мы имитируем, как будет выглядеть магазин, с помощью beforeEach. Это будет выполняться перед каждым it блоком.
  3. Мы рендерим TodoContainer, используя shallow. Любые компоненты, такие как TodoItem, используемые в компоненте, который мы визуализируем с использованием shallow, могут быть заглушены с помощью объекта stubs.
  4. Наконец, мы просто находим все TodoItems. Мы ожидаем, что их два.

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

TodoContainer - Прохождение теста

<template>
  <div>
    <TodoItem
      v-for="id in $store.state.ids"
      :key="id"
      :todo="$store.state.todos[id]"
    />
  </div>
</template>
<script>
  import TodoItem from './components/TodoItem'
  export default {
    name: 'index',
    components: {
      TodoItem
    }
  }
</script>
<style scoped>
</style>

Не так много, чтобы объяснять. Просто зацикливайте ids и передайте todo опору каждому TodoItem.

Проверка фиксации мутаций

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

  1. Внутри TodoItem, есть событие click на внешнем <div>. Обработайте фиксацию там.
  2. В TodoContainer имейте событие click на фактическом <TodoItem>. Вот что я сделаю - я не хочу, чтобы TodoItem был очень умным.

Во-первых, давайте объявим мутации после сохранения внутри beforeEach и имитируем мутацию с помощью jest.fn().

describe('TodoContainer', () => {
  let store
  let mutations
  beforeEach(() => {
    mutations = {
      TOGGLE_COMPLETE: jest.fn()
    }
    store = new Vuex.Store({
      state: {
        todos: { /* */ } 
        ids: [ /* */ ]
      },
      mutations
    })
  })

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

Теперь новый блок it для нового теста.

commit (TOGGLE_COMPLETE) - Неудачный тест

it('commits a TOGGLE_COMPLETE mutation when clicked', () => {
    const wrapper = shallow(TodoContainer, {
      store,
      stubs: {
        TodoItem: '<TodoItem />'
      }
    })
    const todo = wrapper.find('TodoItem')
    todo.trigger('click')
    expect(mutations.TOGGLE_COMPLETE.mock.calls[0][1])
      .toHaveProperty('id')
    expect(mutations.TOGGLE_COMPLETE.mock.calls.length).toBe(1)
})

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

commit (TOGGLE_COMPLETE) - Тест пройден

Теперь прохождение реализации:

<template>
  <div>
    <TodoItem
      v-for="id in $store.state.ids"
      :key="id"
      :todo="$store.state.todos[id]"
      @click.native="complete(id)"
    />
  </div>
</template>
<script>
  import TodoItem from './components/TodoItem'
  export default {
    name: 'index',
    components: {
      TodoItem
    },
     methods: {
      complete (id) {
        this.$store.commit('TOGGLE_COMPLETE', { id })
      }
    }
  }
</script>

Обратите внимание, что вам нужно использовать .native, потому что мы обрабатываем четность фактического компонента, а не внутреннего HTML, отображаемого TodoItem.

Тестирование магазина

Тестировать магазин очень просто, потому что это просто старые функции JavaScript, не имеющие ничего общего с Vue или Vuex. В любом случае, давайте посмотрим, как это сделать. Перейдите на store/index.test.js.

TOGGLE_COMPLETE - Неудачный тест

import { mutations } from './'
describe('mutations', () => {
  it('completes a incomplete todo', () => {
    let state = {
      todos: {
        '0': { complete: false }
      }
    }
    mutations.TOGGLE_COMPLETE(state, { id: 0 })
    expect(state.todos[0].complete).toBe(true)
  })
})

Нам просто нужно вызвать мутацию и подтвердить, что свойство complete изменено.

TOGGLE_COMPLETE - Прохождение теста

Давайте быстро настроим магазин и пройдем этот тест.

import Vue from 'vue'
import Vuex from 'vuex'
Vue.use(Vuex)
const state = {
  todos: {},
  ids: []
}
export const mutations = {
  TOGGLE_COMPLETE (state, { id }) {
    state.todos[id].complete = ! state.todos[id].complete
  }
}
export default new Vuex.Store({
  state,
  mutations
})

Легкий. Обратите внимание, что нам нужно export const mutations, чтобы использовать их в тесте. Здесь нет ничего особенного. Обратите внимание, что нам НЕ нужно импортировать или создавать хранилище - нам не нужно проверять, работает ли функция Vuex commit или нет, мы знаем, что она работает, потому что у нее есть собственные внутренние тесты. Помните, тестируйте свой код, а не структуру.

Теперь нам просто нужен способ добавить задачи.

Тестирование NewTodoForm

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

NewTodoForm - Неудачный тест

import {shallow} from 'vue-test-utils'
import NewTodoForm from './'
describe('NewTodoForm', () => {
  it('calls createTodo when enter is pressed', () => {
    const createTodo = jest.fn()
    const wrapper = shallow(NewTodoForm)
    wrapper.setMethods({ createTodo })
    const input = wrapper.find('input')
    input.trigger('keydown.enter')
    expect(createTodo.mock.calls.length).toBe(1)
  })
})

Вместо того, чтобы издеваться над мутацией, я хочу показать, как имитировать методы экземпляра Vue, используя setMethods. Фактическая реализация, как вы увидите, вызовет createTodo, который, в свою очередь, совершит мутацию - хотя мы уже видели, как это проверить выше, поэтому я хотел показать некоторые другие возможности vue-test-utils.

NewTodoForm - Прохождение теста

<template>
  <div>
    <input v-model="newTodoText" @keydown.enter="createTodo"/>
  </div>
</template>
<script>
  export default {
    name: 'index',
    data () {
      return {
        newTodoText: ''
      }
    },
    methods: {
      createTodo () {
        this.$store.commit('CREATE_TODO', { 
          text: this.newTodoText     
        })
        this.newTodoText = ''
      }
    }
  }
</script>

Еще один довольно простой компонент. Объяснять особо нечего - мы можем использовать keydown.enter, точно так же, как мы написали в тесте, для вызова метода при нажатии Enter.

CREATE_TODO - Неудачный тест

Давайте посмотрим, как протестировать CREATE_TODO - опять же, простой JavaScript, а не ничего кричащего.

it('creates a todo by calling CREATE_TODO', () => {
    let state = {
      todos: {
      },
      ids: []
    }
    mutations.CREATE_TODO(state, { text: 'New todo' })
    expect(state.todos[1]).toHaveProperty('text')
    expect(state.todos[1].text).toEqual('New todo')
    expect(state.ids.length).toBe(1)
})

Просто подтвердите, что задача была добавлена ​​с текстом. Реализация одинаково проста:

CREATE_TODO (state, { text }) {
   let id = state.ids.length + 1
   state.todos = Object.assign({},
     state.todos, {
       [id]: { text, complete: false }
     }
   )
   state.ids.push(id)
}

Не самый надежный способ выбора идентификатора, но он подходит для примера. Вы можете использовать оператор распространения ... вместо Object.assign, чтобы добавить новое задание, но шаблон webpack-simple не включает его, поэтому я использовал Object.assign.

При этом все работает - на самом деле мы не настроили приложение, не импортировали магазин и не отрендерили что-либо, но это достаточно просто сделать.

Давайте проверим охват с помощью флага jest --coverage.

Все на 100%, кроме NewTodoForm - функция data() не тестировалась. В любом случае все, что мы делаем, - это привязываем с использованием v-model, так что все в порядке. Нам не нужно проверять, v-model работает - проверьте свой код, у фреймворка есть собственные тесты, поэтому мы можем быть уверены, что v-model действительно работает.

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