Руководство бэкенд-разработчика по тестированию компонентов на Vue.js

Мне как бэкенд-разработчику, использующему Vue, быстро стало понятно: модульное тестирование сыграет решающую роль в успехе продукта. Разработка пользовательского интерфейса казалась похожей на игру “поймай крота”, где одно исправление приводило к отмене других, более ранних.

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

Предварительные требования

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

Очень простой пример

Начнем с Vue-компонента, который показывает сообщение для пользователя.

<template>
  <div>{{ message }}</div>
</template>
<script>
export default {
   name: 'MyComponent',
   props: ['message']
}
</script>

В этом примере нужно проверить, отображается ли сообщение внутри шаблона. Тест может выглядеть примерно так.

it('should display the message', () => {
  const message = 'foo'
  cmp = mount(MyComponent, {
    propsData: {message}
  })
  expect(cmp.text()).to.equal(message)
})

Но что, если шаблон изменится на нечто подобное?

<template>
  <div style="background-color: green">
    <p>{{ message }}</p>
  </div>
</template>

Шаблон изменился, но фундаментальная концепция теста осталась прежней. Поле message должно удостовериться, что данное значение отображается в компоненте. Это действует как для предыдущей, так и для новой версий.

Но как насчет background-color: green? Разве его не нужно тестировать? В данном случае нет, потому что это значение статично для самого шаблона. Компонент формально не предписывает способ его изменения, а значит, оно не критично для достоверности самого компонента. Если убрать постороннюю информацию, останется следующее.

И этот фрагмент описывает тот же компонент, который мы сконструировали в самом начале.

Компоненты с сиблингами

Предыдущий компонент показывал пользователю сообщение. Попробуем немного его усложнить.

<template>
<div>
  <p>This is my demo!</p>
  <p>{{ message }}</p>
</div>
</template>

Теперь тест должен измениться, так как внутренний текст корневого компонента выглядит примерно так: ‘This is my demo!’+ {{ some whitespace }} + message. Как уже говорилось, статические данные не имеют отношения к поведению шаблона. Наивный способ переписать этот тест выглядит примерно так.

it('should display the message', () => {
  const message = 'foo'
  cmp = mount(MyComponent, {
    propsData: {message}
  })
  const ps = cmp.findAll('p')
  expect(ps.at(1).text()).to.equal(message)
})

Этот тест работает, но он слишком специфичен для внутренних механизмов компонента. Если обертка для сообщения когда-нибудь станет div или span или же не будет находиться в индексе с номером 1, тест придется переписать. Одно из возможных решений  —  присвоить компоненту идентификатор (например, ref или id), а затем добавить ссылку на него в тесте. Тогда шаблон будет следующим.

<template>
<div>
  <p>This is my demo!</p>
  <p ref="messageField">{{ message }}</p>
</div>
</template>

Результат будет выглядеть примерно так.

it('should display the message', () => {
  const message = 'foo'
  cmp = mount(MyComponent, {
    propsData: {message}
  })
  const field = cmp.findComponent({ref: 'messageField'})
  expect(field.text()).to.equal(message)
})

Таким образом, независимо от того, как развивается шаблон, ref для messageField всегда будет связан со значением свойства message.

Стилистические изменения компонента

Что делать, если компонент содержит свойство, которое изменяет цвет фона?

<template>
  <div :style="success?'background-color: green':''">
    <p>{{ message }}</p>
  </div>
</template>
<script>
export default {
  name: 'MyComponent',
  props: ['message', 'success']
}
</script>

Истинность свойства success определяет вывод атрибута background-color

it('should set the color if success is set', () => {
  cmp = mount(MyComponent, {
    propsData: { success: true }
  })
  expect(cmp.element.style.backgroundColor).to.equal('green')
})
it('should not set the color if success is not set', () => {
  cmp = mount(MyComponent, {
    propsData: {}
  })
  expect(cmp.element.style.backgroundColor).to.be.undefined
})

Обратите внимание: хотя компонент содержит свойство для message, оно не описано и не упоминается ни в одном из тестов. Почему? Дело в том, что message не влияет на изменение цвета компонента. Для сравнения представьте, что этот объект был бы описан не как Vue-компонент, а как класс.

class MyComponentTheClass {
  constructor(message, success) {
    this.value = message
    this.color = success?'green':''
  }
  get message() {
    return this.message
  }
  set message(v) {
    this.message = message
  }
  get success() {
    return this.color
  }
  set success(success) {
    this.color = success?'green':''
  }
}

Если вы тестируете результирующее значение, вы должны задать и вызвать .success. Установка и получение значения .message не влияют на выходное значение поля .success и должны проверяться отдельным набором тестов.

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

Шаблон может выглядеть так.

class MyComponentTheClass {
  constructor(message, success) {
    this.value = message
    this.color = success?'green':''
  }
  get message() {
    return this.message
  }
  set message(v) {
    this.message = message
  }
  get success() {
    return this.color
  }
  set success(success) {
    this.color = success?'green':''
  }
}

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

<template>
  <div :class="success:'success-me':''">
    <p>{{ message }}</p>
  </div>
</template>
<style scoped>
.success-me {
  background-color: green;
}
.success-me p {
  text-decoration: underline;
}
</style>

Тест будет выглядеть так.

it('should render success if set', () => {
  cmp = mount(MyComponent, {
    propsData: { success: true }
  })
  expect(cmp.classes()).to.include('success-me')
})
it('should not render success if not set', () => {
  cmp = mount(MyComponent, {
    propsData: {}
  })
  expect(cmp.classes()).to.not.include('success-me')
})

Если у вас возникают затруднения при работе с таблицами стилей, то вы можете воспользоваться подобным шаблоном.

<template>
  <div 
    v-if="success"
    class="success-me"
    style="background-color: green"
  >
    <p :style="text-decoration: underline">
      {{ message }}
    </p>
  </div>
  <div v-else>
    <p>{{ message }}</p>
  </div>
</template>

Тест по-прежнему работает, так как CSS-класс success-me показывает, что происходит, когда задано свойство success.

Компоненты внутри компонентов

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

<template>
  <div>
    <p>
      There are <span>{{ count }}</span> whatevers going on now.
    </p>
    <MyCoolWhatever :blah="message" @whatevs="count += 1" />
  </div>
</template>
<script>
export default {
  name: 'MyCoolParent',
  props: ['message'],
  data: () => ({ count: 0 })
}
</script>

MyCoolWhatever не имеет значения в контексте компонента MyCoolParent. Важно то, как компонент взаимодействует с этим вложенным компонентом для создания собственного шаблона.

it('should provide the message', () => {
  const message = 'foo'
  cmp = mount(MyCoolParent, {propsData: {message}})
  const whatev = cmp.findComponent({name: 'MyCoolWhatever'})
  expect(whatev.props().blah).to.equal(message)
})

it('should increment the count', async () => {
  cmp = mount(MyCoolParent, {})
  const whatev = cmp.findComponent({name: 'MyCoolWhatever'})
  const count = cmp.find('span')
  expect(count.text()).to.equal('0')
  await whatev.vm.$emit('whatevs')
  expect(count.text()).to.equal('1')
})

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

Разборка и разбиение компонентов

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

  • Опишите одним предложением, что делает компонент. Если вы не можете описать цель, не используя слово “и”, значит каждое из этих предложений потенциально можно превратить в подкомпоненты.
Этот компонент предназначен для отображения панели инструментов (1), меню навигации слева (2) и основного содержимого (3).
  • Затем описываем, как компонент выполняет свое назначение. Опять же, если вы не можете описать его поведение без слова “и”, то каждое из этих предложений может стать новым подкомпонентом.
Этот компонент отображает календарь по неделям. Он отображает календарь, показывая дни недели (1) и часы дня (2), а также запланированные события (3) и праздники (4).

Уменьшая компоненты, вы сокращаете общую площадь тестов. Это, в свою очередь, снижает риск возникновения сложных ошибок и повышает покрытие кода при меньшем количестве тестов.

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

Спасибо за чтение!

Читайте также:

Читайте нас в Telegram, VK и Яндекс.Дзен


Перевод статьи Summer Mousa: A Back-End Developer’s Guide to Vue.js Component Testing

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