Зачем нужны unit-тесты на фронте. Изучаем jasmine.js вместе

апрель 17 , 2016
Демо Исходники

Изучаем jasmine.js вместеОднажды пришла в голову мысль о том, что пора становиться взрослым и начать писать тесты на свой код. Времена, когда javascript использовался преимущественно для анимашек и кликов на кнопки для открытия всплывающих окон, давно прошли. Логика работы с данными понемногу перетекает на клиентскую часть приложения, которая в некоторых случаях становится сложнее серверной. В условиях быстрого разрастания js-кода становится все проблематичнее добавлять новый функционал, не боясь поломать старый. Здесь нам и приходят на помощь unit-тесты. Об этой теме написано уже множество статей, подробно разобраны возможности различных библиотек, поэтому не буду рассматривать самые основы и копировать примеры из официальной документации. В статье я хочу показать, как можно быстро создать песочницу для unit-тестов, как подготовить код к модульным тестам и написать сами тесты. Чтобы не рассматривать какие-то абстрактные варианты, мы будем разбираться и писать тесты на примере модуля корзины для интернет-магазина, который мы разрабатывали пару статей назад. В качестве подопытного кролика выбрана популярная библиотека jasmine.js. Итак, подробнее...


Небольшая предыстория, раскрываем проблему.

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

Через какое-то время клиент просит Вас добавить к этой корзине какой-то новый функционал, допустим, на сайте вводятся скидки для постояннных покупателей, и в корзине нужен пересчет цен на товары и общей суммы с учетом этой скидки. Хорошо, если эта просьба прилетает через неделю после завершения работы над проектом. Вероятно, Вы хорошо помните весь код и быстро добавляете новые методы. Если же это нужно сделать через полгода после сдачи проекта, Вам нужно не только вспомнить старый код, но написать новый, не поломав старый функционал. После добавления функционала скидок Вам приходится заново проверить все варианты работы корзины, убедившись, что все написанное ранее работает по-старому, хорошо и стабильно. Неприятная процедура, и очень невесело бывает, когда отрапортовав клиенту об успешном внедрении скидок, через месяц Вы узнаете, что пересчет скидок ломается, когда из корзины удаляется один товар на сумму свыше 10 000 рублей. При этом в консоль вылетает страшная ошибка с каким-нибудь undefined, отваливается кнопка Оформить заказ, покупатель не может купить нужную ему вещь, уходит с сайта - все расстроены. Вы срочно фиксите этот баг, уверяете клиента, что это единичный случай, и забываете про это. Ровно до того момента, когда клиент захотел ввести хитрую систему вычисления стоимости доставки с учетом общей суммы заказа и количества оформленных ранее заказов. Вас охватывает легкая паника и огромное нежелание связываться с этой несложной на первый взгляд задачей. Код полуторагодовой давности Вы уже давно забыли, а вот сколько проблем принесла последняя подобная просьба, помните хорошо, и не хотите повторять этот кошмар с перетестированием всего старого функционала, учитывая все подводные камни и хитрые случаи. Что же делать?

P.S. Описанная история выдумана и не имеет ничего общего с реальной жизнью :-)
(ага, как же - прим. редактора)


Что делать и как нам помогут модульные тесты?

Для начала самое простое - возвращаемся на пару лет назад к тому моменту, когда мы только начали писать наш модуль корзины. Мы хорошо продумываем весь функционал, который нужен нам на данный момент. Мы разбиваем методы модуля так, чтобы они были максимально короткими и каждый метод выполнял только одну задачу, но выполнял ее хорошо. (чувствую, что пропагандирую Linux с его идеями о кучу небольших утилиток, выполняющих строго ограниченный функционал). Далее подключаем одну из библиотек для юнит-тестов и пишем сценарии, которые определяют функционал нашего модуля.

К примеру, так: если мы удаляем товар из корзины, то общая сумма заказа должна уменьшиться на стоимость этого товара, умноженную на количество. Только не по-русски, а по-javascript-овски.
Написанные сценарии повторяют все то, что Вы делаете при ручном тестировании. Когда Вы проверяете корзину, Вы же всегда добавляете в нее товар, увеличиваете количество товаров, удаляете их по одному и все сразу. При этом Вы следите за общим количеством товаров и пересчетом их общей стоимости. Вот то же самое мы и будем проверять при написании unit-тестов. Конкретные примеры рассмотрим ниже.

Когда все тесты написаны, Вы их запускаете и видите результаты всех описанных сценариев. Если что-то пошло не так, то мы сразу увидим, какие тесты провалились и соответственно, какой участок кода нужно править.

Итак, 2 самых больших плюса юнит-тестов:

  • 1. Прочитав описания к тестам, Вы сразу видите, что делает этот модуль, не вникая в его код, и какие хитрые варианты входных данных методы модуля обрабатывают.
  • 2. Вы можете писать новый функционал, не боясь, что поломаете старый и не заметите этого, потому что будете запускать тесты снова и снова, и сразу увидите, какие методы отказались работать с новым кодом. И конечно, пофиксите код по горячим следам.
Естественно, после добавления новых методов нам нужно будет написать тесты и к ним, чтобы все сценарии работы Вашего модуля оказались под контролем.


Почему jasmine.js

  • 1. Хорошая документация
  • 2. Простота подключения и создания "песочницы"
  • 3. Интуитивно понятный синтаксис
  • 4. Возможность запускать тесты из командной строки в watch-режиме
  • 5. Интересные расширения вроде jasmine-jquery для тестирования верстки (отдельная тема для другой статьи)

Что будем тестировать?

Модуль корзины для интернет-магазина из этой статьи. Для начала изучения тестов и упрощения этой процедуры из модуля корзины убраны методы работы с событиями, оставлен только функционал работы с данными. Что осталось в итоге, смотрим здесь


Нужные ссылки для работы.

Все материалы и файлы для работы, а также документацию Вы можете найти здесь: http://jasmine.github.io/
Чтобы не искать все самим, привожу 2 ссылки, которые прямо сейчас нас интересуют:

  • 1. http://jasmine.github.io/2.4/introduction.html - документация, на момент написания статьи версия 2.4
  • 2. jasmine.zip - исходники нужных файлов библиотеки jasmine.js, модуля cart и самих тестов в одном архиве
Ну и для самых нетерпеливых, что у нас получится в итоге - https://webdevkin.ru/examples/46/
Именно на этой странице будут подключаться все нужные файлы, модуль cart и будут запускаться сами тесты. А теперь, как это сделать своими руками. Убедимся, что это не так страшно, как кажется на первый взгляд.


Создание проекта и его структура.

Создаем папку проекта, например, jasmine. В ней нужно добавить файл index.html и 3 папки:

  • 1. vendor - файлы, нужные для jasmine и вспомогательные библиотеки для нас, например, underscore.js
  • 2. modules - тестируемые js-модули, в нашем случае один cart.js
  • 3. specs - сами тесты, в нашем случае тоже один cart.js - назовем файлы по имени модуля, чтобы не запутаться.

    • В папку vendor положим следующие файлы: jasmine.css, jasmine.js, jasmine-html.js, boot.js - jasmin-овские файлы и underscore.js - нужен для работы нашей корзины.


      Главный файл index.html

      Так как парсер на страницах сайта жрет теги, а мне пока неохота разбираться с этой проблемой, рекомендую открыть index.html из указанного выше архива jasmine.zip и посмотреть на его код. Здесь мы видим стандартную заготовку для html-файла. Подключаем стили для оформления вывода результатов тестов, 3 js-файла для jasmine, underscore.js, modules/cart.js - сам модуль и напоследок файл с тестами specs/cart.js Тег body пустой, jasmine все сделает за нас


      Какой код тестируем - модуль корзины modules/cart.js

          'use strict';
          
          // Модуль корзины
          var Cart = function() {
          
              var cartData;
          
              // Получаем данные
              function updateData() {
                  cartData = JSON.parse(localStorage.getItem('cart')) || [];
                  return cartData;
              }
          
              // Возвращаем данные
              function getData() {
                  return cartData;
              }
          
              // Сохраняем данные в localStorage
              function saveData() {
                  localStorage.setItem('cart', JSON.stringify(cartData));
                  return cartData;
              }
          
              // Очищаем данные
              function clearData() {
                  cartData = [];
                  saveData();
                  return cartData;
              }
          
              // Поиск объекта в коллекции cartData по id
              function getById(id) {
                  return _.findWhere(cartData, {id: id});
              }
          
              // Добавление товара в коллекцию
              function add(item) {
                  var oldItem;
                  updateData();
                  oldItem = getById(item.id);
                  if(!oldItem) {
                      cartData.push(item);
                  } else {
                      oldItem.count = oldItem.count + item.count;
                  }
                  saveData();
                  return item;
              }
          
              // Удаление товара из коллекции
              function remove(id) {
                  updateData();
                  cartData = _.reject(cartData, function(item) {
                      return item.id === id;
                  });
                  saveData();
                  return cartData;
              }
          
              // Изменение количества товара в коллекции
              function changeCount(id, delta) {
                  var item;
                  updateData();
                  item = getById(id);
                  if(item) {
                      item.count = item.count + delta;
                      if (item.count < 1) {
                          remove(id);
                      }
                      saveData();
                  }
                  return _.findWhere(cartData, {id: id}) || {};
              }
          
              // Возвращаем количество товаров (количество видов товаров в корзине)
              function getCount() {
                  return _.size(cartData);
              }
          
              // Возвращаем общее количество товаров 
              function getCountAll() {
                  return _.reduce(cartData, function(sum, item) {return sum + item.count}, 0);
              }
          
              // Возвращаем общую сумму
              function getSumma() {
                  return _.reduce(cartData, function(sum, item) {return sum + item.count * item.price}, 0);
              }
          
          
              // Экспортируем наружу
              return {
                  update: updateData,
                  getData: getData,
                  save: saveData,
                  clearData: clearData,
                  getById: getById,
                  add: add,
                  remove: remove,
                  changeCount: changeCount,
                  getCount: getCount,
                  getCountAll: getCountAll,
                  getSumma: getSumma
              }
          
          };
      

      Весь модуль включает в себя 11 методов, экспортируемых наружу. Все они будут покрыты тестами.
      Вот их полный список:
      update, getData, save, clearData, getById, add, remove, changeCount, getCount, getCountAll, getSumma
      Код их достаточно простой, короткий и снабжен комментариями. Более подробно этот модуль описывается в статье Корзина для интернет-магазина на фронте или Пишем модульный javascript
      Теперь приступаем к главному - самим тестам.


      specs/cart.js - заготовка

      Я предполагаю, что Вы хотя бы бегло прочитали документацию jasmine и знакомы с ее синтаксисом. Если нет, то это не беда, в статье использованы только самые простые конструкции, которые будут понятны без лишнего пояснения.
      Заготовка файла specs/cart.js

          'use strict';
          
          describe('Корзина товаров', function() {    
          
              var savedData,
                  // модуль корзины
                  cart = Cart(),
                  // 2 базовых товара
                  phoneBase = {
                      id: 1,
                      name: 'phone',
                      price: 1000
                  },
                  notebookBase = {
                      id: 2,
                      name: 'notebook',
                      price: 5000
                  };
                  
              // тесты...
          });
      

      describe задает название тестируемого модуля или участка приложения.
      Далее создаем несколько переменных:

      • savedData - назначение объясню в конце статьи.
      • cart - сам модуль корзины из файла modules/cart.js, доступен во всех тестах.
      • phoneBase и notebookBase - базовые товары для операций с корзиной.
      Нам нужны пара заготовок для товаров, которые мы будем добавлять, удалять из корзины и подсчитывать их стоимость.


      Сценарий теста

      У нас есть основные возможности модуля корзины: считывание данных из localStorage, сохранение их туда же, добавление товаров в корзину, изменение их количества, удаление и подсчет сумм товаров и их общего количества.
      Кто-то, возможно, скажет, что каждый тест должен работать независимо от результатов предыдущих, что тесты можно запускать в разной последовательности, и при этом они должны проходить в любом порядке. Но я в случае с корзиной не вижу смысла писать делать так и предлагаю пойти по другому сценарию.
      Все тесты будут оперировать с одной корзиной, к которой есть доступ из каждого теста. Мы будем последовательно добавлять в корзину товары, удалять их, изменять количество, следить за суммой заказа и прочее. Таким образом, мы смоделируем все варианты работы методов, проверим их правильность на разных условиях и увидим возможное пользовательское поведение при работе с корзиной.
      Если пока не очень ясно, что я имел в виду, то не беспокойтесь - дальше по коду все встанет на свои места.


      Первый тест, тестируем метод update - считывание данных из localStorage

          // cart.update()
          it('Считывание данных из localStorage', function() {
              saveLocalStorage();
      
              localStorage.setItem('cart', '[{"id": 1, "name": "phone", "price": 1000, "count": 5}]');
              var data = cart.update();
              expect(data).toEqual([{
                  id: 1,
                  name: 'phone',
                  price: 1000,
                  count: 5
              }]);
          });
      

      Назначение saveLocalStorage() будет в конце статьи, пока не обращайте внимания. Мы помним, что данные для корзины хранятся в поле cart localStorage, поэтому чтобы протестировать работу метода update, мы сначала запишем в это поле один товар руками, потом считаем его в переменную data и сравним с нужными значениями.


      Очистка корзины

          // cart.clear()
          it('Очистка корзины', function() {
              var data = cart.clearData();
              // В корзине пусто
              expect(data).toEqual([]);
          });
      

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


      Тестируем добавление в корзину

          // cart.add()
          it('Добавление в корзину первого товара', function() {
              var phone = _.clone(phoneBase);
              cart.add(_.extend(phone, {count: 1}));
              var data = cart.getData();
              // В корзине 1 телефон
              expect(data).toEqual([{
                  id: 1,
                  name: 'phone',
                  price: 1000,
                  count: 1
              }]);
          });
      
          // cart.add()
          it('Добавление в корзину нескольких товаров', function() {
              var notebook = _.clone(notebookBase);
              cart.add(_.extend(notebook, {count: 4}));
              // В корзине 1 телефон и 4 ноутбука
              var data = cart.getData();
              expect(data).toEqual([{
                  id: 1,
                  name: 'phone',
                  price: 1000,
                  count: 1
              }, {
                  id: 2,
                  name: 'notebook',
                  price: 5000,
                  count: 4
              }]);
          });
      

      Аналогично. Вот здесь нам пригодились базовые товары notebookBase и phoneBase.


      Поиск товара по его id

          // cart.getById()
          it('Поиск товара по id', function() {
              var good = cart.getById(2);
              expect(good).toEqual(_.extend(notebookBase, {count: 4}));
          });
      

      Здесь мы убеждаемся, что метод поиска работает, как нужно.


      Общие параметры: количество товаров и общая сумма

          // cart.getCount()
          it('Количество наименований товаров', function() {
              expect(cart.getCount()).toBe(2);
          });
      
          // cart.getCountAll()
          it('Количество всех товаров', function() {
              expect(cart.getCountAll()).toBe(5);
          });
      
          // cart.getSumma()
          it('Общая сумма товаров', function() {
              expect(cart.getSumma()).toBe(21000);
          });
      

      Здесь без комментариев, методы просты.


      Изменение количества и удаление товаров из корзины

          // cart.changeCount()
          it('Увеличение количества товара', function() {
              cart.changeCount(1, 2);
              // В корзине 3 телефона и 4 ноутбука
              expect(cart.getSumma()).toBe(23000);
          });
      
          // cart.changeCount()
          it('Уменьшение количества товара', function() {
              cart.changeCount(2, -3);
              // В корзине 3 телефона и 1 ноутбук
              expect(cart.getById(2)['count']).toBe(1);
              expect(cart.getSumma()).toBe(8000);
          });
      
          // cart.remove()
          it('Удаление товара', function() {
              cart.remove(2);
              // В корзине 3 телефона
              expect(cart.getData()).toEqual([_.extend(phoneBase, {count: 3})]);
          });
      
          // cart.changeCount()
          it('Уменьшение количества товара до нуля и его удаление', function() {
              cart.changeCount(1, -3);
              // В корзине пусто
              expect(cart.getData()).toEqual([]);
      
              // Возвращаем старое значение localStorage
              restoreLocalStorage();
          });
      

      Здесь мы тестируем 4 варианта: увеличение и уменьшение количества товаров, удаление товара из корзины и еще один вариант: мы хотим убедиться, что уменьшение количества товара до нуля приведет к его удалению.


      Вместо заключения.

      Итак, все тесты написаны, запустив файл index.html мы увидим полную информацию о Ваших тестах. Если какой-то из них не прошел проверку, об этом будет дан подробный отчет. Попробуйте в любом тесте заменить что-то, например, введите неверное число в проверке expect и увидите изменения.

      2 функции, которые выполняются до и после наших тестов: saveLocalStorage и restoreLocalStorage. Мы пишем тесты и прогоняем их на том же проекте, где и располагаются тестируемые модули, логично :-) В нашем примере мы вынесли модуль modules/cart.js в целях демонстрации. В реальной жизни Вы запускаете тесты, но при этом не хотите, чтобы Ваша корзина затерлась после прогонки тестов. Та корзина, что Вы собрали в браузере при тестировании руками, должна сохраняться и после завершения тестов, поэтому перед запуском мы просто сохраняем значение поля cart из localStorage, а после отработки тестов возвращаем их обратно. Таким образом Вы можете параллельно проверять работу корзины на сайте и запускать тесты в соседней вкладке.

          // Сохраняем текущее значение localStorage в объект, чтобы вернуть обратно после завершения теста
          function saveLocalStorage() {
              var lsData = localStorage.getItem('cart');
              savedData = (lsData) ? {empty: false, data: lsData} : {empty: true}
          }
      
          // Восстановление содержимого localStorage
          function restoreLocalStorage() {
              if (!savedData.empty) {
                  localStorage.setItem('cart', savedData.data);
              } else {
                  localStorage.removeItem('cart');
              }
          }
      

      Еще раз ссылки:

      P.S. и небольшой совет: в случае с юнит-тестами прислушивайтесь к голосу разума :-)
      Не пишите тесты на каждый чих и не превращайте их написание в священный обряд. И покрывайте только самые важные участки кода, функционал которого критичен для работы проекта.

      Демо Исходники
      Заходите в группу в контакте - https://vk.com/webdevkin
      Анонсы статей, обсуждения интернет-магазинов, vue, фронтенда, php, гита.
      Истории из жизни айти и обсуждение кода.
      Как Вам статья? Оцените!