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, приложение, используя шаблонизатор на клиенте, выводит их на страницу. Пользователь может кликнуть по элементу поисковой выдачи, чтобы показать, что он ему понравился; в этом случае имя понравившегося человека добавляется в список понравившихся, расположенный справа.

«Традиционная» реализация может выглядеть следующим образом:

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, который будет обеспечивать работу «понравившегося». Созданная копия имеет метод .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. Существует множество других фреймворков для тестирования, но я считаю эти два достаточными для введения в предметную область. Вам следует найти фреймворк, который подойдет для вас и вашего проекта наилучшим образом. Кроме Mocha стоит обратить внимание на достаточно популярный Qunit или подающий большие надежды Intern.

Приведённый код начинается с создания элемента, который будет использован как контейнер для «понравившегося». Затем запускается два теста: первый проверяет, можем ли мы создать блок «понравившегося»; второй нужен, чтобы удостовериться, что метод .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);
});

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

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

Теперь код нашего старого приложения, отвечающий за взаимодействие между «Поисковой выборкой» и «Понравившимся», выглядит так:

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);

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

Во-первых, это то, что нам не нужно посылать серверу реальные запросы, так как это уже будет интеграционным тестированием, а так как мы с вами ответственные разработчики, то код на стороне сервера уже покрыт всеми нужными тестами, верно? Вместо этого нам нужно создать «заглушку», и мы можем сделать это с использованием библиотеки 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('Получаем данные с корректного URL', function () {
    var sd = new SearchData();
    sd.fetch('cat');

    assert.equal(requests[0].url, '/data/search.json?q=cat');
  });

  test('Получаем колбек', 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('Получаем колбек, даже если не был сделан запрос', function () {
    var sd = new SearchData();
    var req = sd.fetch();

    assert.isFunction( req.then );
  });

  test('Не вызываем колбек, если сделан пустой запрос', function () {
    var sd = new SearchData();
    var req = sd.fetch();
    var spy = sinon.spy();

    req.then(spy);

    assert.deepEqual(spy.args[0][0], []);
  });

  test('Получаем данные из свойства results ответа сервера', 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 ]);
  });
});

Я оставила за пределами статьи рефакторинг объекта SearchForm и провела рефакторинг еще нескольких участков кода и тестов. Если вам интересно, вы можете взглянуть на законченную версию приложения в моём репозитории на github.

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

$(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, но если вы заинтересовались и хотите изучить тему глубже, то обратите внимание на следующие ссылки:

  1. моя презентация с конференции Full Frontall (Брайтон, Великобритания, 2012);
  2. grunt — инструмент, который поможет автоматизировать процесс тестирования;
  3. книга Test-Driven JavaScript Development Кристиана Джохансона, создателя библиотеки Sinion. Это краткая, но очень информативная проверка по основным постулатам тестируемого 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-технологии", слово "технологии" считается лишним. Хотя для таких случаев еще правил не придумали, наверно. Вообще, я не настаиваю, просто мне показалось лишним.