Frontender Magazine

Болеутоляющее или статья о том, что можно писать тестируемый 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 приложение может выглядеть так:

var tmplCache = {};

function loadTemplate (name) {
  if (!tmplCache[name]) {
    tmplCache[name] = $.get('/templates/' + name);
  }
  return tmplCache[name];
}

$(function () {

  var resultsList = $('#results');
  var liked = $('#liked');
  var pending = false;

  $('#searchForm').on('submit', function (e) {
    e.preventDefault();

    if (pending) { return; }

    var form = $(this);
    var query = $.trim( form.find('input[name="q"]').val() );

    if (!query) { return; }

    pending = true;

    $.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;
        });
      }
    });

    $('<li>', {
      'class' : 'pending',
      html : 'Идёт поиск…'
    }).appendTo( resultsList.empty() );
  });

  resultsList.on('click', '.like', function (e) {
    e.preventDefault();
    var name = $(this).closest('li').find('h2').text();
    liked.find('.no-results').remove();
    $('<li>', { text: name }).appendTo(liked);
  });

});

Мой друг Адама Сонтега (Adam Sontag) называет это «Выбери себе приключение сам» — в каждой строчке мы с равной вероятностью можем иметь дело как с представлением, так и с информацией, обслуживанием логики пользовательского взаимодействия или проверкой состояния приложения. Остаётся только догадываться! Достаточно просто написать интеграционные тесты для этого кода, и в тоже время очень сложно написать тесты для тестирования отдельных функциональных частей приложения.

Почему это сложно? На это существуют четыре причины:

Организация кода

Первое, что небходимо сделать — выбрать менее запутанный метод организации кода, разбить его на несколько зон ответственности:

В «традиционной» реализации, показанной выше, эти четыре категории перемешаны — на одной строчке мы работаем с представлением, двумя строчками ниже мы общаемся с сервером.

Изображение

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

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

Каждая новая строчка кода будет следовать этому небольшому списку правил:

Для начала надо определиться, на какие части мы разобьём наше приложение. У нас есть три части, относящиеся к представлению и взаимодействию: поисковая форма, поисковые результаты и сектор для понравившегося.

Изображение

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

Начнём с наиболее простой части приложения — сектор для понравившегося. В оригинальном приложении следующий код отвечал за обновление этого сектора:

var liked = $('#liked');
var resultsList = $('#results');

// …

resultsList.on('click', '.like', function (e) {
  e.preventDefault();
  var name = $(this).closest('li').find('h2').text();
  liked.find( '.no-results' ).remove();
  $('<li>', { text: name }).appendTo(liked);
});

Код поисковой формы сильно переплетен с сектором понравившегося и также требует информации о том, как устроена разметка. Гораздо лучшим подходом (и для тестируемости тоже) будет создание объекта сектора понравившегося, ответственного за манипуляции с DOM:

var Likes = function (el) {
  this.el = $(el);
  return this;
};

Likes.prototype.add = function (name) {
  this.el.find('.no-results').remove();
  $('<li>', { text: name }).appendTo(this.el);
};

В этом коде приведён конструктор, создающий новую копию объекта Likes Box. Созданная копия имеет метод .add(), используемый для добавления новых результатов. Мы можем написать немного тестов, чтобы проверить, работает ли этот метод:

var ul;

setup(function(){
  ul = $('<ul><li class="no-results"></li></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 = $('<ul><li class="no-results"></li></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-тестирование, но если вы заинтересовались и хотите изучить тему глубже, то обязательно проверьте следующие ссылки:

Если вы заметили ошибку, вы всегда можете отредактировать статью, создать issue или просто написать об этом редакторам Антону Немцеву в skype ravencry, Константину Гупалову в skype cosmik_soul или в skype matmuchrapna доброму Владимиру Старкову.

Rebecca Murphey
Автор:
Rebecca Murphey
GitHub:
rmurphey
Twitter:
@rmurphey
Сaйт:
http://rmurphey.com/
Email:
rmurphey@gmail.com
Владимир Старков
Переводчик:
Владимир Старков
Сaйт:
http://vstarkov.ru/
Twitter:
@matmuchrapna
GitHub:
matmuchrapna

Комментарии (6 комментариев, если быть точным)

Автар пользователя
beshkenadze

Почему: "Мой друг Адама Сонтега (Adam Sontag)"? Мой друг Адам Сонтаг...Почему не так? Почему листинге кода для теста, часть слов переведена, а часть нет?

Автар пользователя
SilentImp

И то и другое — результат невнимательности редактора. Поправили.

Автар пользователя
mistakster

Очень полезная статья.

Только с терминологий некая путаница, по-моему. Понимаю, что её внесла Ребекка в своей оригинальной статье. По этому хочу сделать несколько акцентов.

Модульное (Unit) тестирование

Тестируемый объект не зависит от окружения или все зависимости легко подменяются на заглушки.

var DateUtil = function (dateService) {
  this._dateService = dateService;
};
/**
 * Получаем последний день месяца
 * @param {Date} [date] опциональная дата
 */
DateUtil.prototype.lastDayOfMonth = function (date) {
  if (!date) {
    // Если дата не передана явно, то используется текущая
    date = this._dateService.now();
  }
  return new Date(date.getFullYear(), date.getMonth() + 1, 0);
};

Этот модуль можно легко протестировать явно передавая даты. Если дата не будет передана, то будет вызван сервис, возвращающий текущую дату. В тестах сервис заменяется на заглушку, которая будет возвращать заранее определённые данные. Нельзя просто взять и вызвать new Date(), так как это лишит модуль предсказуемого поведения.

На момент написания модуля, его зависимости могут быть ещё даже не реализованы. И это никак не помешает закончить модуль и покрыть тестами.

Интеграционное тестирование

Используется, когда создать адекватную заглушку сервиса чрезвычайно тяжело или не представляется возможным. Так же используется для тестирования совместной работы нескольких модулей, внешних сервисов (например, CouchDB) и т.п.

Перед началом теста формируются исходные данные (например, база заполняется пользователями и продуктами). По окончании теста проверяется наличие нужные объектов в базе и отсутствие ненужных.

Сами тесты, по сути, будут очень напоминать модульные тесты. Но в них не используются заглушки (mock), а вызываются методы реальных модулей, компонент и сервисов. Происходит тестирование модулей в связке между собой.

Предполагается, что модули до этого были покрыты Unit-тестами и успешно их проходят. Хотя, это не является обязательным условием. Можно выбрать что-то одно: модульные или интеграционные тесты. В некоторых случаях (например, как в предыдущем примере) интеграционные тесты попросту бессмысленны. Это всё равно, что тестировать генератор случайных чисел.

Функциональное тестирование

Этот слой тестирования предполагает взаимодействие с пользовательским интерфейсом.

Если модульные и интеграционные тесты основывались на вызовах методов каких-то объектов или генерацию программных событий, то функциональные тесты будут имитировать работу пользователя.

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

Итог

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

Что касается тестирования JS-компонент, то скорее всего, интеграционное тестирование будет полностью заменено на функциональное.

Автар пользователя
f0rmat1k

Спасибо. Отличная статья, как раз сейчас дошел до такого уровня, чтоб начать писать тесты для своего кода. Кстати, в выражении "XHR-запрос", слово "запрос" не нужно, т.к. "R" уже означает "запрос".

Автар пользователя
matmuchrapna

Я думаю, что слово «запрос» всё таки нужно, так как это устоявшийся рекурсивный акроним

Автар пользователя
f0rmat1k

Ну, например, во фразе "IT-технологии", слово "технологии" считается лишним. Хотя для таких случаев еще правил не придумали, наверно. Вообще, я не настаиваю, просто мне показалось лишним.