Болеутоляющее или статья о том, что можно писать тестируемый JavaScript
У всех наступал момент, когда ваше JavaScript-приложение, начавшееся с нескольких полезных строчек разрасталось на тысячу строк, затем на две, дальше — больше. Постепенно функция начинает принимать чуть больше параметров; ветки условий получают ещё немного условий. И в один прекрасный день появляется баг: что-то сломано. И нам предстоит распутать весь этот бардак в коде.
Сейчас код на фронтенде берёт на себя всё больше и больше ответственности — на самом деле, уже существует целый пласт приложений, существующих полностью на клиентской стороне — в связи со всем этим становятся очевидными две идеи. Первая — мы просто не можем прокликать все возможные варианты с помощью текущего метода тестирования. Вторая — возможно, нам потребуется изменить подход к тому, как мы привыкли писать код, в угоду возможности писать тесты.
Правда ли, что нам необходимо поменять то, как мы пишем код? Абсолютное да — так как мы осознаём пользу автоматического тестирования, но большинство из нас, возможно, сумеют прямо сейчас написать только интеграционные тесты. Интеграционные тесты важны тем, что отслеживают, насколько хорошо работают между собой отдельные части приложения, но в то же время они не несут никакой информации о том, работают ли отдельные части так, как от них ожидают.
В этот момент на сцену действия выходят модульные тесты (прим. переводчика: также известны как юнит-тесты и функциональные тесты). Нам придётся серьёзно потрудиться над написанием модульных тестов, пока мы не начнём писать тестируемый джаваскрипт.
Модульные тесты и интеграционные: в чём разница?
Обычно способ создания интеграционных тестов достаточно прямолинеен: мы просто пишем код, описывающий, как пользователь взаимодействует с нашим приложением, и то, что пользователь ожидает увидеть. Популярным средством автоматического тестирования в браузере является Selenium. Capybara для Ruby облегчает взаимодействие с Selenium, более того, существуют тысячи подобных инструментов на других языках.
Ниже представлен интеграционный тест для небольшой части поискового приложения:
def test_search fill_in('q', :with => 'cat') find('.btn').click assert( find('#results li').has_content?('cat'), 'Результаты поиска отображены' ) assert( page.has_no_selector?('#results li.no-results'), 'Результаты поиска отсутствуют' ) end
В то время как интеграционный тест заинтересован в проверке взаимодействия пользователя с приложением, внимание модульного теста лежит на небольших участках кода.
Если я вызову функцию с зафиксированными параметрами, то получу ли я ожидаемый результат?
Приложения, написанные в традиционном процедурном стиле, чаще всего трудно модульно тестировать — так же, как трудно поддерживать, отлаживать и расширять. Но если мы будем писать код, держа в уме необходимость модульного тестирования, то мы обнаружим не только то, что тестировать становится проще, но также то, что мы просто пишем аккуратный и более качественный код.
В качестве иллюстрации к тому, о чём я говорю, давайте взглянем на обычное поисковое приложение:
Когда пользователь начинает что-то искать, приложение отправляет XHR-запрос на сервер. Когда сервер отвечает данными в формате JSON, приложение принимает эти данные и отображает их на странице при помощи клиентской шаблонизации. Пользователь может кликнуть на элементе поисковой выдачи, чтобы показать, что ему понравился этот пункт; когда это происходит, имя человека, к которому пользователь проявил интерес, добавляется в список «Понравившиеся» в правой колонке приложения.
«Обычное» JavaScript приложение может выглядеть так:
Мой друг Адама Сонтега (Adam Sontag) называет это «Выбери себе приключение сам» — в каждой строчке мы с равной вероятностью можем иметь дело как с представлением, так и с информацией, обслуживанием логики пользовательского взаимодействия или проверкой состояния приложения. Остаётся только догадываться! Достаточно просто написать интеграционные тесты для этого кода, и в тоже время очень сложно написать тесты для тестирования отдельных функциональных частей приложения.
Почему это сложно? На это существуют четыре причины:
- общий недостаток структурированности; чаще всего всё происходит в коллбэке $(document).ready() — и, так как это анонимная функция, её невозможно протестировать ввиду невозможного к ней обращения;
- слишком сложные функции; Если в функции больше 10 строк кода, как в обработчике отправки формы, то эта функция, вероятно, выполняет и несёт отвественность за слишком много вещей;
- скрытые состояния; так как состояние pending помещено в замыкание, то нет никакой возможности проверить, правильно ли установлено это состояние;
- избыточная связанность функционала; к примеру, обработчику $.ajax совершенно нет необходимости иметь доступ к DOM;
Организация кода
Первое, что небходимо сделать — выбрать менее запутанный метод организации кода, разбить его на несколько зон ответственности:
- представление и пользовательское взаимодействие;
- управление данными;
- общее состояние приложения;
- настройка и код-прослойка, чтобы все части работали вместе;
В «традиционной» реализации, показанной выше, эти четыре категории перемешаны — на одной строчке мы работаем с представлением, двумя строчками ниже мы общаемся с сервером.

Несмотря на то, что мы можем без проблем писать интеграционные тесты для этого кода (и мы обязаны это делать!), писать модульные тесты действительно сложно. В наших функциональных тестах мы можем утверждать: «когда пользователь ищет что-то, он должен видеть соответствующие результаты», но мы не можем быть точнее. Если что-то пойдёт не так, нам следует определить, что именно пошло не так, и наши функциональные тесты не смогут помочь в этом.
Если мы переосмыслим то, как мы пишем код, то мы можем не только написать юнит- тесты, которые дадут нам лучшее представление о том, откуда все пошло не по плану, но и, в конечном итоге, писать более удобный код — поддерживаемый, расширяемый.
Каждая новая строчка кода будет следовать этому небольшому списку правил:
- каждый отдельный фрагмент кода должен быть отдельным объектом, попадающим в одну из четырёх зон ответственности, и не должен иметь ни малейшего представления о других объектах. Это поможет избегать запутанного кода;
- поддерживайте возможность настройки вместо того, чтобы задавать конкретные значения определённым параметрам. Это предотвратит необходимость повторения всего HTML-окружения, чтобы написать тесты;
- каждый метод любого объекта должен быть простым и коротким. Этот пункт позволит иметь простые и читаемые тесты;
- для создания объектов следует использовать конструкторы. Это поможет сделать возможным создание «чистых» копий объектов, необходимых для тестирования.
Для начала надо определиться, на какие части мы разобьём наше приложение. У нас есть три части, относящиеся к представлению и взаимодействию: поисковая форма, поисковые результаты и сектор для понравившегося.

Также существует кусок кода, относящийся к запросу данных с сервера, и часть, обеспечивающая возможность совместной работы остальных частей.
Начнём с наиболее простой части приложения — сектор для понравившегося. В оригинальном приложении следующий код отвечал за обновление этого сектора:
Код поисковой формы сильно переплетен с сектором понравившегося и также требует информации о том, как устроена разметка. Гораздо лучшим подходом (и для тестируемости тоже) будет создание объекта сектора понравившегося, ответственного за манипуляции с DOM:
В этом коде приведён конструктор, создающий новую копию объекта Likes Box. Созданная копия имеет метод .add() , используемый для добавления новых результатов. Мы можем написать немного тестов, чтобы проверить, работает ли этот метод:
var ul; setup(function( )< ul = $(' '); >); test('constructor', function ( ) < var l = new Likes(ul); assert(l); >); test('adding a name', function ( ) < var l = new Likes(ul); l.add('Дмитрий Менделеев'); assert.equal(ul.find('li').length, 1); assert.equal(ul.find('li').first().html(), 'Дмитрий Менделеев'); assert.equal(ul.find('li.no-results').length, 0); >);
Не так сложно, правда? Здесь используется Mocha в качестве тестирующего фреймворка и Chai как дополнительная библиотека. Mocha обеспечивает функции test и setup ; Chai — функцию assert . Существует бездна других фреймворков для тестирования, но я нахожу эти два достаточными для введения в предметную область. Вам же следует найти свой фреймворк по своим предпочтениям — Qunit популярен, а новый Intern подаёт большие надежды.
Приведённый код начинается с создания элемента, который будет использован как контейнер для сектора понравившегося. Затем запускается два теста: первая проверка на вменяемость — можем ли мы создать Like Box; вторая, чтобы удостовериться, что метод .add() имеет желаемый эффект. При наличии этих тестов, у нас появляется возможность безопасно рефакторить и быть уверенными, что мы сразу узнаем баге.
Код нашего приложения теперь выглядит так:
var liked = new Likes('#liked'); var resultsList = $('#results'); // … resultsList.on('click', '.like', function (e) < e.preventDefault(); var name = $(this).closest('li').find('h2').text(); liked.add(name); >);
Код, отвечающий за поисковые результаты, сложнее, чем Like Box, но давайте попробуем свои силы в рефакторинге. Точно так же, как мы создали метод .add() у Likes Box, мы хотим создать методы для общения с поисковыми результатами. Мы хотим добавлять новые результаты и удобные способы оповещения других частей приложения о событиях внутри поисковых результатов — например, когда кому-то понравился пункт поисковой выдачи.
var SearchResults = function (el) < this.el = $(el); this.el.on( 'click', '.btn.like', _.bind(this._handleClick, this) ); >; SearchResults.prototype.setResults = function (results) < var templateRequest = $.get('people-detailed.tmpl'); templateRequest.then( _.bind(this._populate, this, results) ); >; SearchResults.prototype._handleClick = function (evt) < var name = $(evt.target).closest('li.result').attr('data-name'); $(document).trigger('like', [ name ]); >; SearchResults.prototype._populate = function (results, tmpl) < var html = _.template(tmpl, < people: results >); this.el.html(html); >;
Теперь код нашего старого приложения, отвечающий за взаимодействие между поисковыми результатами и Likes Box, выглядит так:
var liked = new Likes('#liked'); var resultsList = new SearchResults('#results'); // … $(document).on('like', function (evt, name) < liked.add(name); >)
Такой код намного более простой и менее запутанный, потому что мы используем document как глобальный транспорт для сообщений, и, передавая данные через него, мы избавляем отдельные части приложения от необходимости знать друг о друге. (В реальной жизни мы использовали бы backbone или RSVP для управления событиями. В текущем демонстрационном приложении мы запускаем события в document для упрощения кода). Мы также спрячем всю рутинную работу — поиск имени понравившегося человека из поисковой выдачи — внутри объекта поисковых результатов, чтобы не загрязнять им код приложения. Наконец, хорошие новости — теперь мы можем писать тесты, чтобы доказать, что работа поисковых результатов соотствует нашим ожиданиям:
var ul; var data = [ /* ненастоящие данные */ ]; setup(function ( ) < ul = $(' '); >); test('constructor', function ( ) < var sr = new SearchResults(ul); assert(sr); >); test('display received results', function ( ) < var sr = new SearchResults(ul); sr.setResults(data); assert.equal(ul.find('.no-results').length, 0); assert.equal(ul.find('li.result').length, data.length); assert.equal( ul.find('li.result').first().attr('data-name'), data[0].name ); >); test('announce likes', function( ) < var sr = new SearchResults(ul); var flag; var spy = function ( ) < flag = [].slice.call(arguments); >; sr.setResults(data); $(document).on('like', spy); ul.find('li').first().find('.like.btn').click(); assert(flag, 'event handler called'); assert.equal(flag[1], data[0].name, 'обработчик события получил данные' ); >);
Взаимодействие с сервером — другая часть для обсуждения. Оригинальный код содержит в себе прямой вызов $.ajax() , и обработчик этого вызова работает напрямую с DOM:
$.ajax('/data/search.json', < data : < q: query >, dataType : 'json', success : function( data ) < loadTemplate('people-detailed.tmpl').then(function(t) < var tmpl = _.template( t ); resultsList.html( tmpl(< people : data.results >) ); pending = false; >); > >);
Повторюсь, что очень трудно писать модульные тесты для такого кода из-за того, что слишком много вещей происходит всего в нескольких строчках кода. Мы можем переделать объект с информацией в самостоятельный объект:
var SearchData = function ( ) < >; SearchData.prototype.fetch = function (query) < var dfd; if (!query) < dfd = $.Deferred(); dfd.resolve([]); return dfd.promise(); > return $.ajax( '/data/search.json', < data : < q: query >, dataType : 'json' >).pipe(function( resp ) < return resp.results; >); >;
Сейчас мы можем изменить код, чтобы получить результаты на странице:
var resultsList = new SearchResults('#results'); var searchData = new SearchData(); // … searchData.fetch(query).then(resultsList.setResults);
В который раз замечу, что мы невообразимо упростили код нашего приложениня, и спрятали всю сложность кода в объект Search Data вместо того, чтобы хранить его в общем коде. Также мы сделали наш поисковой интерфейс тестируемым, в тоже время надо помнить о некоторых оссобенностях при тестировании кода, взаимодействующего с сервером.
Первое — это то, что нам не нужно взаимодействовать с сервером по-настоящему — это проникновение в мир интеграционных тестов, а так как мы ответственные разработчики, то у нас уже есть тесты, сообщающие, что сервер работает правильно; всё верно? Вместо этого мы хотим создать заглушку для серверного взаимодействия, и это мы можем сделать с помощью библиотеки Sinion. Второй важный момент — нам также необходимо тестировать неидеальные случаи, например, пустой запрос.
test('constructor', function ( ) < var sd = new SearchData(); assert(sd); >); suite('fetch', function ( ) < var xhr, requests; setup(function ( ) < requests = []; xhr = sinon.useFakeXMLHttpRequest(); xhr.onCreate = function (req) < requests.push(req); >; >); teardown(function ( ) < xhr.restore(); >); test('fetches from correct URL', function ( ) < var sd = new SearchData(); sd.fetch('cat'); assert.equal(requests[0].url, '/data/search.json?q=cat'); >); test('вернуть promise', function ( ) < var sd = new SearchData(); var req = sd.fetch('cat'); assert.isFunction(req.then); >); test('нет ответа, если нет запроса', function ( ) < var sd = new SearchData(); var req = sd.fetch(); assert.equal(requests.length, 0); >); test('вернуть promise, даже если нет запроса', function ( ) < var sd = new SearchData(); var req = sd.fetch(); assert.isFunction( req.then ); >); test('no query promise resolves with empty array', function ( ) < var sd = new SearchData(); var req = sd.fetch(); var spy = sinon.spy(); req.then(spy); assert.deepEqual(spy.args[0][0], []); >); test('returns contents of results property of the response', function ( ) < var sd = new SearchData(); var req = sd.fetch('cat'); var spy = sinon.spy(); requests[0].respond( 200, < 'Content-type': 'text/json' >, JSON.stringify(< results: [ 1, 2, 3 ] >) ); req.then(spy); assert.deepEqual(spy.args[0][0], [ 1, 2, 3 ]); >); >);
Оставив это за пределами статьи, я провела рефакторинг объекта Search Form и упростила несколько участков кода и тестов, но если вам интересно, вы можете взглянуть на законченную версию приложения в моём репозитории на гитхабе.
По окончании переписывания приложения с использованием шаблонов тестируемого JavaScript, мы пришли к более ясному, чистому и поддерживаемому, в отличие от стартового, коду:
$(function( ) < var pending = false; var searchForm = new SearchForm('#searchForm'); var searchResults = new SearchResults('#results'); var likes = new Likes('#liked'); var searchData = new SearchData(); $(document).on('search', function (event, query) < if (pending) < return; > pending = true; searchData.fetch(query).then(function (results) < searchResults.setResults(results); pending = false; >); searchResults.pending(); >); $(document).on('like', function (evt, name) < likes.add(name); >); >);
Важнее того, что мы получили более аккуратный код, может быть только то, что он превосходно покрыт модульными тестами. Это означает, что мы можем смело рефакторить приложение без страха поломки чего-либо. Мы даже можем написать дополнительные тесты, если появится такая необходимость, и потом мы напишем код, который с успехом пройдёт все тесты.
Тестирование повышает качество жизни в долгосрочной перспективе
Несложно посмотреть на всё, что тут написано и спросить: «Подождите, вы хотите, чтобы я писал больше кода, который бы делал ту же самую работу?»
Причина в некоторых непререкаемых фактах относительно создания вещей в интернете. Вы потратите время, разрабатывая решение проблемы. Вы проверите своё решение, прокликав интерфейс в браузере и написав автоматические модульные тесты, или вероломно позволите пользователю тестировать ваше приложение в продакшене. И тем не менее, сколько бы вы не написали тестов, баги будут всегда.
Понимание тестирования заключается в том, что оно, возможно, и занимает чуть больше времени на старте, но в итоге оно экономит вам время в будущем. Вы будете прыгать от радости, когда тест, написанный вами, впервые обнаружит баг до того, как он попадёт в продакшн. Также вы будете счастливы, когда система тестов сможет подтвердить, что ваши правки действительно фиксят баг, для которого они предназначались.
Дополнительные источники
Эта статья только поверхностно описывает JavaScript-тестирование, но если вы заинтересовались и хотите изучить тему глубже, то обязательно проверьте следующие ссылки:
- Моя презентация с конференции Full Frontall (Брайтон, Великобритания, 2012);
- Grunt — инструмент, который поможет автоматизировать процесс тестирования;
- Книга Test-Driven JavaScript Development Кристиана Джохансона, создателя библиотеки Sinion. Это краткая, но очень информативная проверка по основным постулатам тестируемого JavaScript.
Как и зачем писать тесты
Как тесты помогают писать чистый новый код и увереннее редактировать старый.
Время чтения: 13 мин
Открыть/закрыть навигацию по статье
- Кратко
- Что такое тест
- Примитивное тестирование
- Инструменты для тестирования во фронтенде
- Подготовка (Arrange)
- Выполнение (Act)
- Проверка (Assert)
- Идеальный тест
- Заставляют думать над крайними случаями
- Уменьшают количество регрессий
- Дают больше уверенности при рефакторинге
- Решают проблемы при обновлении зависимостей
- Автоматическая документация
- Нужно больше времени на начальных этапах
- Нужно продумать структуру для тестов и тестовых данных
- Нужно настроить CI
- Unit тесты
- Интеграционные тесты
- E2E тесты
- Приёмочные тесты
Обновлено 21 декабря 2021
Кратко
«Тесты — это лишняя работа», «тесты писать необязательно» — такие мнения часто можно услышать в разговорах о тестировании. В этой статье мы постараемся развеять этот миф и рассмотрим плюсы тестирования и минусы его отсутствия.
Тесты делают код более прочным и живучим. Одновременно с этим тесты — это отличная документация, которая не врёт и не устаревает. Также тесты можно использовать как инструмент разработки программы.
Для тестов нужно закладывать больше времени на разработку, это правда. Но время, потраченное в начале работы над проектом, окупится в дальнейшем.
Что такое тест
Тест — это код, который проверяет предположения о работе другого кода.
Представим, что у нас есть функция add ( ) , которая складывает одно число с другим:
function add(a, b) return a + b>function add(a, b) return a + b >Мы предполагаем, что функция прибавляет аргумент b к аргументу a и возвращает нам результат. Мы можем проверить это, вызвав её:
const result = add(10, 5)// result === 15const result = add(10, 5) // result === 15Но что будет, если мы передадим не два числа, а одно? А если передадим не числа? Или функция за время жизни проекта изменится? Чтобы проверить такие предположения, мы пишем тесты.
Примитивное тестирование
Самый простой тест, который мы можем написать — ручной. Сравним руками результат работы функции и ожидаемое значение:
function testAdd() const result = add(10, 5) const expected = 15 console.assert( result === expected, `The result $ doesn't match the expected value $.` )>function testAdd() const result = add(10, 5) const expected = 15 console.assert( result === expected, `The result $result> doesn't match the expected value $expected>.` ) >При запуске функции test Add ( ) она проверит, что вернёт функция add ( ) . Если результат не будет соответствовать ожиданию, консоль покажет ошибку.
Конечно, такой тест никуда не годится
- Ему сильно не хватает описания – как понять, что именно мы проверяем?
- Не хватает выразительности и лаконичности — сравнивать значения и выбрасывать ошибки руками не круто.
- Не хватает интерактивности — чтобы перезапустить тест, нужно запустить функцию заново.
В идеале хотелось бы, чтобы при изменении теста он перезапускался сам. Чтобы было место для описания того, что мы тестируем. Специально для этого придумали инструменты для тестирования.
Инструменты для тестирования во фронтенде
Инструментов для тестирования много. Чтобы подобрать подходящий, нам надо определиться, какие тесты мы хотим писать. Подробнее о видах тестирования мы поговорим в конце статьи, а пока что посмотрим на самый часто используемый инструмент — Jest.
Чтобы использовать Jest, его нужно установить в свой проект через npm . Подробнее об установке можно узнать на сайте с документацией Jest.
Попробуем, используя его, переписать тест нашей функции add ( ) :
describe('When given 2 numbers', () => it('returns the sum of those 2 numbers', () => const result = add(10, 5) const expected = 15 expect(result).toEqual(expected) >)>)describe('When given 2 numbers', () => it('returns the sum of those 2 numbers', () => const result = add(10, 5) const expected = 15 expect(result).toEqual(expected) >) >)Разберём по строкам:
- На первой строке мы указываем описание теста — в каких условиях мы собираемся тестировать функцию.
- На второй строке указываем само предположение о результате — что функция должна нам вернуть.
- На строчках 3–6 выполняем сам тест.
Функция expect ( ) помогает избежать работы с ошибками напрямую и предоставляет удобные методы для сравнения аргументов друг с другом.
Обвязка из describe ( ) и it ( ) помогает нам описать тест в виде самого настоящего текстового предположения, которое код теста проверит.
Теперь разберём, собственно, код теста.
Анатомия теста
Наш тест состоит из 3 строк:
const result = add(10, 5)const expected = 15expect(result).toEqual(expected)const result = add(10, 5) const expected = 15 expect(result).toEqual(expected)Его можно разделить на 3 стадии, которые можно запомнить по мнемоникам:
- ПВП: Подготовка, Выполнение, Проверка;
- или по-английски AAA: Arrange, Act и Assert.
Подготовка (Arrange)
На стадии подготовки мы готовим исходные данные для функции и ожидаемый результат. В более сложных тестах на этой стадии мы бы готовили зависимости для функции и фиктивные объекты.
В нашем случае подготовкой можно назвать выбор аргументов 10 и 5 , а также обозначение ожидаемого значения const expected = 15 .
Выполнение (Act)
На второй стадии мы запускаем функцию, чтобы получить результат. В этот момент мы отрабатываем тестируемый сценарий и получаем значение, которое потом будем проверять.
В нашем случае это строчка с вызовом функции: const result = add ( 10 , 5 ) .
Проверка (Assert)
На стадии проверки мы сверяем полученный результат с ожидаемым. Хотя проверка может состоять из нескольких утверждений, хорошей практикой считается внутри одного теста проверять только одно предположение.
В нашем случае проверка — это сравнение результатов на последней строке:
expect(result).toEqual(expected);expect(result).toEqual(expected);Идеальный тест
Идеальный тест состоит из всех трёх стадий ПВП. Часто — такой тест даже состоит из 3 строчек.
Кроме этого, идеальный тест проверяет только одно утверждение. Для проверки разных утверждений лучше написать отдельные тесты.
Идеальный тест не зависит от других тестов. Если тесты зависят друг от друга, они могут влиять и на результаты проверки друг друга. Правильно написанный тест можно запустить где и как угодно, и его результат не изменится.
Плюсы тестов
Может показаться, что тестирование это пустая трата времени, и лучше выделить это время на другие задачи. И правда, на начальных этапах жизни проекта тесты отнимают время. Но в будущем написанные тесты сохранят больше времени, потому что будут играть роль и документации, и инструмента разработки, и проверки ранее написанного кода.
Рассмотрим основные плюсы тестирования.
Заставляют думать над крайними случаями
Когда мы пишем программу, мы чаще думаем об основном сценарии работы (happy path), часто забывая о крайних случаях.
Тесты смещают фокус с основного сценария на «что может пойти не так». Когда мы пишем тесты, мы больше склонны искать ошибки и неадекватную работу функции. Чем больше крайних случаев мы обработаем, тем надёжнее будет код.
Уменьшают количество регрессий
Регрессия — это ошибка, которая возникает в уже работающей части системы после изменений в коде. Это может быть добавление новой функциональности в старый код или доработка общих функций.
В нашей голове может уместиться лишь небольшой кусочек системы, которую мы программируем. Большая часть системы всегда находится вне поля нашего зрения. Это значит, что при добавлении функциональности мы можем не учесть особенности работы уже существующего кода.
Тесты закрывают такие ошибки, потому что падают при возникновении неучтённой ситуации и не дают ей отправиться в продакшен.
Дают больше уверенности при рефакторинге
Когда мы рефакторим код, мы изменяем его структуру. Ошибки при рефакторинге появляются по той же причине — мы не можем держать всё в голове.
Даже если мы уверены, что полностью знаем кусок, который рефакторим, мы не застрахованы от более простых ошибок:
// 1.let a = 15;if (a == 20) <> // 2.let a = 15;if (a = 20) <>// 1. let a = 15; if (a == 20) > // 2. let a = 15; if (a = 20) >Второй случай в примере выше всегда будет истинным, потому что в условии вместо сравнения — присваивание. Тесты уберегут от подобных ошибок.
Решают проблемы при обновлении зависимостей
Это частный случай регрессий. Обновление зависимостей не только исправляет баги и несёт новые фичи, но иногда и ломает логику работы. Если наш код протестирован, то такие ломающие обновления мы определим сразу.
Автоматическая документация
Тесты не врут
Они действительно показывают, как работает система.Дополнительная документация может устареть, особенно часто это случается с комментариями. Если документация устарела, у нас появляется два источника правды: документация и код. Это плохо, потому что непонятно, чему верить, и как программа должна работать. Тесты же точно говорят, как программа работать должна и работает ли.
Кроме этого, хорошо написанные тесты читаются как текст на английском языке. Из их описаний при желании можно автоматически сгенерировать и текстовую документацию.
Издержки тестирования
Тестирование не бесплатное, за надёжность кода приходится платить.
Нужно больше времени на начальных этапах
На старте проекта издержки ощущаются больнее всего. Чем моложе проект, тем субъективно большую долю времени будут занимать тесты. В этот момент желание плюнуть и не писать тесты сильнее всего, потому что время как будто уходит на бесполезную работу.
Это, конечно, не так. Чем проект старше, тем больше мы получаем выгоды от написанных тестов.
Избавиться от ощущения ненужности тестов можно, если заранее закладывать время на них в план, а также считать их базовой гигиеной при работе. Часть команд и проектов сейчас не работают без тестов вовсе. Такие команды — это хорошая среда для того, чтобы привить себе привычку к тестированию на уровне автоматизма.
Нужно продумать структуру для тестов и тестовых данных
При работе с тестами приходится использовать фиктивные объекты и данные. Например, для проверки аутентификации мы не поднимаем настоящий сервер, а используем заглушку для него, которая отвечает нужными нам в конкретном тесте данными.
Такие заглушки называются моками. Мы подробно рассказываем о моках в соответствующей статье.
Чтобы не запутаться во всех фиктивных объектах и данных, для них нужно организовать структуру. Чем удобнее структура, тем проще писать тесты.
Грамотно организовать систему с фиктивными объектами сложно. Это требует навыков проектирования, знаний о хорошей архитектуре и опыта.
Нужно настроить CI
Тесты в проекте существуют, как правило, вместе с автоматическими задачами, которые их запускают. Они, например, могут отменять релиз, если при обновлении кода тесты не проходят.
Настройка таких задач — это тоже дополнительная работа.
Виды тестов
Хорошо, вот мы взвесили все преимущества и недостатки тестирования. Если мы хотим внедрить тестирование в своём проекте, то какие тесты нам писать?
Тесты бывают разных видов и проверяют они тоже разные вещи. Рассмотрим основные типы тестов в виде пирамиды и поговорим о каждом.

Пирамида тестирования: в основании Unit-тесты, чуть выше интеграционные, на вершине — End-to-End.
Unit тесты
В основании пирамиды лежат юнит-тесты. Их ещё называют модульными тестами или блочными тестами.
Такие тесты проверяют работу конкретного модуля, функции или части программы. Когда мы писали тест для функции add выше, мы писали именно юнит-тест.
Особенность таких тестов в том, что они затрагивают лишь одну часть программы, никак не проверяя остальные. Если тестируемый модуль ссылается на другие модули, то вместо них мы будем использовать фиктивные объекты. Поведение таких объектов мы смоделируем таким, как нам требуется в каждом конкретном тесте.
Как правило, большая часть тестов в проекте — это модульные тесты. Их проще писать, для них не требуется слишком сложной структуры. Они быстро проверяются и их можно запускать параллельно, потому что они не зависят друг от друга.
На юнит-тестах основана методология разработки через тестирование, TDD. Мы рассмотрим работу по TDD в отдельной статье.
Интеграционные тесты
Интеграционные тесты проверяют, как между собой взаимодействуют две и более части программы, которые работают над одной задачей (то есть как они интегрированы друг с другом, отсюда название).
Для интеграционных тестов нужна структура чуть сложнее, чем для модульных. Чтобы запустить интеграционный тест, надо изолированно запустить несколько частей программы.
Часто для интеграционного тестирования нужен отдельный фреймворк, который умеет читать спецификации задач и проверять, что программа работает правильно. Важно, что в таком тестировании мы проверяем не порядок вызова частей программы, а именно результат их совместной работы.
Интеграционными тестами проверяют связующие модули (так называемое middleware):
- юнит-тесты проверяют прямое назначение middleware в целом;
- интеграционные — как middleware взаимодействует с конкретными модулями.
E2E тесты
Они же End-to-End тесты, они же системные тесты — это проверка работы программы в целом.
Такие тесты играют роль пользователей, которые ходят по приложению и выполняют разные задачи. Для этих тестов тоже необходим фреймворк, который умеет манипулировать приложением, как это делают люди.
Например, мы захотели полностью проверить, как работает регистрация на сайте. Мы можем написать сценарий для «робота», который будет ходить по сайту, заполнять формы и проверять, как прошла регистрация.
Одними из самых популярных и удобных фреймворков для E2E тестирования можно назвать Webdriver.IO и Cypress. В качестве примера тестирования с Cypress можем привести тестирование приложения для учёта расходов.
Приёмочные тесты
Приёмочные тесты — это проверка программы перед сдачей клиенту или релизом. Как правило, такие тесты ручные и включают в себя сложные сценарии из спецификаций или технического задания.
Во фронтенд-разработке они встречаются реже всех остальных, но часто используются при разработке десктопных программ или приложений для телефонов. У таких приложений более долгий цикл доставки до пользователя, поэтому приёмочные тесты обычно проходят перед релизом. В вебе цикл поставки короткий, и тесты стараются максимально полно автоматизировать.
Писать или не писать
Даже при наличии издержек тестирование экономит силы, время и деньги в долгосрочной перспективе. Отказаться от тестирования можно, если:
- мы разрабатываем прототип, который не пойдёт в продакшен — скорость важнее;
- или проект точно не будет жить долго — предсказать такое сложно, но если мы уверены, что тесты не успеют окупиться, ими можно пренебречь.
В остальных случаях тесты лучше писать с самого начала.
Причины тестирования — JS: Автоматическое тестирование
Какую главную задачу должны решать тесты? Этот вопрос невероятно важен. Ответ на него даёт понимание того, как правильно писать тесты и как писать их не нужно.
Представьте, что вы написали функцию capitalize(text) , которая делает заглавной первую букву переданной строки:
capitalize('hello'); // 'Hello'Вот один из вариантов её реализации:
const capitalize = (text) => const firstChar = text[0].toUpperCase(); const restSubstring = text.slice(1); return `$firstChar>$restSubstring>`; >;Что мы делаем после создания функции? Проверяем, как она работает. Например, открываем REPL и вызываем функцию с разными аргументами:
> capitalize('hello') 'Hello' > capitalize('how are you') > 'How are you'Таким нехитрым способом убеждаемся, что функция работает. По крайней мере для тех аргументов, которые мы передали в неё. Если во время проверки заметили ошибки, то исправляем функцию и повторяем всё заново.
Фактически, весь этот процесс и есть тестирование. Но не автоматическое, а ручное. Задача такого тестирования — убедиться, что код работает как надо. И нам совершенно без разницы, как конкретно реализована эта функция. Это и есть главный ответ на вопрос, заданный в начале урока.
Тесты проверяют, что код (или приложение) работает корректно. И не заботятся о том, как конкретно написан код, который они проверяют.
Автоматические тесты
Всё, что требуется от автоматических тестов — повторить проверки, которые мы выполняли, делая ручное тестирование. Для этого достаточно старого доброго if и исключений.
Даже если вы не знакомы с исключениями, ничего страшного. В этом курсе достаточно знать две вещи: для чего они нам нужны и какой у них синтаксис. До сих пор в курсах Хекслета вы встречались с ошибками, которые возникают непроизвольно: вызов несуществующей функции, обращение к несуществующей константе и так далее. Но ошибки можно порождать самостоятельно с помощью исключений, что необходимо для нашей ситуации. Исключения создаются такой конструкцией:
// Дословно: выбросить новую ошибку // Исключения бросают throw new Error('описание исключения'); // Код, следующий за этим выражением, не выполнится, а сам скрипт завершится с ошибкой console.log('nothing');if (capitalize('hello') !== 'Hello') // Если результат функции не равен ожидаемому значению // Выбрасываем исключение и завершаем выполнение теста throw new Error('Функция работает неверно!'); >Из примера выше видно, что тесты — это точно такой же код, как и любой другой. Он работает в том же окружении и подчиняется тем же правилам, например, стандартам кодирования. А ещё он может содержать ошибки. Но это не значит, что надо писать тесты на тесты. Избежать всех ошибок невозможно, да и не нужно, иначе стоимость разработки стала бы неоправданно высокой. Обнаруженные ошибки в тестах исправляются, и жизнь продолжается дальше 😉
В коде тесты, как правило, складывают в специальную директорию в корне проекта. Обычно она называется tests, хотя встречаются и другие варианты:
Структура этой директории зависит от того, на базе чего пишутся тесты, например, на базе какого фреймворка. В простых случаях, она отражает структуру исходного кода. Если предположить, что наша функция capitalize(text) определена в файле src/capitalize.js, то её тест лучше поместить в файл tests/capitalize.test.js и в модуль тестов импортировать функцию. Слово test в имени модуля с тестами, используется только для более явного обозначения цели файла.
Теперь при любых изменениях, затрагивающих эту функцию, важно не забывать запускать тесты:
# Если все хорошо, код молча выполнится. # Если есть ошибка, то будет выведено сообщение об ошибке.Как пишутся тесты
Тесты — это не магия. Нам, как разработчикам, нужно самостоятельно импортировать тестируемые функции, вызывать их с необходимыми аргументами и проверять, что функции возвращают ожидаемые значения.
Если поменялась сигнатура функции (входные или выходные параметры, её имя), то придётся переписывать тесты. Если сигнатура осталась той же, но поменялись внутренности функции:
const capitalize = (text) => const [firstChar, . restChars] = text; return `$firstChar.toUpperCase()>$restChars.join('')>`; >;Тогда тесты должны продолжать работать без изменений.
Хорошие тесты ничего не знают про внутреннее устройство проверяемого кода. Это делает их более универсальными и надёжными.
Сколько и какие нужно писать проверки?
Невозможно написать тесты, которые гарантируют 100% работоспособность кода. Для этого потребовалось бы реализовать проверки всех возможных аргументов, что физически неосуществимо. С другой стороны, без тестов вообще нет никаких гарантий, только честное слово разработчиков.
При написании тестов нужно ориентироваться на разнообразие входных данных. У любой функции есть один или несколько основных сценариев использования. Например, в случае capitalize() — это любое слово. Достаточно написать ровно одну проверку, которая покрывает этот сценарий. Дальше нужно смотреть на «пограничные случаи». Это ситуации, в которых код может повести себя по-особенному:
- Работа с пустой строкой
- Обработка null
- Деление на ноль (в большинстве языков вызывает ошибку)
- Специфические ситуации для конкретных алгоритмов
Для capitalize() пограничным случаем будет пустая строка:
if (capitalize('') !== '') throw new Error('Функция работает неверно!'); >Добавив тест на пустую строку, мы увидим, что вызов показанной в начале урока функции capitalize() завершается с ошибкой. Внутри неё идёт обращение к первому индексу строки без проверки его существования. Исправленная версия кода:
const capitalize = (text) => if (text === '') return ''; > const firstChar = text[0].toUpperCase(); const restSubstring = text.slice(1); return `$firstChar>$restSubstring>`; >;В большом числе ситуаций пограничные случаи требуют отдельной обработки, наличия условных конструкций. Тесты должны быть построены таким образом, чтобы они затрагивали каждую такую конструкцию. Но не забывайте, что условные конструкции могут порождать хитрые связи. Например, два независимых условных блока порождают 4 возможных сценария:
- Функция выполнилась так, что не был выполнен ни один условный блок
- Функция выполнилась так, что был выполнен только первый условный блок
- Функция выполнилась так, что был выполнен только второй условный блок
- Функция выполнилась так, что были выполнены оба условных блока
Комбинация всех возможных вариантов поведения функции называется цикломатической сложностью. Это число показывает все возможные пути кода внутри функции. Цикломатическая сложность — хороший ориентир для понимания того, сколько и какие тесты нужно написать.
Иногда пограничные случаи не связаны с условными конструкциями. Особенно часто такие ситуации встречаются там, где есть вычисления границ слов или массивов. Такой код может работать в большинстве ситуаций, но только в некоторых может давать сбой:
// В этой функции забыли отнять единицу от длины // Этот код сработает в некоторых ситуациях, когда последний элемент undefined или в массиве нет элементов // Но в остальных случаях вернёт неверное значение const last = (elements) => elements[elements.length];Проверка входных данных
Особняком стоят ошибки типов входных данных. Например, в функцию capitalize() можно передать число вместо строки. Как она должна себя вести в таком случае? Нужно ли писать такой тест?
Ещё один интересный вопрос. Нужно ли внутри capitalize() обрабатывать такие ситуации? Ответ — не нужно. Иначе код превратится в мусорку, а пользы от этого мало. Всё равно должны быть тесты, которые проверяют, что система работает в целом, а они обычно выявляют проблемы кода на более нижних уровнях.
Ответственность за передачу правильных данных в функцию capitalize() лежит не на ней, а на коде, который вызывает эту функцию. И если он хорошо протестирован, то подобная ошибка либо обнаружится, либо вообще не возникнет.
Но даже если ошибка обрабатывается внутри функции, не надо пытаться написать тесты, покрывающие каждую ошибку. Это выливается в огромное число тестов, которые требуют поддержки и времени на написание. Нужно уметь вовремя остановиться и двигаться дальше, к покрытию другого кода.
Собирая всё вместе
В конечном итоге мы получили такую структуру директорий:
import capitalize from '../src/capitalize.js'; if (capitalize('hello') !== 'Hello') throw new Error('Функция работает неверно!'); > if (capitalize('') !== '') throw new Error('Функция работает неверно!'); > console.log('Все тесты пройдены!');Открыть доступ
Курсы программирования для новичков и опытных разработчиков. Начните обучение бесплатно
- 130 курсов, 2000+ часов теории
- 1000 практических заданий в браузере
- 360 000 студентов
Наши выпускники работают в компаниях:
Тестирование JavaScript кода с Jest для чайников. Часть 1
Здравствуй, Хабр! Данное руководство является первой частью в запланированном цикле статей про такой замечательный фреймворк для тестирования как Jest. Материал будет полезен новичкам и тем, кто только знакомится с тестированием, и хотел бы изучить этот фреймворк. В первой части мы разберём: как начать работу с jest, как написать простой тест, и какие есть методы для сопоставления проверяемых значение с ожидаемыми. Кому интересно — добро пожаловать под кат!
Что такое Jest?
Как указано на домашней странице проекта:
Jest — это восхитительная среда тестирования JavaScript с упором на простоту.
И действительно, Jest очень простой. Он не требует дополнительных настроек, легкий в понимании и применении, а так же имеет довольно хорошую документацию. Отлично подходит для проектов использующих Node, React, Angular, Vue, Babel, TypeScript и не только.
Также он имеет открытый исходный код и поддерживается компанией Facebook.Установка
Для установки Jest в ваш проект выполните:
npm install --save-dev jestЕсли вы используете yarn:
yarn add --dev jestПосле установки можете обновить секцию scripts вашего package.json:
“scripts” :
С помощью такого простого вызова мы уже можем запустить наши тесты (на самом деле jest потребует существование хотя бы одного теста).
Также можно установить глобально (но так делать я бы не рекомендовал, так как по мне глобальная установка модулей является плохой практикой):
npm install jest --globalИ соответственно для yarn:
yarn global add jestПосле этого вы можете использовать jest непосредственно из командной строки.
При помощи вызова команды jest —init в корне проекта, ответив на несколько вопросов, вы получите файл с настройками jest.config.js. Или можно добавить конфигурацию прямиком в ваш package.json. Для этого добавьте в корень json ключ «jest» и в соответствующем ему объекте можете добавлять необходимые вам настройки. Сами опции мы разберем позже. На данном этапе в этом нет необходимости, поскольку jest можно использовать «сходу», без дополнительных конфигураций.
Первый тест
Давайте создадим файл first.test.js и напишем наш первый тест:
//first.test.js test('My first test', () => < expect(Math.max(1, 5, 10)).toBe(10); >);И запустим наши тесты с помощью npm run test или непосредственно командой jest (если он установлен глобально). После запуска мы увидим отчет о прохождении тестов.
PASS ./first.test.js ✓ My first test (1 ms) Test Suites: 1 passed, 1 total Tests: 1 passed, 1 total Snapshots: 0 total Time: 0.618 s, estimated 1 sДавайте «сломаем» наш тест и запустим jest повторно:
//first.test.js test('My first test', () => < expect(Math.max(1, 5, 10)).toBe(5); >);Как мы видим, теперь наш тест не проходит проверки. Jest отображает подробную информацию о том, где возникла проблема, какой был ожидаемый результат, и что мы получили вместо него.
Теперь давайте разберём код самого теста. Функция test используется для создания нового теста. Она принимает три аргумента (в примере мы использовали вызов с двумя аргументами). Первый — строка с названием теста, его jest отобразит в отчете. Второй — функция, которая содержит логику нашего теста. Также можно использовать 3-й аргумент — таймаут. Он является не обязательным, а его значение по умолчанию составляет 5 секунд. Задаётся в миллисекундах. Этот параметр необходим когда мы работаем с асинхронным кодом и возвращаем из функции теста промис. Он указывает как долго jest должен ждать разрешения промиса. По истечению этого времени, если промис не был разрешен — jest будет считать тест не пройденным. Подробнее про работу с асинхронными вызовами будет в следующих частях. Также вместо test() можно использовать it(). Разницы между такими вызовами нету. it() это просто алиас на функцию test().
Внутри функции теста мы сначала вызываем expect(). Ему мы передаем значение, которое хотим проверить. В нашем случае, это результат вызова Math.max(1, 5, 10). expect() возвращает объект «обертку», у которой есть ряд методов для сопоставления полученного значения с ожидаемым. Один из таких методов мы и использовали — toBe.
Давайте разберем основные из этих методов:
- toBe() — подходит, если нам надо сравнивать примитивные значения или является ли переданное значение ссылкой на тот же объект, что указан как ожидаемое значение. Сравниваются значения при помощи Object.is(). В отличие от === это дает возможность отличать 0 от -0, проверить равенство NaN c NaN.
- toEqual() — подойдёт, если нам необходимо сравнить структуру более сложных типов. Он сравнит все поля переданного объекта с ожидаемым. Проверит каждый элемент массива. И сделает это рекурсивно по всей вложенности.
test('toEqual with objects', () => < expect(< foo: 'foo', subObject: < baz: 'baz' >>) .toEqual( < foo: 'foo', subObject: < baz: 'baz' >>); // Ок expect( < foo: 'foo', subObject: < num: 0 >>) .toEqual( < foo: 'foo', subObject: < baz: 'baz' >>); // А вот так ошибка. >); test('toEqual with arrays', () => < expect([11, 19, 5]).toEqual([11, 19, 5]); // Ок expect([11, 19, 5]).toEqual([11, 19]); // Ошибка >);const arr = ['apple', 'orange', 'banana']; expect(arr).toContain('banana'); expect(new Set(arr)).toContain('banana'); expect('apple, orange, banana').toContain('banana');expect([, ]).toContainEqual();expect([1, 2, 3, 4]).toHaveLength(4); expect('foo').toHaveLength(3); expect(< length: 1 >).toHaveLength(1);const num = 0.1 + 0.2; // 0.30000000000000004 expect(num).toBeCloseTo(0.3); expect(Math.PI).toBeCloseTo(3.14, 2);expect('Banana').toMatch(/Ba/);function funcWithError() < throw new Error('some error'); >expect(funcWithError).toThrow(); expect(funcWithError).toThrow(Error); expect(funcWithError).toThrow('some error'); expect(funcWithError).toThrow(/some/);expect(true).not.toBe(false); expect(< foo: 'bar' >).not.toEqual(<>); function funcWithoutError() <> expect(funcWithoutError).not.toThrow();// src/circle.js const area = (radius) => Math.PI * radius ** 2; const circumference = (radius) => 2 * Math.PI * radius; module.exports = < area, circumference >;Далее добавим тесты:
// tests/circle.test.js const circle = require('../src/circle'); test('Circle area', () => < expect(circle.area(5)).toBeCloseTo(78.54); expect(circle.area()).toBeNaN(); >); test('Circumference', () => < expect(circle.circumference(11)).toBeCloseTo(69.1, 1); expect(circle.circumference()).toBeNaN(); >);В этих тестах мы проверили результат работы 2-х методов — area и circumference. При помощи метода toBeCloseTo мы сверились с ожидаемым результатом. В первом случае мы проверили или вычисляемая площадь круга с радиусом 5 приблизительно равна 78.54, при этом разница с полученым значением (оно составит 78.53981633974483) не большая и тест будет засчитан. Во втором мы указали, что нас интересует проверка с точностью до 1 знака после запятой. Также мы вызвали наши методы без аргументов и проверили результат с помощью toBeNaN. Поскольку результат их выполнения будет NaN, то и тесты будут пройдены успешно.
Разберём ещё один пример. Создадим функцию, которая будет фильтровать массив продуктов по цене:
// src/productFilter.js const byPriceRange = (products, min, max) => products.filter(item => item.price >= min && item.price ;// tests/product.test.js const productFilter = require('../src/producFilter'); const products = [ < name: 'onion', price: 12 >, < name: 'tomato', price: 26 >, < name: 'banana', price: 29 >, < name: 'orange', price: 38 >]; test('Test product filter by range', () => < const FROM = 15; const TO = 30; const filteredProducts = productFilter.byPriceRange(products, FROM, TO); expect(filteredProducts).toHaveLength(2); expect(filteredProducts).toContainEqual(< name: 'tomato', price: 26 >); expect(filteredProducts).toEqual([< name: 'tomato', price: 26 >, < name: 'banana', price: 29 >]); expect(filteredProducts[0].price).toBeGreaterThanOrEqual(FROM); expect(filteredProducts[1].price).toBeLessThanOrEqual(TO); expect(filteredProducts).not.toContainEqual(< name: 'orange', price: 38 >); >);В этом тесте мы проверям результат работы функии byRangePrice. Сначала мы проверили соответствие длины полученого массива ожидаемой — 2. Следующая проверка требует, чтобы в массиве находился элемент — < name: 'tomato', price: 26 >. Объект в массиве и объект переданный toContainEqual — это два разных объекта, а не ссылка на один и тот же. Но toContainEqual сверит каждое свойство. Так как оба объекта идентичные — проверка пройдет успешно. Далее мы используем toEqual для провеки структуры всего массива и его элементов. Методы toBeGreaterThanOrEqual и toBeLessThanOrEqual помогут нам проверить price первого и второго элемента массива. И, наконец, вызов not.toContainEqual сделает проверку, не содержится ли в массиве элемент — < name: 'orange', price: 38 >, которого по условию там быть не должно.
В данных примерах мы написали несколько простых тестов используя функции проверки описанные выше. В следующих частях мы разберём работу с асинхронным кодом, функции jest которые не затрагивались в этой части туториала, поговорим о его настройке и многое другое.
- JavaScript
- Тестирование веб-сервисов
