пятница, 17 января 2014 г.

Тестирование с помощью qUnit



В этой статье я намерен продемонстрировать мой опыт в тестировании небольшого JavaScript приложения (см. предыдущую статью) с помощью qUnit. Поскольку я буду тестировать уже существующее приложение, то изначальную версию кода можно взять из этого коммита в репозитории.
Я предполагаю, что читатель знаком с принципом работы qUnit. В приложении используются библиотеки jQuery и FRP-библиотека Bacon.js, позволяющая писать чистый декларативный JS. В итоге выходит вполне себе общий случай сочетания разных методов программирования, и поэтому статья может быть интересна тем, кто задается вопросом, как писать тестируемый JavaScript-код с минимальными издержками в сфере логичности, лаконичности и производительности приложения. Иначе говоря, в этой статье я приведу пример рационального подхода к созданию test-driven приложений.













Статья вышла слишком частная, и без подготовки непонятная т.к. используется довольно редкая библиотека bacon.js, она, конечно, вполне наглядно иллюстрирует test-driven код, но пример довольно необычный и если вас больше интересует общие случаи, то можете просто пролистать мой вывод.

Вообще, есть мнение, что хорошо бы писать сначала тесты, а потом уже приложение. Но я себе смутно представляю такой процесс, когда приложение находится на уровне, когда оно еще не реализовано даже в голове. Другой вопрос, что наращивать функционал можно и скорее всего нужно подготавливая сначала тесты, которые улучшенное приложение должно пройти. Так или иначе, я попробую не лезть вперед и представить, что приложения как такового еще нет и придумывать тесты "от малого к большому".

Если вы не читали предыдущий пост, то тестируется игра сокобан. Для корректной работы кроме js файла и библиотек нужен еще JSON, которые описывает карту игры в координатах. Чтобы было на чем тестировать я изначально заготовлю несколько тестировочных карт:

var t1 = { //правильный уровень
width: 4, height: 4, player : {}, walls : [],
blocks : [{x:1, y:1}], goals : [{x:2,y:1}] 
}

var t2 = {//уровень, где блоки и цели уже стоят на местах
  width: 4, height: 4,player : {}, walls : [], 
  blocks : [{x:1, y:1}], goals : [{x:1,y:1}]
}

var t3 = {// уровень, где не все блоки стоят на местах
  width: 4, height: 4, player : {}, walls : [], 
  blocks : [{x:1, y:1}, {x:1,y:2}], goals : [{x:2,y:1}, {x:1,y:2}]
}
Для наглядности. Игра - это сетка обычного DHTML, где каждый квадратик - это DIV-элемент. См. ниже.


Первоначально мне бы хотелось выяснить инициализировалось ли вообще мое приложение. Пока все хорошо, раз уж мое приложение попадает в объект window, то я могу обратиться к нему напрямую.
test('Sokoban loaded', function () {
  ok(typeof Sokoban == 'function', 'Sokoban is ready');  
  //это значит, что файл содержащий в себе код игры загрузился и переменная Sokoban попала в объект window
  ok(Sokoban(level), "Sokoban is loaded" );  
  //это значит, что сам код игры отработал для аргумента level (который я беру из предыдущей статьи)
});

Чистые функции и сайд-эффекты

Вообще, имеет смысл понимать что мы тестируем и для чего. Предположим, что в дальнейшем я буду развивать игру, добавлять туда новые элементы. Мне бы хотелось, чтобы изменения никак не повлияли на прежний функционал. Скажем, чтобы при добавлении нового вида блоков, залезая на которые игра заканчивается (т.н. мины), не сломалось вообще ничего из предыдущих функций. 
Поэтому важно разделить, на что стоит тратить усилия и тестировать, а на что не стоит. Так или иначе, предполагается, что я доверяю тем библиотекам, которыми пользуюсь. Например функцию перекрашивающую ячейку карты тестировать нет смысла, потому что там работает только jQuery и по сути это функция которая воспроизводит side-эффект, то есть меняет текущее состояние системы в целом.
Благо, я использовал библиотеку Bacon.js, которая предполагает разделение чистых функций и функций с побочными эффектами, поэтому я могу проигнорировать все ненужные функции, такие как& draw, drawLvl, init, updateDirection, movePlayer, moveBlock, checkVictory, все они (за исключением checkVictory) - полностью функции побочных действий для работы с DOM. Теоретически их можно протестировать, но суть в том, что из схемы:
Данные -> Функция -> Изменение DOM
нужно будет получить схему:
Текущий DOM -> Данные -> Функция -> Данные -> Измененный DOM
Где измененный DOM надо будет сравнивать с ожидаемыми изменениями в DOM, что довольно затруднительно в силу разных реализаций DOM и вообще.
Поскольку всю эту работу выполняет на самом деле jQuery, то мы лишь можем тестировать то насколько верно мы работаем с DOM. В смысле крася ячейку {x,y}, красим ли мы на самом деле ячейку на в строке y и столбце  x, а не наоборот. Однако, это очень затратный процесс, пока я не вижу быстрого способа (без написания лишнего кода) как проверять изменения в DOM, поэтому эти функции я не тестирую, предполагая, что они работают корректно (что так и есть).

У меня есть набор чистых функций isArrows, findDirection, isEmpty, isBlock, blockMove, move, которые и стоит протестировать, т.к. они получают данные, обрабатывают их и выдают данные. Следующий вопрос, как осуществить к ним доступ.

Метод 1:
Сделать их методами класса. Чтобы получить доступ через экземпляр класса.
Вместо
function isArrows(e){return e.keyCode >= 37 && e.keyCode <= 40}
писать
this.isArrows =function(e){return e.keyCode >= 37 && e.keyCode <= 40}
Теперь игру надо инициализировать с помощью new, а не просто вызвав функцию-конструктор. Потом можно обратиться к функции isArrows так:
var x = new Sokoban(level); x.isArrows()
Но если думать наперед, станет очевидным, что это не лучшая стратегия. Поскольку помимо декларативной части программы есть еще и работа с DOM, то скорее всего вызовы методов sokoban произойдут во внутренних замыканиях, где ссылка на переменную this будет контекстом (т.е. объектом window).

Метод 2: Создать переменную контейнер. Чаще всего для этого используется тактика с символической переменной that.Например:
function myClass(x){ 
  that : {
    publicMethod : function(){ ... }
  }
  return that;
}
Однако, я решил собрать все функции, к которым мне бы хотелось осуществить доступ в одну переменную-контейнер являющуюся свойством класса Sokoban:
this.functions = {
  isArrows : function(e) { ... },
  findDirection : function(e) { ... },
  ...
}
И иметь также приватную глобальную для контекста переменной Sokoban переменную:

var f = this.functions;

Теперь, я могу обращаться к любой доступной функции через:

f.isArrows()

И декларативная часть программы становиться следующей:
var f = this.functions;
  var arrowDowns = $(document).asEventStream("keydown").filter(f.isArrows);
  var playerMove =  arrowDowns.map(f.player).map(f.move);
  
  arrowDowns.map(f.findDirection).onValue(updateDirection);
  playerMove.filter(f.isEmpty).onValue(movePlayer);
  playerMove.filter(f.isBlock).map(f.blockMove).map(f.move).filter(f.isEmpty).onValue(moveBlock); 
Теперь можно приступить к написанию тестов:

Первый тест для функции isArrows(e). Напоминаю, что это функция в качестве аргумента берет jQuery-event и смотрит попадает ли свойство keyCode в диапазон 37-40, что соответствует кодам клавиш стрелок.
test("isArrows function", function(){
  ok(!game.functions.isArrows({keyCode:36}), "Correctly for keycode less than 37");
  ok(game.functions.isArrows({keyCode:37}), "Correctly for keycode 37");
  ok(!game.functions.isArrows({keyCode:-13}),"Correctly for keycode less than 0");
  ok(!game.functions.isArrows(undefined), "Correctly for undefined value");
  ok(!game.functions.isArrows({keyCode:undefined}), "Correctly for undefined property");
  ok(!game.functions.isArrows({keyCode:41}), "Correctly for keycode more than 40");
})
QUnit считает тест пройденным, если первый аргумент функции ok не false. Что неудивительно, этот тест будет благополучно провален для для значений undefined и {keyCode:undefined}, так как в функции вовсе не проверяется наличие свойства keyCode у аргумента т.к. предполагается, что все аргументы функции isArrows - это jQuery-события типа keydown, которые имеют свойство keyCode. Если же я настойчиво хочу изменить поведение функции так, чтобы она проходила эти тесты, то я ее перепишу:
isArrows : function(e){return  (e !== undefined) && (e.keyCode !== undefined) ? (e.keyCode >= 37 && e.keyCode <= 40) : false}
Теперь это функция успешно проходит все тесты. В том же духе я пишу тесты для всех остальных функций. Например для функции findDirection, которая каждому keyCode сопоставляет координату движения:
test("findDirection function", function(){
  //здесь переписывать функцию нет смысла, так как мы уже 
  //предполагаем, что она получает только keyCode в нужном диапазоне
  deepEqual(game.functions.findDirection({keyCode:37}), {x:-1,y:0}, "Left");
  deepEqual(game.functions.findDirection({keyCode:38}), {x:0,y:-1}, "Down");
  deepEqual(game.functions.findDirection({keyCode:39}), {x:1,y:0}, "Right");
  deepEqual(game.functions.findDirection({keyCode:40}), {x:0,y:1}, "Up");
  notDeepEqual(game.functions.findDirection({keyCode:20}), {x:0,y:0}, "Any given");
})
Здесь я пользуюсь функцией qUnit deepEqual, а не просто equal т.к. сравниваю сложные переменные т.к. объекты и мне важно, чтобы сошлись все свойства JSON объектов. Приводить все возможные тесты я не буду, они есть в этом коммите.
Единственное, я решил переписать функцию checkVictory, выделив оттуда часть, которая проверяет все ли блоки на своих местах. Для этого я подготавливаю тесты:
test("allInside function", function(){
  ok(!t1.functions.allInside(t1.functions.getLevel()), "Test 1");
  ok(t2.functions.allInside(t2.functions.getLevel()), "Test 2");
  ok(!t3.functions.allInside(t3.functions.getLevel()), "Test 3");
});
Как помните, только в переменной t2 все блоки стоят на целевых местах. Теперь осталось выделить функцию allInside, которая вернет true, если все блоки на целевых местах из checkVictory и засунуть ее в контейнер публичных методов, чтобы ее можно было тестировать.
В целом, на этом тесты заканчиваются. Строго говоря, я выбрал не самую выгодную модель тестирования, я засунул все тестируемые функции в лишнюю переменную, хотя можно было просто расширить Sokoban.prototype, что в общем случае является куда более приемлемой практикой в создании test-driven приложений.

Расширение программы

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

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

По логике вещей, функция генерирующая новую - функция побочных действий. Она раз в n секунд должна эмитить событие, откуда можно вытащить координаты сгенерированной мины.
function generateMineAndRedraw(interval){
  setInterval(function(){
    var genX = ((1 + Math.random()*lvl.width) >> 0) - 1;
    var genY = ((1 + Math.random()*lvl.height) >> 0) - 1;
  
    $container.trigger({
      type : "mineCreated",
      x: genX,
      y: genY
    });
  }, interval);
}
Кроме того нужна функция, которая собственное рисует мину на поле и сохраняет координаты в объекте уровня.
function drawNewMine(e){
  lvl.mines.push(e);
  draw(e, "rgb(216, 18, 18)");
}
А теперь можем заняться игровой логикой:
var mineCreations = $container.asEventStream("mineCreated"); //поток генерации новых мин
var newMineCoordinates = mineCreations.map(f.coordinates); //берем координаты сгенерированных мин
var newMinePlace = newMineCoordinates.filter(f.newMineAllowed); //проверяем можно ли нарисовать по этим координатам мину

var blockMoveMine = blockMove.map(f.move).filter(f.checkCell("mines")); //блок на мине
var playerMoveMine = playerMove.filter(f.checkCell("mines")); //игрок на мине
var loose = blockMoveMine.merge(playerMoveMine); //игрок на мине или блок на мине, тобишь поражение

var victory = blockMove.map(f.move).filter(f.isEmpty).map(moveBlock).filter(f.allInside); //победа

newMinePlace.onValue(drawNewMine); //рисуем мину как она родилась
Здесь присутствуют незнакомые функции, - это coordinates (вытаскивает координаты из события), checkCell(x) (проверяет является ли ячейка типа x, например блоком или миной) и newMineAllowed (что тоже самое, что isEmpty, только, возвращает false для goals и player). Функция newMineAllowed - мало чем отличается от isEmpty, а coordinates возвращает просто {x:e.x, y:e.y}, где е - это аргумент. Я убрал функцию isBlock, которая проверяла является ли ячейка блоком, и заменил на checkCell("blocks") для блоков и checkCell("mines") для мин. Один из важных моментов - это обобщения и создание универсальных функций. У меня есть функция isBlock, но при введении в игру мин, нужна будет и функция isMine. Чтобы в дальнейшем при каждом новом введении в игру не пришлось добавлять новую функцию, я прихожу ко всеобщности этой функции. 
checkCell : function (cell){
  return function(e){
    for(var i=0,l=lvl[cell].length;i<<;i++){
      if( e.x== lvl[cell][i].x && e.y == lvl[cell][i].y )
        return true
    }
    return false;
  }
},
Наконец, теперь переходим к тестам расширенной версии. Как вы можете видеть, смысл того, что у нас были готовы тесты в том, что мы почти не затронули уже тестопригодные функции, хотя добавили новый функционал (не без заслуги Bacon.js конечно).  Но если запустить новую версию игры на тестирование для старых тестов, она даже не доберется до первого теста. Скажет, что не может прочитать свойство length undefined переменной. Дело в том, что она читает length переменной lvl.mines, которой у нас в старой версии не было. При желании с этим можно сделать что угодно, например, переписать инициализирующую функцию, чтобы для всех undefined в lvl записывался пустой массив. Но я просто добавлю в тестовые уровни свойство mines : []
После перезапуска увидим следующее:

















Тест для функции isBlock не прошел. Что логично, ведь я убрал эту функцию из программы. Теперь вместо нее функция checkCell(cell), поэтому я переписываю этот тест для всех возможных типов ячеек, чтобы удостовериться в универсальности моей функции.
В общем виде этот тест выглядит так:
test("checkCell (checking blocks) function", function(){
  ok(!game.functions.checkCell("blocks")({x:0,y:0}), "Correctly for walls");
  ok(!game.functions.checkCell("blocks")({x:1,y:1}), "Correctly for player"); 
  ok(game.functions.checkCell("blocks")({x:3,y:3}), "Correctly for blocks");
  ok(!game.functions.checkCell("blocks")({x:5,y:4}), "Correctly for goals");
  ok(!game.functions.checkCell("blocks")({x:2,y:4}), "Correctly for mines");
})
Чтобы тестировать любой другой тип ячеек, нужно вместо blocks указывать нужный тип.
После чего добавляет тесты для всех типов ячеек (mines, walls, goals), чтобы удостовериться, что в дальнейшем мы можем безбоязно пользоваться этой функцией. Напоследок добавим тесты для функции newMineAllowed. Я бы привел конечно isEmpty и newMineAllowed к общему виду, но к сожалению мне лень уже этим заниматься у меня не хватило времени. Вот собственно и все

Краткий вывод

Строго говоря, приведенный мною пример не показывает во всей красе перечень проблем с которыми сталкивается разработчик пытающийся писать тестопригодный JavaScript-код, такие как попытки тестировать анонимные коллбэки, которые вешаются в качестве хендлеров на всякие события ввода, такие как функции, которые одновременно обрабатывают данные и на их основе производят обширные изменения DOM. У меня все было просто. Я использовал декларативную библиотеку Bacon.js, из за которой мне так и не пришлось столкнуться с большинством этих проблем. С другой стороны, одно лишь то, что декларативный стиль написания и событийно-ориентированная модель помогли мне избавиться, вернее не допустить огромного множества проблем с тестированием кода, довольно недвусмысленно говорит о том, что функциональная, прототипная и событийно-ориентированная природа JavaScript, - это именно то, что нужно для получения благополучного тестопригодно кода, такого, что на создание и поддержку тестов не уходит неприлично много времени.
Никто не говорит, что обычные костыли и велосипеды, изобретаемые чтобы имитировать классическое ООП, или, например, сложные приложения работающие не столько данными, сколько с интерфейсом и так далее, никто не говорит что их нельзя тестировать, но дело в том, что подготовка тестов для такого рода приложений занимает слишком мало времени. Резюмируя могу сказать, что если уж и ломать себе голову тем, что писать тестопригодный JavaScrip то только на NodeJS.

Комментариев нет:

Отправить комментарий