Jasmine.js

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

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

Jasmine — фреймворк, предназначенный для разработки через тестирование (test driven development или TDD) для Javascript, нетребующий для своей работы других фреймворков и даже DOM. Здесь и далее мы будем использовать версию 2.0.0.

Установка

Для того, чтобы тесты у нас проводились автоматически при изменении исходных файлов и результаты тестирования выводились в консоль, а не на отдельной странице в браузере, мы будем использовать Jasmine совместно с Grunt.js и PhantomJS. Если ранее вы не работали с Grunt.js, то рекомендую сперва ознакомиться с ним, например прочитать эту статью здесь же на сайте. PhantomJS — по сути консольный Webkit браузер, который будет интерпретировать наш Javascript код, а также позволит работать с DOM, если нам это будет нужно.

Итак, предполагаем, что Node.js у вас установлен. В таком случае в папке нашего тестового проекта инициализируем новый пакет

npm init

В итоге у вас получится package.json примерно такого вида:

{
  "name": "JasmineTest",
  "version": "0.0.0",
  "description": "A test project to learn jasmine",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "Dmitry Gavrikov",
  "license": "MIT"
}

После этого установим сам Grunt, плагин к нему для отслеживания изменений в файлах grunt-contrib-watch и плагин для тестирования с помощью Jasmine grunt-contrib-jasmine:

npm install grunt grunt-contrib-watch grunt-contrib-jasmine --save-dev

Последняя команда установит для нас не только плагин для Grunt, но и непосредственно Jasmine, и PhantomJS.

Подготовка нового проекта

Создадим в корне проекта две поддиректории: lib — для файлов, которым необходимо тестирование, и spec — для спецификаций, согласно которым тестирование будет происходить. Файлам спецификаций будем присваивать расширение spec.js. Какого-то строгого требования к расширению файлов со спецификациями нет. Достаточно, чтобы это был либо Javascript, либо CoffeeScript файл. Просто удобно, когда файлы спецификаций можно легко отличить от файлов с самим кодом.

В таком случае наш Gruntfile.js будет иметь следующий вид:

module.exports = function(grunt) {

// 1) Project configuration.
grunt.initConfig({
  jasmine: {
    test: {
      src: 'lib/*.js',
      options: {
        specs: 'spec/*.spec.js'
      }
    }
  },

  watch: {
    scripts: {
      files: ['lib/*.js', 'spec/*.spec.js'],
      tasks: ['jasmine'],
      options: {
        livereload: true
      }
    }
  }
});

// 2) Load plugins
grunt.loadNpmTasks('grunt-contrib-jasmine');
grunt.loadNpmTasks('grunt-contrib-watch');

// 3) Task(s) registration
grunt.registerTask('default', ['jasmine']);

};

Запустим Grunt в режиме слежения за файлами:

grunt watch

Спецификации для тестирования

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

Создадим два новых файла lib/calc.js для кода функций и spec/calc.spec.js для спецификации.

Формат спецификации

Спецификая для Jasmine описывается тремя основными функциями: describe, it и expect.

describe имеет следующий вид:

describe('what we are going to describe', function() {

});

Данная функция используется для группировки тестов. Строка 'what we are going to describe', идущая первым параметром, является заголовком группировки. Функции describe можно вкладывать друг в друга. В таком случае мы получаем возможность создавать подзаголовки и создавать четкую иерархию тестов, чтобы даже беглого взгляда в консоль было достаточно, для чего нужен тот или иной тест. Также эта функция позволяет управлять областями видимости.

it имеет вид аналогичный describe:

it('should work the way you want', function() {

});

Функция it описывает тест. Строка, идущая первым аргументом, описывает тест словами, а функция, идущая следом, содержит алгоритм для подтверждения теста.

Алгоритм теста описывается с помощью функции expect. Данная функция принимает какое-то значение и соотносит его с ожидаемым. Соотношение происходит с помощью специальных функций, которые называются matchers. К примеру, простейший алгоритм теста, подтверждающий, что 2 + 2 = 4, будет иметь вид:

expect(2 + 2).toEqual(4)

В данном случае функцией соотношения является toEqual

Теперь напишем спецификацию для нашего калькулятора. Предположим, что его функции имеют следующий вид и записаны в calc.js

function sum(a, b) {
  return a + b;
}

function subtract(a, b) {
  return a - b;
}

function mul(a, b) {
  return a * b;
}

function divide(a, b) {
  return a / b;
}

В таком случае файл со спецификацией для них будет выглядеть так:

describe('Calculator functions', function() {
  it('should have a sum function', function() {
    expect(sum(3, 7)).toEqual(10);
  });

  it('should have a subtract function', function() {
    expect(subtract(10, 7)).toEqual(3);
  });

  it('should have a mul function', function() {
    expect(mul(3, 5)).toEqual(15);
  });

  it('should have a divide function', function() {
    expect(divide(15, 3)).toEqual(5);
  });
});

В консоли мы увидим что-то вроде этого:

>> File "spec/calc.spec.js" changed.
Running "jasmine:test" (jasmine) task
Testing jasmine specs via PhantomJS

 Calculator functions
   ✓ should have a sum function
   ✓ should have a subtract function
   ✓ should have a mul function
   ✓ should have a divide function

4 specs in 0.006s.
>> 0 failures

Значит все тесты прошли успешно и наши функции работают как и задумывалось.

Конкретные цифры для проверки функций мы взяли с потолка. Важно подбирать такие значения, которые бы обрисовывали наиболее общую ситуацию, а не являлись частным случаем. К примеру, 2 + 2 = 4 и 2 * 2 = 4, но это не значит, что результат функций sum и mul всегда равен между собой.

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

Функции соответствия (Matchers)

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

Также перед любой функцией соответствия можно поставить .not для инверсии ее значиния. К примеру

expect(2).not.toEqual(3);

Jasmine предоставляет следующие встроенные функции соответствия:

toMatch(regexp) — принимает в качестве аргумента регулярное выражение и проверяет на соответствие ему строку, которую передали в expect

expect('foo bar').toMatch(/bar/);
expect('foo bar').toMatch('bar');

toBeDefined(), toBeUndefined() — функции без аргументов, которые проверяют является ли значение undefined или нет.

expect(10).toBeDefined();
expect(undefined).not.toBeDefined();
expect(undefined).toBeUndefined();

toBeNull() — функция без аргументов, которая проверяет, является ли значение равным null

expect(null).toBeNull();

toBeNaN() — функция без аргументов, которая проверяет, является ли значение NaN

expect(NaN).toBeNaN();

toBeTruthy(), toBeFalsy() — функции, которые проверяеют являются ли значения равными true и false соответственно.

expect(true).toBeTruthy();
expect(false).toBeFalsy();

toContain(value) — проверяет, содержит ли строка подстроку, содержащуюся в аргументе, а также содержит ли массив элемент, содержащийся в аргументе.

expect('foo bar').toContain('bar');
expect([1, 5, 9]).toContain(5);

toBeGreaterThan(value), toBeLessThan(value) — функции, проверяющие значения на неравенство.

expect(11).toBeGreaterThan(10);
expect(10).toBeLessThan(11);

toBeCloseTo(value, precision) — функция, проверяющая значение на равенство со значением value с учетом precision знаков после запятой (по умолчанию равно 2). При этом округления не происходит. Проверяется именно близость значений. Числа являются близкими, если разница между ними меньше 5 единиц меньшего чем precision порядка. Таким образом

expect(3.21).toBeCloseTo(3.26, 1);
expect(3.21).not.toBeCloseTo(3.27, 1);

toThrow() — функция, проверяющая, пробросила ли исходная функция исключение

var foo = function() {
  return 1 + 2;
};

var bar = function() {
  return a + 1; // a is undefined
};

expect(foo).not.toThrow();
expect(bar).toThrow();

toThrowError() — функция, проверяющая, пробросила ли исходная функция исключение, являющееся instanceof Error

var foo = function() {
  return 1 + 2;
};

var bar = function() {
  return a + 1; // a is undefined
};

expect(foo).not.toThrowError();
expect(bar).toThrowError();

Подготовка к тесту и постобработка

Часто бывает необходимо выполнять одни те же действия перед каждым тестом. К примеру, если мы тестируем класс, то нам необходимо перед каждым тестом создавать новый экземпляр класса, а не использовать один и тот же, так как в противном случае предыдущие тесты могли что-то поменять в этом объекте, что скажется на результатах теста.

Для удобства, и чтобы не создавать повторяющиеся куски кода, существуют функции beforeEach и afterEach. Согласно своим названиям, beforeEach вызывается перед каждым вызовом функции it, а afterEach — после.

Переделаем наш код так, чтобы у нас были не отдельные функции, а класс Calc, который бы их вызывал. Тогда calc.js примет вид:

function Calc() {
}

Calc.prototype.sum = sum;
Calc.prototype.subtract = subtract;
Calc.prototype.mul = mul;
Calc.prototype.divide = divide;

function sum(a, b) {
  return a + b;
}

function subtract(a, b) {
  return a - b;
}

function mul(a, b) {
  return a * b;
}

function divide(a, b) {
  return a / b;
}

Приведем нашу спецификацию в соответствие с этими изменениями:

describe('Calc class', function() {
  var calc;

  beforeEach(function() {
    calc = new Calc;
  });

  it('should have a sum function', function() {
    expect(calc.sum(3, 7)).toEqual(10);
  });

  it('should have a subtract function', function() {
    expect(calc.subtract(10, 7)).toEqual(3);
  });

  it('should have a mul function', function() {
    expect(calc.mul(3, 5)).toEqual(15);
  });

  it('should have a divide function', function() {
    expect(calc.divide(15, 3)).toEqual(5);
  });
});

Контекст this в функциях beforeEach, it, afterEach

Во всех этих функциях this ссылается на один и тот же пустой объект, который обнуляется для каждого нового теста. Это позволяет легко переносить данные между этими функциями:

describe("A spec", function() {
  beforeEach(function() {
    this.foo = 0;
  });

  it("can use the `this` to share state", function() {
    expect(this.foo).toEqual(0);
    this.bar = "test pollution?";
  });

  it("prevents test pollution by having an empty `this` created for the next spec", function() {
    expect(this.foo).toEqual(0);
    expect(this.bar).toBe(undefined);
  });
});

Отключение тестов

Бывают ситуации, когда необходимо временно отключить какие-то тесты или их группы. Можно в таком случае просто закоментировать отдельные блоки кода, но в Jasmine есть встроенное средство — функции xdescribe и xit. Добавив лишь одну букву, мы можем перевести тесты в состояние pending. В этом состоянии тест не проводится, но Jasmine о нем не забывает, как в случае, если бы мы просто закоментировали код. Pending тесты остаются в списке вывода в консоли, но перед ними появляется звездочка *. В таком случае вы не забудете, что какой-то тест у вас не выполняется. Если же он вам не нужен, то правильнее его удалить совсем, чем закоментировать и забыть.

describe('Calc class', function() {
  var calc;

  beforeEach(function() {
    calc = new Calc;
  });

  xit('should have a sum function', function() {
    expect(calc.sum(3, 7)).toEqual(10);
  });

  it('should have a subtract function', function() {
    expect(calc.subtract(10, 7)).toEqual(3);
  });

  xit('should have a mul function', function() {
    expect(calc.mul(3, 5)).toEqual(15);
  });

  it('should have a divide function', function() {
    expect(calc.divide(15, 3)).toEqual(5);
  });
});

Консоль:

>> File "spec/calc.spec.js" changed.
Running "jasmine:test" (jasmine) task
Testing jasmine specs via PhantomJS

 Calc class
    * should have a sum function
    ✓ should have a subtract function
    * should have a mul function
    ✓ should have a divide function

4 specs in 0.006s.
>> 0 failuresd

Прочие возможности Jasmine

Мы рассмотрели основные, но не все встроенные возможности Jasmine. Есть возможность подменять тестируемые функции на другие (как правило для ускорения процесса тестирования), проверять принадлежность экземпляра класса к конкретному классу или проверять частичное совпадение свойств и значений объектов, поддержка тестирования функций, использующих в своем коде отсроченное выполнение через setTimeout, setInterval, асинхронный запуск тестов и т.п.

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

jasmine-jquery — расширение для Jasmine

Мы рассмотрели, как можно тестировать Javascript, который не касается DOM. Но что делать, когда коду необходимо управлять DOM? Ведь такая необходимость случается постоянно. Можно попытаться обойтись встроенными методами Jasmine, но есть более удобный способ — расширение jasmine-jquery.

На самом деле jasmine-jquery — это два расширения вместе. Первое предоставляет дополнительные функции соответствия (matchers) для работы с DOM, а второе — позволяет управлять песочницами (fixtures) со вставками HTML, CSS и JSON.

Установка

Есть несколько способов установки этого расширения. На странице разработчиков предлагается либо вручную скачать и подключить ее, либо установить как gem для Ruby on Rails. Мы же установим его через Bower, так как Node.js у вас уже точно есть, RoR может не быть, а устанавливать зависимости вручную — не самое благодарное занятие.

Если Bower у вас еще не установлен, то необходимо его установить. Он устанавливается глобально, а значит у вас должны быть права root под Mac или Linux или консоль должна быть запущена с правами администратора под Windows.

npm install -g bower

Далее в папке с нашим проектом выполняем инициализацию пакета Bower через

bower init

Что вы ответите на вопросы, не суть важно. Затем устанавливаем jquery и jasmine-jquery как зависимости через Bower.

bower install jquery jasmine-jquery --save-dev

Теперь необходимо подключить их к самому Jasmine. Для этого в Gruntfile.js вносим изменения:

module.exports = function(grunt) {

  // 1) Project configuration.
  grunt.initConfig({
    jasmine: {
      test: {
        src: 'lib/*.js',
        options: {
          // добавляем ссылку на стороннюю библиотеку
          vendor: [
            'bower_components/jquery/dist/jquery.js',
            'bower_components/jasmine-jquery/lib/jasmine-jquery.js'
          ],
          // конец изменений
          specs: 'spec/*.spec.js'
        }
      }
    },

    watch: {
      scripts: {
        files: ['lib/*.js', 'spec/*.spec.js'],
        tasks: ['jasmine'],
        options: {
          livereload: true
        }
      }
    }
  });

  // 2) Load plugins
  grunt.loadNpmTasks('grunt-contrib-jasmine');
  grunt.loadNpmTasks('grunt-contrib-watch');

  // 3) Task(s) registration
  grunt.registerTask('default', ['jasmine']);

};

Все, теперь можно пользоваться.

Функции соответствия (matchers) jasmine-jquery

Полный перечень функций с примерами находится на странице проекта. Переписывать их сюда, не вижу смысла. Для того чтобы понять, что делают большинство из них, достаточно посмотреть на их названия или на приводимые на сайте примеры: toBeInDOM, toBeVisible, toHaveClass, toHaveId, toHaveProp, toHaveValue, toHaveCss, toBeChecked, toBeFocused, toBeSelected, toContainText, toContainElement. Аналогично встроенным функциям соответствия, все они могут быть инвертированы с помощью .not.

Более интересными являются функции слежения за событиями. Для этого создаются «шпионы» за событиями (event spies). Рассмотрим на примере:

var $el = $('<div id="elem"></div>'); // создали новый элемент
var spyEvent = spyOnEvent($el, 'click'); // создали шпиона передав ему элемент первым аргументом, тип события вторым
$el.click(); // вызвали событие
expect('click').toHaveBeenTriggeredOn($el); // проверили, что событие 'click' было вызвано на элементе $el
expect(spyEvent).toHaveBeenTriggered(); // шпион отчитался о том, что событие, за которым ему было поручено следить, было вызвано на отслеживаемом элементе

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

Вместо $el могло быть любое CSS выражение, если бы наш элемент находился бы в DOM. Например, в таком случае:

var $el = $('<div id="elem"></div>'); // создали новый элемент
$('body').append($el);
var spyEvent = spyOnEvent('#elem', 'click'); // создали шпиона передав ему элемент первым аргументом, тип события вторым
$el.click(); // вызвали событие
expect('click').toHaveBeenTriggeredOn('#elem'); // проверили, что событие 'click' было вызвано на элементе $el
expect(spyEvent).toHaveBeenTriggered(); // шпион отчитался о том, что событие, за которым ему было поручено следить, было вызвано на отслеживаемом элементе

Состояние отслеживаемых событий можно сбросить:

var $el = $('<div id="elem"></div>'); // создали новый элемент
var spyEvent = spyOnEvent($el, 'click'); // создали шпиона передав ему элемент первым аргументом, тип события вторым
$el.click(); // вызвали событие
expect('click').toHaveBeenTriggeredOn($el); // проверили, что событие 'click' было вызвано на элементе $el
expect(spyEvent).toHaveBeenTriggered(); // шпион отчитался о том, что событие, за которым ему было поручено следить, было вызвано на отслеживаемом элементе
spyEvent.reset(); // сбросили состояние
expect('click').not.toHaveBeenTriggeredOn($el); // проверили, что событие 'click' НЕ было вызвано на элементе $el
expect(spyEvent).not.toHaveBeenTriggered(); // шпион отчитался о том, что событие, за которым ему было поручено следить, НЕ было вызвано на отслеживаемом элементе

Аналогичным способом проверяется, было ли поведение по умолчанию при наступлении события предотвращено или его всплытие было остановлено. Делается это с помощью функций toHaveBeenPreventedOn, toHaveBeenPrevented и toHaveBeenStoppedOn, toHaveBeenStopped.

Вставки (fixtures)

До сих пор мы толком не касались DOM. Можно создавать элементы в jQuery и вручную вставлять их в DOM, но это не удобно, если нам необходимо работать с большим количеством элементов со сложной иерархией. При разработке клиентской части в таких случаях обычно применяют шаблоны, например Underscore или Handlebars. Действительно, было бы удобно написать HTML и CSS код и использовать его как некий полигон в наших тестах. Именно такую возможность дают нам песочницы (fixtures).

Вставка HTML

Основными функциями для работы с песочницами, которые нам доступны в тестах, являются setFixtures, loadFixtures, appendLoadFixtures , readFixtures, appendSetFixtures.

setFixtures(html) — функция, которая принимает в качестве аргумента либо строку с кодом HTML, либо элемент созданный jQuery и создает песочницу с этим кодом.

При вставке кода, он вставляется не напрямую в тег body, как можно было бы подумать, а в div с id="jasmine-fixtures". Между тестами этот контейнер очищается, что делает тесты полностью независимыми между собой, и можно не волноваться, что после предыдущего теста что-то осталось, что может повлиять на последующие.

Если доработать предыдущий пример с отслеживанием событий и применить песочницу, то получим следующий код:

describe('jquery-jasmine plugin', function() {
  beforeEach(function() {
    setFixtures('<div id="elem">Hello World!</div>');
  });

  it('should be able to watch events', function() {
    spyOnEvent('#elem', 'click');
    $('#elem').click();
    expect('click').toHaveBeenTriggeredOn('#elem');
  });
});

loadFixtures(fixtureUrl[, fixtureUrl, ...]) — тоже самое что и setFixtures, но загружает HTML код из файла. По умолчанию поиск осуществляется в spec/javascript/fixtures, но мы можем это исправить, если необходимо, указав явно директорию с кодом посредством jasmine.getFixtures().fixturesPath.

fixture/my_fixture.html

<div id="elem">Hello World!</div>

spec/jquery_jasmine_test.spec.js

jasmine.getFixtures().fixturesPath = 'fixture';

describe('jquery-jasmine plugin', function() {
  beforeEach(function() {
    loadFixtures('my_fixture.html');
  });

  it('should be able to watch events', function() {
    spyOnEvent('#elem', 'click');
    $('#elem').click();
    expect('click').toHaveBeenTriggeredOn('#elem');
  });
});

В данном случае вставляемый код находится в директрии fixtures. Путь начинает отсчет от Gruntfile.js, а не от файла со спецификацией.

appendLoadFixtures(fixtureUrl[, fixtureUrl, ...]) — тоже самое, что и loadFixtures, только не переопределяет песочницу, а добавляет в нее код подобно функции jQuery append.

fixture/my_fixture.html

<div id="elem">Hello World!</div>

fixture/some_other_fixture.html

<div id="another-elem">Hi there!</div>

spec/jquery_jasmine_test.spec.js

jasmine.getFixtures().fixturesPath = 'fixture';

describe('jquery-jasmine plugin', function() {
  beforeEach(function() {
    loadFixtures('my_fixture.html');
  });

  it('should be able to watch events', function() {
    spyOnEvent('#elem', 'click');
    $('#elem').click();
    expect('click').toHaveBeenTriggeredOn('#elem');
  });

  it('should be able to append new html to the sandbox', function() {
    appendLoadFixtures('some_other_fixture.html'); // добавляем в конец нашей песочницы новый код из файла
    expect($('#elem + div')).toEqual('#another-elem'); // проверяем, что наш новый код успешно добавлен в DOM
  });
});

Обратите внимание, что в фукнцию toEqual мы передаем не сам элемент, а CSS селектор. Это возможно благодаря тому, что плагин jasmine-jquery расширяет поведение стандартной функции. Если бы его не было подключено, то у нас была бы проверка на равенство со строкой '#another-elem'. С другой стороны, в expect мы передаем не строку с селектором, а обертку jQuery с элементом, так как expect не поддерживает селекторы.

readFixtures(fixtureUrl[, fixtureUrl, ...]) — функция, загружающая код из файла, но в отличие от loadFixtures не вставляет его в DOM, а возвращает код в виде строки. Это может быть полезно, если мы хотим проверить код напрямую, и нам не важно, находится ли он в DOM.

fixture/my_fixture.html

<div id="elem">Hello World!</div>

spec/jquery_jasmine_test.spec.js

jasmine.getFixtures().fixturesPath = 'fixture';

describe('jquery-jasmine plugin', function() {
  var myFixture;

  beforeEach(function() {
    myFixture = readFixtures('my_fixture.html'); // загрузили html в виде строки
  });

  it('should be able to process html as a string', function() {
    expect(myFixture).toContainText('Hello World!'); // проверили, что в коде есть искомая подстрока
    expect($(myFixture)).toEqual('div#elem'); // превратили строку в элемент и удостоверились, что имеем дело с нашим элементом
  });
});

appendSetFixtures(html) — тоже самое что и setFixtures, только не переопределяет песочницу, а добавляет в нее код подобно функции jQuery append.

Вставка CSS

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

Загрузка стилей очень похожа на загрузку HTML. По умолчанию файлы со стилями ищутся в директории spec/javascripts/fixtures, но это можно исправить прописав путь в jasmine.getStyleFixtures().fixturesPath. Точно также как и в случае с HTML, CSS код обнуляется между тестами, что позволяет проводить полностью независимые тесты.

Для подгрузки стилей используются следующие функции:

loadStyleFixtures(fixtureUrl[, fixtureUrl, ...]) — подгружает стили, вставляя их в DOM внутрь тега head, а также удаляет другие стили, если они были загружены до этого.

fixture/my_fixture.css

#elem {
  position: absolute;
  top: 100px;
}

fixture/my_fixture.html

<div id="elem">Hello World!</div>

spec/jquery_jasmine_test.spec.js

jasmine.getFixtures().fixturesPath = 'fixture';
jasmine.getStyleFixtures().fixturesPath = 'fixture';

describe('jquery-jasmine plugin', function() {
  beforeEach(function() {
    loadFixtures('my_fixture.html');
    loadStyleFixtures('my_fixture.css');
  });

  it('should be able to load CSS', function() {
    expect('#elem').toHaveCss({top: '100px'});
  });
});

appendLoadStyleFixtures(fixtureUrl[, fixtureUrl, ...]) — тоже самое что и loadStyleFixtures, только не удаляет предыдущие стили.

setStyleFixtures(css) — тоже самое что и loadStyleFixtures, только не загружает стили из файла, а создает их из передаваемой строки. Также удаляет любые стили, которые были заданы до ее вызова.

fixture/my_fixture.html

<div id="elem">Hello World!</div>

spec/jquery_jasmine_test.spec.js

jasmine.getFixtures().fixturesPath = 'fixture';

describe('jquery-jasmine plugin', function() {
  beforeEach(function() {
   loadFixtures('my_fixture.html');
   setStyleFixtures('#elem {position: absolute; top: 100px;}');
  });

  it('should be able to load CSS', function() {
    expect('#elem').toHaveCss({top: '100px'});
    $('#elem').css('top', '300px');
    expect('#elem').toHaveCss({top: '300px'});
  });

  it('should be able to load CSS twice', function() {
    expect('#elem').toHaveCss({top: '100px'});
  })
});

appendSetStyleFixtures(css) — тоже самое что setStyleFixtures, только не удаляет стили, которые были заданы до ее вызова.

Прочие возможности jquery-jasmine

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

Разработка через тестирование (Test Driven Development)

Все что мы выше рассмотрели отвечало на вопрос «Как?». Однако, мы совсем не нашли ответа на вопрос «Зачем?». Действительно, зачем нужно заниматься автоматическим тестированием? Если бы это было нужно всем и каждому, то все бы этим занимались и изучали бы параллельно тому же jQuery, к примеру. Но этого не происходит. Многие разработчики начинают интересоваться тестированием только на определенном этапе своего развития. Почему? Потому что выгоды для многих людей неочевидны. Тесты не попадают в итоговый код, за который платит заказчик, но при этом отнимают время.

Обычно ответ на вопрос «Зачем?» дают в самом начале, но понять, какие плюсы дает тестирование, намного проще, сперва поняв, как оно происходит.

Разработка через тестирование (Test Driven Development) — техника разработки, которая переворачивает все с ног на голову — сперва пишется тест, а уже после этого — код, способный пройти этот тест.

Если быть более точным, то эта техника операется на очень короткие циклы разработки.
1) Пишется требование к будущему коду, что мы от него хотим, в виде теста.
2) Запускается тестирование. Убеждаемся, что тест провален (если тест проходит всегда, то он бесполезен).
3) Пришло время написать код, который пройдет тест. На данном этапе это может быть далеко не самая удачная реализация, главное, чтобы задача, поставленная в тесте, была решена.
4) Повторно запускаем тестирование. Если все тесты пройдены успешно, то двигаемся дальше. Если какой-то из тестов провален, то возвращаемся к коду и исправляем ошибки.
5) Рефакторинг. Смотрим, можно ли улучшить написанный код. Это относится как к повышению читаемости кода, так и к повышению производительности (к примеру, можно сократить количество обращений к DOM).
6) Снова запускается тестирование. Убеждаемся, что рефакторинг ничего не поломал. В противном случае, возвращаемся к коду и исправляем ошибки.

Какие преимущества дает данный подход? Их довольно много:

1) Написание тестов создает новый слой абстракции. Разработчик может не отвлекаться на размышления над реализацией решения на этапе планирования. Это существенно облегчает разработку архитектуры будущего приложения. Архитектор оперирует лишь текстовым описанием тестов, прорабатывая весь функционал ПО еще до его написания.

2) У разработчиков есть четкий план работы. Если тестами покрыта вся функциональность приложения, то чтобы завершить проект необходимо, чтобы код прошел все тесты. При примерно равной степени сложности тестов количество пройденных тестов можно использовать для рассчета процента завершенности проекта.

3) Работа дробится на отдельные независимые друг от друга (при хорошей архитектуре) куски. Это позволяет привлекать к работе большее количество специалистов без риска, что они будут мешать друг другу.

4) Облегчение рефакторинга. Очень часто от рефакторинга отказываются вовсе по причине того, что никто не уверен, что не появятся новые ошибки. В итоге плохой код остается без изменений просто потому, что его боятся трогать. Тестирование же позволяет быть уверенным, что весь функционал покрытый тестами, а в случае с разработкой через тестирование это 100%, продолжает работать.

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

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

7) Так как весь функционал расписан заранее, предотвращается появление блоков кода со схожим функционалом, что помогает соблюдать принцип DRY.

8) Нацеленность на написание кода, который должен лишь пройти тест, позволяет выбирать наиболее простое и очевидное решение задачи, что известно как принцип KISS. Это ускоряет разработку и помогает писать более понятный код.

9) Нацеленность на решение задач только представленных в тестах позволяет заниматься реализацией лишь того функционала, который нужен, что также известно как принцип YAGNI. В результате опять же ускоряем разработку, так как не тратим время на функционал, который не был запланирован, а также уменьшаем поле для возникновения ошибок — если нет кода, то нет и ошибок, которые он может создать.

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

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

12) Тесты могут служить документацией. Хуже отсутствия документации может быть только неактуальная документация. Тесты же актуальны всегда. Обратите внимание на вывод консоли, когда мы тестировали наш объект-калькулятор.

>> File "spec/calc.spec.js" changed.
Running "jasmine:test" (jasmine) task
Testing jasmine specs via PhantomJS

 Calc class
    * should have a sum function
    ✓ should have a subtract function
    * should have a mul function
    ✓ should have a divide function

4 specs in 0.006s.
>> 0 failuresd

Очень напоминает документацию по классу Calc, не правда ли?. При этом отложенные (pending) тесты, показывают, что этот функционал еще либо не реализован, либо содержит ошибки. Если открыть код теста, то можно посмотреть примеры применения кода. Так как тест пишется заранее, то мы имеем 100% покрытие тестами функционала приложения, а значит имеем примеры применения всего этого функционала.

Получили довольно внушительный список плюсов, но есть ли у этого подхода минусы? Как всегда без недостатков не обошлось:

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

2) Написание тестов — время потраченное на написание кода, который не попадет в конечный продукт, а это дополнительные расходы. Поэтому не стоит писать тесты для тривиальных решений. Также положительный эффект от подхода тем больше, чем крупнее проект. Поэтому для мелких и простых проектов тестирование лишь повысит затраты.

3) Прохождение тестов не гарантирует отсутствия ошибок в коде, но при этом дает ложное ощущение безопасности. Ошибки в тесте могут привести к тому, что в прохождение теста окажется ложным. К тому же плохое планирования теста может привести к тому, что тест не покрывает всех частных случаев, которые могут наступить. А потому …

4) Автоматическое тестирование не отменяет ручное тестирование. Вам все равно придется смотреть на то, как ведет себя ваш код в реальных условиях.

5) Подход не защищает от неправильной интерпретации разработчиком описания теста. Если алгоритм теста и конечный код пишет один и тот же человек, то в таком случае будет реализован неверный функционал, который при этом пройдет все тесты или наоборот вызовет неожиданные сбои в прохождении других тестов, тестируемый код которых опирается на этот функционал.

6) Не существует единого мнения по поводу того, достаточно ли проводить тестирование лишь через публичные методы или частные методы и состояние частных переменных также необходимо контролировать. В Javascript нет возможности получить доступ к частным методам и свойствам, так как они спрятаны в замыканиях. В таком случае желание повысить покрытие тестами увеличит количество публичных методов и свойств, что ухудшит качество кода. Либо придется делать их публичными только на время тестирования, но в таком случае получится, что код, который тестируется, отличается от того, который будет внедряться, что противоречит основной идее тестирования.

Ответ на вопрос, стоит ли разрабатывать через тестирование, во многом зависит от размера и сложности проекта. Чем крупнее и сложнее проект, тем больше пользы будет от этого подхода, а негативные стороны будут менее значительными. Но даже если вы его не будете применять, умение писать код, который легко тестируется, применение принципов DRY, KISS и YAGNI, хорошо продуманная заранее архитектура приложения, т.е. все то чему способствует применение разработки через тестирование, поможет писать качественный код независимо от размеров и сложности проекта.