Знакомство с фронтенд-тестированием. Часть вторая. Юнит-тестирование

Рассказывает Гил Тайяр, автор блога на Hackernoon


Как мы решили в первой части, юнит-тест — это код, который тестирует юниты (части) кода: функции, модули или классы. Большинство считает, что основной массой тестов должны быть юнит-тесты — но не я. На протяжении всей серии статей я буду утверждать, что важен не способ тестирования, а их количество. Тестов должно быть достаточно для того, чтобы быть уверенным в качестве предоставляемого пользователю продукта.

Юнит-тесты — это самые простые тесты для написания и самые легкие для понимания. Вся суть — подать что-то на вход юнита и проверить результат на выходе (например, на вход вы подаете параметры функции, а на выходе получаете значение).

Прим. перев. Советуем вам также прочитать нашу статью, посвящённую целям юнит-тестирования — узнаете, зачем они нужны и для чего, кроме тестирования, их можно использовать.

Кроме того, вы должны стремиться писать код таким образом, чтобы это позволяло тестировать юниты изолированно.

Юниты в приложении Calculator

Но достаточно теории, давайте взглянем на приложение Calculator, исходный код которого можно найти на GitHub. Это React-приложение, которое имеет два главных компонента: keypad и display. Это определенно юниты, поскольку они не зависят от других юнитов, но это React-юниты — как тестировать их, мы узнаем позже. Если вы только знакомитесь с React, почитайте нашу подборку советов для начинающих.

Причина, по которой я не использую JSX, заключается в том, что я не хотел углубляться в трансляцию кода. Все современные браузеры полностью совместимы с ES6, так почему бы мне не запустить код без трансляции? Да, я знаю, что мой код не запустится в IE, но это демо-код, так что все в порядке. В реальном проекте я бы так не сделал.

Но должен же какой-то код определять, что происходит при нажатии на цифру (1, 5) или оператор (+, =)? Как это принято сегодня, я разделил мои компоненты на презентационные (keypad и display) и компоненты-контейнеры — calculator-app. Это единственный компонент в этом приложении, который имеет состояние (state), и именно этот компонент определяет, что должно отображаться на экране при нажатии на кнопку калькулятора.

Модуль-калькулятор

Но тот компонент отвечает лишь за логику отображения, а что с вычислениями? Этим занимается отдельный модуль, calculator, который не имеет React-зависимостей. Этот модуль идеален для юнит-тестирования! Код идеально подходит для юнит-тестирования, если он не содержит I/O- и UI-зависимостей. Вы должны стараться избегать таких зависимостей в логике своих приложений.

Что означает I/O (ввод / вывод) в веб-приложениях? Там же нет файлов, баз данных и тому подобного? Да, этого нет, но есть AJAX-вызовы, localStorage и доступ к DOM. Я считаю, что все, касающееся API браузера — это I/O.

Как я отделил логику калькулятора от компонента React? В случае с калькулятором это довольно легко. Я выделил её в модуль calculator.

Модуль очень прост — он принимает состояние калькулятора (объект) и символ (то есть цифру или оператор) и возвращает новое состояние калькулятора. Если вы когда-то использовали Redux, то увидите, что это похоже на шаблон редьюсера Redux. Но если каждое состояние калькулятора зависит от предыдущего, как получить самое первое? Просто — модуль также экспортирует initialState, которое вы используете для инициализации калькулятора. Состояние калькулятора не является неизвестным — оно включает в себя поле с именем display, которое и нужно показать приложению калькулятора для этого состояния.

Если вы хотите увидеть код, давайте посмотрим на начало, которое является самой важной частью, поскольку детали алгоритма не так важны:

module.exports.initialState = { display: '0', initial: true }

module.exports.nextState = (calculatorState, character) => {
  if (isDigit(character)) {
    return addDigit(calculatorState, character)
  } else if (isOperator(character)) {
    return addOperator(calculatorState, character)
  } else if (isEqualSign(character)) {
    return compute(calculatorState)
  } else {
    return calculatorState
  }
}

//....

Специфика алгоритма не так важна, как простота функции, которую экспортирует модуль — получив состояние, можно всегда проверить следующее.

Именно это мы и делаем в test-calculator. Здесь полностью протестирована эта весьма нетривиальная логика.

Для тестирования создано множество фреймворков. Самым популярным в настоящее время является Mocha, и мы будем использовать именно его. Но не стесняйтесь использовать Jest, Jasmine, Tape или любой другой фреймворк, который вам нравится.

Тестируем юнит при помощи Mocha

Все тестирующие фреймворки схожи — вы пишете тестовый код в функциях, называемых тестовыми, и фреймворк их запускает. Конкретный код, который запускает их, обычно называется «runner».

«Runner» в Mocha — это скрипт под названием mocha. Если вы посмотрите на package.json в тестовом скрипте, вы увидите его там:

"scripts": {
...
    "test": "mocha 'test/**/test-*.js' && eslint test lib",
...
},

Это запустит все тесты в тестовой папке, которые начинаются с префикса test-. Если вы склонируете этот репозиторий и вызовете npm install для него, то сможете запустить npm test и протестировать юнит самостоятельно.

При запуске будет примерно следующее:

Очевидно, что если тест не будет пройден, он будет помечен красным, и вы его немедленно исправите. Давайте посмотрим на код:

const {describe, it} = require('mocha')
const {expect} = require('chai')
const calculator = require('../../lib/calculator')

describe('calculator', function () {
  const stream = (characters, calculatorState = calculator.initialState) =>
    !characters
      ? calculatorState
      : stream(characters.slice(1),
               calculator.nextState(calculatorState, characters[0]))

  it('should show initial display correctly', () => {
    expect(calculator.initialState.display).to.equal('0')
  })
  it('should replace 0 in initialState', () => {
    expect(stream('4').display).to.equal('4')
  })
//...

Первым делом мы импортируем mocha и библиотеку для проверок (assert’ов) expect. Мы импортировали функции, которые нам нужны: describe, it и except.

Потом мы импортируем модуль, который тестируем — calculator.

Затем идут тесты, которые описаны с использованием функции it, например:

it('should show initial display correctly', () => {
    expect(calculator.initialState.display).to.equal('0')
})

Эта функция принимает строку, описывающую тест, и функцию, которая является самим тестом. Но it тесты не могут быть «голыми» — они должны находиться в тестовых группах, которые определяются с помощью функции describe.

А что находится в тестовой функции? Все, что мы захотим. В данном случае мы проверяем, что исходное состояние display равно 0. Как мы это делаем? Мы действительно могли бы сделать что-то вроде этого:

if (calculator.initialState.display !== '0')
  throw 'failed'

Это сработало бы чудесно! Тест в Mocha не срабатывает, если он генерирует исключение. Это так просто. Но expect делает его намного приятнее, ведь в ней есть множество фич, облегчающих тестирование данных — например, проверка того, что массив или объект равны определенному значению.

Это и есть суть юнит-тестирования — запуск функции или набора функций (или создание экземпляра объекта и вызов некоторых его методов, если речь идёт об ООП) и сравнение фактического результата с ожидаемым.

Как писать хорошо тестируемые юниты?

Сложной частью юнит-тестирования являются не написание тестов, а максимально возможное разбиение кода. С помощью юнит-тестов может быть протестирован тот код, у которого нет I/O-зависимостей и мало зависимостей от других модулей. И это трудно, потому что мы имеем склонность компоновать логику с UI-кодом и I/O-кодом. Но это возможно, и для этого существует много способов. Например, если код проверяет поля или группу полей, нужно объединить все проверочные функции в модуль и тестировать его.

Стоп, код работает под NodeJS?!

Невероятно важный факт — юнит-тесты работают под NodeJS! Само приложение работает в браузере, а для тестирования кода, в том числе и конечного, мы используем NodeJS.

Это возможно, потому что наш код изоморфен. Это значит, что он работает и в браузере, и под NodeJS. Как же так получилось? Если вы пишете свой код, не используя I/O, это значит, что он не делает ничего специфичного для конкретного браузера, следовательно, нет причины, по которой он бы не запускался под NodeJS. Особенно если он использует require, поскольку require нативно распознается как NodeJS, так и сборщиком вроде Webpack. И если вы посмотрите на package.json, то увидите, что мы используем Webpack именно для того, чтобы связать код, использующий require:

"scripts": {
   "build": "webpack && cp public/* dist",
   ...
}

Итак, наш код использует require для импортирования React и других модулей, и благодаря магии NodeJS и Webpack мы можем использовать эту модульную систему как в NodeJS, так и в браузере — NodeJS распознает require нативно, а Webpack использует require, чтобы объединить все модули в один большой JS-файл.

Выполнение юнит-тестов в браузере

Кстати, мы могли бы использовать другой тестовый фреймворк, Karma, для запуска нашего Mocha-кода в браузере. Однако я считаю, что если юнит-тесты могут выполняться под Node, то именно так и стоит делать так. И если вы не транслируете код, то тесты выполняются очень быстро.

Но не запускать тесты в браузере нельзя, так как мы не знаем, работает ли наш код в браузере. Возможны отличия в поведении JS-кода в браузере и в NodeJS. И тут на помощь приходят E2E-тесты, о которых мы поговорим в следующей части.

Перевод статьи: «Testing Your Frontend Code: Part II (Unit Testing)»