В этом руководстве мы разработаем приложение Backbone.js и протестируем его с помощью Jasmine . Не достаточно хорош для вас? Мы сделаем все это с помощью CoffeeScript. Trifecta!
Мы собираемся работать над приложением изолированно — используя статическую безсерверную среду. Это имеет несколько преимуществ:
- Тестирование и запуск кода очень быстро.
- Отделение нашего приложения Backbone от серверной части делает его еще одним клиентом. Например, мы можем создать мобильное приложение, которое будет использовать тот же API.
Нашим тестовым приложением будет простой веб-сайт, на котором мы можем управлять базой данных, содержащей не более, чем рестораны
Начальный Boilerplate
Для начала нам нужно переместить несколько частей на место. Просто скачайте этот архив , содержащий:
- Backbone.js , версия 0.9.2
- Жасмин версия 1.2.0
- Jasmine-jQuery , чтобы легко загружать HTML-приборы в наших тестах
- Twitter Bootstrap для некоторых базовых стилей
- Hogan.js для компиляции шаблонов усов
- Проверка Backbone, расширение Backbone, которое позволяет очень легко добавлять
правила валидации для модели Backbone - JQuery для базовых манипуляций с DOM
Есть также два HTML-файла: index.html
и SpecRunner.html
. Первый показывает, что наше приложение работает, а второй — наши спецификации Jasmine.
Давайте проверим нашу настройку, запустив приложение через веб-сервер. Для этого есть различные варианты, но я обычно полагаюсь на очень простую команду Python (доступную в OsX):
1
|
python -m SimpleHTTPServer
|
Backbone предоставляет хороший API для определения событий в рамках определенного представления.
Затем перейдите в браузере по http://localhost:8000/index.html
, и вы должны увидеть сообщение с поздравлением. Также откройте http://localhost:8000/SpecRunner.html
; страница должна содержать образец спецификации, работающей зеленым цветом.
Вы также должны найти Cakefile
в корневом каталоге. Это очень простой файл CoffeeScript, который вы можете использовать для автоматической компиляции всех файлов .coffee
которые мы собираемся записать. Предполагается, что у вас установлен CoffeeScript как глобально доступный модуль Node, и вы можете обратиться к этой странице за инструкциями. Кроме того, вы можете использовать такие инструменты, как CodeKit или Livereload для достижения того же результата.
Чтобы запустить задачу для торта, просто наберите « cake compile
. Эта задача будет продолжаться. Вы можете следить за изменениями при каждом сохранении, но вам может потребоваться перезапустить скрипт, если вы добавите новые файлы.
Шаг 1 — Модель ресторана
Пространства имен
Использование Backbone означает, что мы собираемся создавать модели, коллекции и представления. Поэтому наличие пространства имен для их организации является хорошей практикой, и мы можем сделать это, создав файл приложения и соответствующую спецификацию:
1
2
|
touch javascript/app.coffee
touch javascript/spec/app_spec.coffee
|
Спецификационный файл содержит только один тест:
1
2
3
4
|
describe «App namespace», ->
it «should be defined», ->
expect(Gourmet).toBeDefined()
|
Переключившись на файл javascript/app.coffee
, мы можем добавить следующее объявление пространства имен:
1
2
3
4
|
window.Gourmet =
Models: {}
Collections: {}
Views: {}
|
Далее нам нужно добавить файл приложения в index.html
:
1
2
3
|
…
<script type=»text/javascript» src=»/javascript/app.js»></script>
…
|
Нам нужно сделать то же самое в SpecRunner.html
, но на этот раз для приложения и спецификации:
1
2
3
4
5
6
|
<!— lib —>
<script type=»text/javascript» src=»/javascript/app.js»></script>
<!— specs —>
<script type=»text/javascript» src=»/javascript/spec/toolchain_spec.js»></script>
<script type=»text/javascript» src=»/javascript/spec/app_spec.js»></script>
|
Повторите это для каждого файла, который мы создаем с этого момента.
Основные атрибуты
Основным объектом нашего приложения является ресторан, определяемый следующими атрибутами:
- имя
- почтовый индекс
- рейтинг (от 1 до 5)
Поскольку добавление большего количества атрибутов не обеспечит никаких преимуществ в рамках данного учебного пособия, сейчас мы можем просто работать с этими тремя.
Давайте создадим модель Restaurant
и соответствующий файл спецификации:
1
2
3
4
|
mkdir -p javascript/models/
mkdir -p javascript/spec/models/
touch javascript/models/restaurant.coffee
touch javascript/spec/models/restaurant_spec.coffee
|
Теперь мы можем открыть оба файла и добавить некоторые основные спецификации в restaurant_spec.coffee
, показанные здесь:
01
02
03
04
05
06
07
08
09
10
11
12
13
|
describe «Restaurant Model», ->
it «should exist», ->
expect(Gourmet.Models.Restaurant).toBeDefined()
describe «Attributes», ->
ritz = new Gourmet.Models.Restaurant
it «should have default attributes», ->
expect(ritz.attributes.name).toBeDefined()
expect(ritz.attributes.postcode).toBeDefined()
expect(ritz.attributes.rating).toBeDefined()
|
Тест очень прост:
- Мы проверяем, что класс
Restaurant
существует. - Мы также проверяем, что новый экземпляр
Restaurant
всегда инициализируется с настройками по умолчанию, которые отражают наши требования.
Обновление /SpecRunner.html
покажет сбой спецификации. Теперь давайте реализуем models/restaurant.coffee
. Это еще короче
1
2
3
4
5
6
|
class Gourmet.Models.Restaurant extends Backbone.Model
defaults:
name: null
postcode: null
rating: null
|
Магистраль позаботится об отправке правильных запросов Ajax.
Нам просто нужно создать класс в пространстве имен window
чтобы сделать его доступным глобально — мы будем беспокоиться о пространстве имен во второй части. Теперь наши спецификации должны пройти. Обновите /SpecRunner.html
, и спецификации должны пройти.
Validations
Как я уже говорил, мы будем использовать магистральные проверки для проверки на стороне клиента. Давайте добавим новый блок describe
в models/restaurant_spec.coffee
чтобы выразить наши ожидания:
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
|
describe «Restaurant Model», ->
…
describe «Validations», ->
attrs = {}
beforeEach ->
attrs =
name: ‘Ritz’
postcode: ‘N112TP’
rating: 5
afterEach ->
ritz = new Gourmet.Models.Restaurant attrs
expect(ritz.isValid()).toBeFalsy()
it «should validate the presence of name», ->
attrs[«name»] = null
it «should validate the presence of postcode», ->
attrs[«postcode»] = null
it «should validate the presence of rating», ->
attrs[«rating»] = null
it «should validate the numericality of rating», ->
attrs[«rating»] = ‘foo’
it «should not accept a rating < 1», ->
attrs[«rating»] = 0
it «should not accept a rating > 5», ->
attrs[«rating»] = 6
|
Мы определяем пустой объект атрибутов, который будет изменяться при каждом ожидании. Каждый раз мы устанавливаем только один атрибут с недопустимым значением, тем самым проверяя тщательность наших правил проверки. Мы также можем использовать блок afterEach
чтобы избежать большого количества повторений. Запуск наших спецификаций покажет 6 сбоев. Еще раз, у нас есть чрезвычайно краткая и удобочитаемая реализация благодаря проверкам Backbone:
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
|
class Gourmet.Models.Restaurant extends Backbone.Model
defaults:
name: null
postcode: null
rating: null
validate:
name:
required: true
postcode:
required: true
rating:
required: true
type: ‘number’
min: 1
max: 5
|
Наши спецификации теперь пройдут, и с этими изменениями у нас будет довольно солидная модель ресторана.
Коллекция ресторанов
Поскольку мы хотим управлять списком ресторанов, имеет смысл иметь класс RestaurantsCollection
. Мы еще не знаем, насколько это должно быть сложно; Итак, давайте сосредоточимся на минимальных требованиях, добавив новый блок describe
в файл models/restaurant_spec.coffee
:
01
02
03
04
05
06
07
08
09
10
11
|
describe «Restaurant model», ->
…
describe «Restaurants collection», ->
restaurants = new Gourmet.Collections.RestaurantsCollection
it «should exist», ->
expect(Gourmet.Collections.RestaurantsCollection).toBeDefined()
it «should use the Restaurant model», ->
expect(restaurants.model).toEqual Gourmet.Models.Restaurant
|
Backbone предоставляет обширный список методов, уже определенных для коллекции, поэтому наша работа здесь минимальна. Мы не хотим тестировать методы, определенные платформой; Итак, мы просто должны убедиться, что коллекция использует правильную модель. Что касается реализации, мы можем добавить следующие несколько строк в models/restaurant.coffee
:
1
2
3
|
class Gourmet.Collections.RestaurantsCollection extends Backbone.Collection
model: Gourmet.Models.Restaurant
|
Ясно, что CoffeeScript и Backbone — очень мощная команда, когда речь заходит о ясности и краткости. Давайте повторно запустим наши спецификации, чтобы убедиться, что все в порядке.
Шаг 2 — Просмотр ресторанов
Разметка
До сих пор мы даже не смотрели на то, как мы собираемся отображать или взаимодействовать с нашими данными. Мы сделаем это визуально простым и сосредоточимся на двух действиях: добавление и удаление ресторана из списка.
Благодаря Bootstrap мы можем легко добавить некоторую базовую разметку, которая приводит к прилично выглядящей таблице прототипов. Давайте откроем файл index.html
и добавим следующее содержимое тела:
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
|
<div class=»container»>
<div class=»navbar»>
<div class=»navbar-inner»>
<div class=»container»>
<a href=»#» class=»brand»>Awesome restaurants</a>
</div>
</div>
</div>
<div class=»container»>
<div class=»row»>
<div class=»span4″>
<form action=»#» class=»well form-horizontal» id=»restaurant-form»>
<div class=»control-group»>
<label for=»restaurant_name»>Name</label>
<input type=»text» name=»restaurant[name]» id=»restaurant_name» />
<span class=»help-block»>Required
</div>
<div class=»control-group»>
<label for=»restaurant_rating»>Rating</label>
<input type=»text» name=»restaurant[rating]» id=»restaurant_rating» />
<span class=»help-block»>Required, only a number between 1 and 5
</div>
<div class=»control-group»>
<label for=»restaurant_postcode»>Postcode</label>
<input type=»text» name=»restaurant[postcode]» id=»restaurant_postcode» />
<span class=»help-block»>Required
</div>
<input type=»button» class=»btn btn-primary» value=»Save» id=»save»/>
</form>
</div>
<div class=»span8″>
<table class=»table» id=»restaurants»>
<thead>
<tr>
<th>Name</th>
<th>Postcode</th>
<th>Rating</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
</div>
</div>
|
Что нас действительно волнует, так это #restaurant-form
и #restaurants
table. Входные элементы используют традиционный шаблон для своих имен ( entity[attribute]
), что делает их легко обрабатываемыми большинством внутренних сред (особенно Rails). Что касается таблицы, мы оставляем tbody
пустым, так как мы будем отображать содержимое на клиенте с помощью Hogan. Фактически, мы можем добавить шаблон, который будем использовать, прямо перед всеми другими тегами <script>
в <head>
.
01
02
03
04
05
06
07
08
09
10
11
12
13
14
|
…
<link rel=»stylesheet» media=»screen» href=»/css/bootstrap.css» >
<script type=»text/mustache» id=»restaurant-template»>
<tr>
<td>{{ name }}</td>
<td>{{ postcode }}</td>
<td>{{ rating }}</td>
<td>
<i class=»icon-remove remove» id=»{{ id }}»></i>
</td>
</tr>
</script>
<script type=»text/javascript» src=»/javascript/vendor/jquery.min.js»></script>
…
|
Будучи шаблоном Усы, ему нужен правильный тип text/mustache
и id
мы можем использовать для извлечения его из DOM. Все параметры, заключенные в {{ }}
являются атрибутами нашей модели Restaurant
; это упрощает функцию рендеринга. В качестве последнего шага мы можем добавить значок remove
который при нажатии удаляет соответствующий ресторан.
Ресторан View View Class
Как уже говорилось, у нас есть два основных компонента представления: список ресторанов и форма ресторана. Давайте займемся первым, создав структуру каталогов для представлений и необходимые файлы:
1
2
3
4
|
mkdir -p javascript/views
mkdir -p javascript/spec/views
touch javascript/views/restaurants.coffee
touch javascript/spec/views/restaurants_spec.coffee
|
Давайте также скопируем #restaurant-template
в файл SpecRunner.html
:
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
|
…
<script type=»text/javascript» src=»/javascript/vendor/jasmine-jquery.js»></script>
<!— templates —>
<script type=»text/mustache» id=»restaurant-template»>
<tr>
<td>{{ name }}</td>
<td>{{ postcode }}</td>
<td>{{ rating }}</td>
<td>
<i class=»icon-remove remove» id=»{{ id }}»></i>
</td>
</tr>
</script>
<!— vendor js —>
<script type=»text/javascript» src=»/javascript/vendor/jquery.min.js»></script>
…
|
Кроме того, нам нужно включить файлы .js
в SpecRunner.html
. Теперь мы можем открыть views/restaurant_spec.coffee
и начать редактирование.
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
|
describe «Restaurants view», ->
restaurants_data = [
{
id: 0
name: ‘Ritz’
postcode: ‘N112TP’
rating: 5
},
{
id: 1
name: ‘Astoria’
postcode: ‘EC1E4R’
rating: 3
},
{
id: 2
name: ‘Waldorf’
postcode: ‘WE43F2’
rating: 4
}
]
invisible_table = document.createElement ‘table’
beforeEach ->
@restaurants_collection = new Gourmet.Collections.RestaurantsCollection restaurants_data
@restaurants_view = new Gourmet.Views.RestaurantsView
collection: @restaurants_collection
el: invisible_table
it «should be defined», ->
expect(Gourmet.Views.RestaurantsView).toBeDefined()
it «should have the right element», ->
expect(@restaurants_view.el).toEqual invisible_table
it «should have the right collection», ->
expect(@restaurants_view.collection).toEqual @restaurants_collection
|
Приспособления — это простой способ импортировать фрагменты HTML в наших тестах без необходимости записывать их в сам файл спецификации.
Похоже, много кода, но это стандартное начало для спецификации представления. Давайте пройдемся по нему:
- Мы начнем с создания экземпляра объекта, который содержит некоторые данные ресторана. Как следует из документации по Backbone , хорошей практикой является подача приложению Backbone данных, которые ему необходимы, непосредственно в разметке, чтобы избежать задержки для пользователя и дополнительного HTTP-запроса при открытии страницы.
- Мы создаем невидимый элемент таблицы, не добавляя его в DOM; нам не нужно это для взаимодействия с пользователем.
- Мы определяем блок
beforeEach
котором создаем экземплярRestaurantsCollection
с данными, которые мы создали ранее. Выполнение этого в блокеbeforeEach
гарантирует, что каждая спецификация будет начинаться с чистого листа. - Затем мы создаем экземпляр класса
RestaurantsView
и передаем как коллекцию, так и невидимую таблицу в инициализатор. Ключи объекта,collection
иel
, являются методами Backbone по умолчанию для классаView
. Они идентифицируют контейнер, в котором будет отображаться представление, и источник данных, используемый для его заполнения. - Спецификации просто проверяют, что все, что мы предполагаем в блоке
beforeEach
соответствует действительности.
Запуск наших тестов приводит к ошибке, потому что класс RestaurantsView
еще не определен. Мы можем легко получить все для зеленого, добавив следующий контент в views/restaurant.coffee
:
1
|
class Gourmet.Views.RestaurantsView extends Backbone.View
|
Нам не нужно переопределять или изменять конструктор, определенный прототипом Backbone.View
потому что мы создали экземпляр представления с collection
и атрибутом el
. Этой единственной строки достаточно, чтобы наши спецификации стали зелеными; однако, с точки зрения конечного результата он практически ничего не сделает.
Предполагая, что в коллекцию добавлены рестораны, класс представления должен отобразить их на странице, как только страница загрузится. Давайте переведем это требование в спецификацию, которую мы можем добавить внизу файла views/restaurant_spec.coffee
:
1
2
|
it «should render the the view when initialized», ->
expect($(invisible_table).children().length).toEqual 3
|
Мы можем проверить количество дочерних элементов (элементов <tr/>
), которое должно иметь невидимая таблица, учитывая, что мы определили примерный набор данных из трех ресторанов. Это приведет к красной спецификации, потому что мы даже не начали работать над рендерингом. Давайте добавим соответствующий фрагмент кода в класс RestaurantsView
:
1
2
3
4
5
6
7
8
9
|
class Gourmet.Views.RestaurantsView extends Backbone.View
template: Hogan.compile $(‘#restaurant-template’).html()
initialize: ->
@render @collection
render: =>
@$el.empty()
for restaurant in @collection.models
do (restaurant) =>
@$el.append @template.render(restaurant.toJSON())
|
… реальным преимуществом является возможность эффективно работать с тестируемыми функциональными компонентами, которые следуют предсказуемым шаблонам.
Вы увидите этот шаблон очень часто в приложении Backbone, но давайте разберем его на части:
- Функция
template
изолирует логику шаблонов, которую мы используем внутри приложения. Мы используем шаблоны усов, скомпилированные с помощью Hogan, но мы могли бы использовать сами Underscore или Mustache. Все они следуют аналогичной структуре API; Таким образом, переключение не составит труда (хотя и немного скучно). Кроме того, изоляция функции шаблона дает четкое представление о том, какой шаблон использует представление. - Функция
render
очищаетel
(обратите внимание, что@$el
является кэшированной версией jQuery самого элемента, доступной по умолчанию в Backbone), выполняет итерации по моделям внутри коллекции и отображает результат, а затем добавляет его к элементу. Это наивная реализация, и вы можете реорганизовать ее дляappend
только один раз, вместо того, чтобы делать это в каждом цикле. - Наконец, мы вызываем
render
когда представление инициализируется.
Это сделает нашу спецификацию зеленой и даст нам минимальный объем кода, полезного для ее отображения на странице. Давайте откроем index.html
и добавим следующее:
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
|
…
<body>
<script type=»text/javascript»>
restaurants_data = [
{
id: 0,
name: ‘Ritz’,
postcode: ‘N112TP’,
rating: 5
},
{
id: 1,
name: ‘Astoria’,
postcode: ‘EC1E4R’,
rating: 3
},
{
id: 2,
name: ‘Waldorf’,
postcode: ‘WE43F2’,
rating: 4
}
];
$(document).ready(function(){
restaurants = new Gourmet.Collections.RestaurantsCollection(restaurants_data);
restaurants_view = new Gourmet.Views.RestaurantsView({
collection: restaurants,
el: ‘#restaurants tbody’
})
});
</script>
…
|
В основном мы реплицируем набор данных по умолчанию и настройку, необходимую для запуска приложения. Мы также делаем это внутри HTML-файла, потому что этот код полезен только в этой статической версии приложения.
Обновить страницу и вот! Таблица ресторанов будет заполнена результатами.
Далее нам нужно разобраться с тем, что происходит, когда мы добавляем или удаляем ресторан из коллекции. Важно помнить, что форма является лишь одним из возможных способов воздействия на коллекцию; например, мы могли бы получать push-события от других пользователей. Поэтому важно, чтобы эта логика была отделена чистым и независимым образом.
Что мы ожидаем, что произойдет? Давайте добавим эти спецификации в файл views/restaurants\_view\_spec.coffee
(сразу после последнего):
01
02
03
04
05
06
07
08
09
10
|
it «should render when an element is added to the collection», ->
@restaurants_collection.add
name: ‘Panjab’
postcode: ‘N2243T’
rating: 5
expect($(invisible_table).children().length).toEqual 4
it «should render when an element is removed from the collection», ->
@restaurants_collection.pop()
expect($(invisible_table).children().length).toEqual 2
|
По сути, мы добавляем и удаляем ресторан в коллекцию, ожидая, что наш стол обновится соответствующим образом. Для добавления этого поведения в класс представления требуется пара строк в инициализаторе, поскольку мы можем использовать события Backbone в коллекции:
1
2
3
4
5
6
|
…
initialize: ->
@render @collection
@collection.on ‘add’, @render
@collection.on ‘remove’, @render
…
|
Мы можем перерисовать всю таблицу, используя коллекцию в текущем состоянии (после добавления или удаления элемента), потому что наша логика рендеринга довольно проста. Это заставит наши спецификации пройти.
Теперь, когда вы откроете файл index.html
, вы увидите, что значок удаления в каждой строке таблицы ничего не делает. Давайте уточним, что мы ожидаем, в конце файла views/restaurants\_view\_spec.coffee
:
1
2
3
4
5
6
|
it «should remove the restaurant when clicking the remove icon», ->
remove_button = $(‘.remove’, $(invisible_table))[0]
$(remove_button).trigger ‘click’
removed_restaurant = @restaurants_collection.get remove_button.id
expect(@restaurants_collection.length).toEqual 2
expect(@restaurants_collection.models).not.toContain removed_restaurant
|
Шпионы Жасмин довольно сильны, и я призываю вас прочитать о них.
Тест довольно многословный, но он суммирует, что именно должно произойти:
- Мы находим значок удаления первой строки в таблице с помощью jQuery.
- Затем мы нажимаем этот значок.
- Мы определяем, какой ресторан необходимо удалить, используя
id
кнопки «Удалить», который соответствуетid
модели ресторана. - Мы проверяем, что в коллекции ресторанов меньше элементов, и именно этот элемент мы определили ранее.
Как мы можем реализовать это? Backbone предоставляет хороший API для определения событий в рамках определенного представления. Давайте добавим один в класс RestaurantsView
:
1
2
3
4
5
6
7
8
|
class Gourmet.Views.RestaurantsView extends Backbone.View
events:
‘click .remove’: ‘removeRestaurant’
…
removeRestaurant: (evt) =>
id = evt.target.id
model = @collection.get id
@collection.remove model
|
При нажатии на элемент с классом .remove
представление вызывает функцию removeRestaurant
и передает объект события jQuery. Мы можем использовать его для получения id
элемента и удаления соответствующей модели из коллекции. Мы уже обрабатываем то, что происходит при удалении элемента из коллекции; Итак, этого будет достаточно, чтобы получить спецификацию зеленого цвета.
Кроме того, вы можете открыть index.html
и увидеть его в действии в браузере.
Ресторан Форма Класс
Теперь нам нужно обработать пользовательский ввод при использовании формы для добавления нового ресторана:
- Если пользователь вводит неверные данные, мы будем отображать встроенные ошибки проверки.
- Если пользователь вводит действительные данные, ресторан будет добавлен в коллекцию и отображен в таблице.
Поскольку мы уже добавили проверки в модель Restaurant
, теперь нам нужно привязать их к представлению. Неудивительно, что мы начнем с создания нового класса представления и соответствующего файла спецификации.
1
2
|
touch javascript/views/restaurant_form.coffee
touch javascript/spec/views/restaurant\_form\_spec.coffee
|
Еще раз, давайте не забудем добавить скомпилированную версию JavaScript представления в index.html
и обе скомпилированные версии в SpecRunner.html
.
Самое время представить приспособления, часть функциональности, предоставляемую Jasmine-jQuery, потому что мы будем иметь дело с разметкой формы. По сути, фикстуры — это простой способ импортировать фрагменты HTML в наших тестах без необходимости записывать их в сам файл спецификации. Это сохраняет спецификации чистыми, понятными и может в конечном итоге привести к повторному использованию прибора среди множества спецификаций. Мы можем создать приспособление для разметки формы:
1
2
|
mkdir -p javascript/spec/fixtures
touch javascript/spec/fixtures/restaurant_form.html
|
Давайте скопируем всю форму в index.html
в арматуру restaurant_form.html
:
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
|
<form action=»#» class=»well form-horizontal» id=»restaurant-form»>
<div class=»control-group»>
<label for=»restaurant_name»>Name</label>
<input type=»text» name=»restaurant[name]» id=»restaurant_name» />
<span class=»help-block»>Required
</div>
<div class=»control-group»>
<label for=»restaurant_rating»>Rating</label>
<input type=»text» name=»restaurant[rating]» id=»restaurant_rating» />
<span class=»help-block»>Required, only a number between 1 and 5
</div>
<div class=»control-group»>
<label for=»restaurant_postcode»>Postcode</label>
<input type=»text» name=»restaurant[postcode]» id=»restaurant_postcode» />
<span class=»help-block»>Required
</div>
<input type=»button» class=»btn btn-primary» value=»Save» id=»save»/>
</form>
|
Теперь откройте views/restaurant\_form\_spec.coffee
и добавьте прибор вместе с некоторыми шаблонами:
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
|
describe «Restaurant Form», ->
jasmine.getFixtures().fixturesPath = ‘javascript/spec/fixtures’
beforeEach ->
loadFixtures ‘restaurant_form.html’
@invisible_form = $(‘#restaurant-form’)
@restaurant_form = new Gourmet.Views.RestaurantForm
el: @invisible_form
collection: new Gourmet.Views.RestaurantsCollection
it «should be defined», ->
expect(Gourmet.Views.RestaurantForm).toBeDefined()
it «should have the right element», ->
expect(@restaurant_form.$el).toEqual @invisible_form
it «should have a collection», ->
expect(@restaurant_form.collection).toEqual (new Gourmet.Views.RestaurantsCollection)
|
Изменение jasmine.getFixtures().fixtures_path
необходимо, так как у нас есть собственная структура каталогов, которая отличается от библиотеки по умолчанию. Затем в блоке beforeEach
мы загружаем прибор и определяем переменную @invisible_form
которая нацеливается на только что импортированную форму. Наконец, мы определяем экземпляр класса, который мы собираемся создать, передавая пустую коллекцию ресторанов и @invisible_form
нами @invisible_form
. Как обычно, эта спецификация будет красной (класс все еще не определен), но если мы откроем restaurant_form.coffee
мы можем легко это исправить:
1
|
class Gourmet.Views.RestaurantForm extends Backbone.View
|
Далее нам нужно подумать о структуре нашей спецификации. У нас есть два варианта:
Использование Backbone означает, что мы собираемся создавать модели, коллекции и представления. Поэтому наличие пространства имен для их организации является хорошей практикой.
- Мы можем следить за содержанием формы с помощью жасмина и издеваться над ней.
- Мы могли бы вручную изменить содержимое полей и затем смоделировать щелчок.
Лично я за первый подход. Второе не исключает необходимости надлежащего интеграционного тестирования, но усложнит спецификацию.
Шпионы Жасмин довольно сильны, и я призываю вас прочитать о них. Если вы имеете опыт тестирования на Ruby, они очень похожи на макеты RSpec и кажутся вам знакомыми. Нам нужно иметь представление о шаблоне, который мы собираемся реализовать, по крайней мере, широкими штрихами:
- Пользователь вводит данные в форму.
- Когда он нажимает сохранить, мы получаем содержимое формы в сериализованной форме.
- Мы трансформируем эти данные и создаем новый ресторан в коллекции.
- Если ресторан действителен, мы сохраняем его, в противном случае мы будем отображать ошибки проверки.
Как было сказано ранее, мы собираемся смоделировать первый шаг, и мы сделаем это, определив новый блок описания, в котором мы создаем экземпляр объекта, который представляет собой правильно сформированную, действительную структуру данных, поступающую из формы.
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
|
describe «Restaurant Form», ->
…
describe «form submit», ->
beforeEach ->
@serialized_data = [
{
name: ‘restaurant[name]’,
value: ‘Panjab’
},
{
name: ‘restaurant[rating]’,
value: ‘5’
},
{
name: ‘restaurant[postcode]’,
value: ‘123456’
}
]
spyOn(@restaurant_form.$el, ‘serializeArray’).andReturn @serialized_data
|
В конце мы определяем шпиона метода serializeArray
для нашей формы. Это означает, что если мы вызываем @restaurant_form.$el.serializeArray()
, мы уже знаем, что он вернет объект, который мы создали выше. Это насмешливое средство, в котором мы нуждались; он моделирует пользовательский ввод, с которым мы должны тестировать. Далее мы можем добавить некоторые характеристики:
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
|
it «should parse form data», ->
expect(@restaurant_form.parseFormData(@serialized_data)).toEqual
name: ‘Panjab’,
rating: ‘5’,
postcode: ‘123456’
it «should add a restaurant when form data is valid», ->
spyOn(@restaurant_form, ‘parseFormData’).andReturn
name: ‘Panjab’,
rating: ‘5’,
postcode: ‘123456’
@restaurant_form.save() # we mock the click by calling the method
expect(@restaurant_form.collection.length).toEqual 1
it «should not add a restaurant when form data is invalid», ->
spyOn(@restaurant_form, ‘parseFormData’).andReturn
name: »,
rating: ‘5’,
postcode: ‘123456’
@restaurant_form.save()
expect(@restaurant_form.collection.length).toEqual 0
it «should show validation errors when data is invalid», ->
spyOn(@restaurant_form, ‘parseFormData’).andReturn
name: »,
rating: ‘5’,
postcode: ‘123456’
@restaurant_form.save()
expect($(‘.error’, $(@invisible_form)).length).toEqual 1
|
В первой спецификации мы проверяем, что у нашего класса RestaurantForm
есть метод, который анализирует данные из формы. Этот метод должен возвращать объект, который мы можем передать в коллекцию ресторана. Во второй спецификации мы высмеиваем предыдущий метод, потому что нам не нужно тестировать его снова. Вместо этого мы сосредоточимся на том, что происходит, когда пользователь нажимает «Сохранить». Это, вероятно, вызовет событие, которое вызывает функцию save
.
Мы должны настроить макет второй спецификации, чтобы вернуть неверные данные для ресторана, чтобы убедиться, что ресторан не добавлен в коллекцию. В третьей спецификации мы проверяем, что это также вызывает ошибки проверки в форме. Реализация несколько сложнее:
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
|
class Gourmet.Views.RestaurantForm extends Backbone.View
events:
‘click #save’: ‘save’
save: ->
data = @parseFormData(@$el.serializeArray())
new_restaurant = new Restaurant data
errors = new_restaurant.validate(new_restaurant.attributes)
if errors then @handleErrors(errors) else @collection.add new_restaurant
parseFormData: (serialized_array) ->
_.reduce serialized_array, @parseFormField, {}
parseFormField: (collector, field_obj) ->
name = field_obj.name.match(/\[(\w+)\]/)[1]
collector[name] = field_obj.value
collector
handleErrors: (errors) ->
$(‘.control-group’).removeClass ‘error’
for key in (_.keys errors)
do (key) ->
input = $(«#restaurant_#{key}»)
input.closest(‘.control-group’).addClass ‘error’
|
Это хорошая практика, чтобы убедиться, что мы используем фальшивый сервер только там, где нам нужно, сводя к минимуму помехи для остальной части тестового набора.
Давайте посмотрим на каждую функцию:
- У нас есть хеш
events
который связывает щелчок мышью пользователя с функциейsave
. - Функция сохранения анализирует данные (подробнее об этом ниже) в форме и создает новый ресторан. Мы вызываем функцию
validate
(доступную для Backbone и определяемую для проверки Backbone). Он должен возвращатьfalse
если модель действительна, и объект ошибки, когда она недействительна. Если действительный, мы добавляем ресторан в коллекцию. - Две функции ‘parse’ необходимы для извлечения имен атрибутов из формы и создания объекта в нужном формате Backbone-ready. Имейте в виду, что эта сложность необходима из-за разметки. Мы могли бы изменить это, но это хороший пример того, как вы могли бы работать поверх существующей формы, чтобы улучшить ее.
- Функция
handleErrors
выполняетhandleErrors
по объектуerrors
и находит соответствующие поля ввода, добавляя класс.error
когда это необходимо.
Запуск спецификации теперь показывает обнадеживающую серию зеленых точек. Чтобы он работал в браузере, нам нужно расширить нашу функцию инициализации:
01
02
03
04
05
06
07
08
09
10
11
|
$(document).ready(function(){
restaurants = new Gourmet.Collections.RestaurantsCollection(restaurants_data);
restaurants_view = new Gourmet.Views.RestaurantsView({
collection: restaurants,
el: ‘#restaurants tbody’
});
restaurant\_form\_view = new Gourmet.Views.RestaurantForm({
el: ‘#restaurant-form’,
collection: restaurants
});
});
|
Есть только одно предупреждение: на данный момент вы не можете удалить ресторан, который вы добавили, потому что мы полагаемся на атрибут id
чтобы указать правильную модель в коллекции ресторанов (Backbone нужен слой постоянства, чтобы назначить его). Это то место, куда вы бы добавили, в зависимости от ваших потребностей, настоящий LocalStorage
— например, сервер Rails или адаптер LocalStorage
.
Шаг 3 — Тестирование взаимодействия с сервером
Несмотря на то, что мы находимся в среде без сервера, мы можем воспользоваться парой дополнительных библиотек, которые позволяют нам подключать наше приложение для развертывания сервера. В качестве доказательства концепции мы предположим, что работаем над стеком Ruby on Rails.
Чтобы использовать Backbone с приложением Rails, нам нужен дополнительный адаптер для синхронизации; По умолчанию Backbone не обеспечивает этого (это независимый от сервера инструмент). Мы можем использовать тот, который включен в проект Backbone-rails .
1
|
curl -o javascript/vendor/backbone\_rails\_sync.js https://raw.github.com/codebrew/backbone-rails/master/vendor/assets/javascripts/backbone\_rails\_sync.js
|
Далее нам нужно включить его как в index.html
и в SpecRunner.html
, сразу после скрипта, который требует сам Backbone. Этот адаптер заботится о выполнении всех асинхронных запросов, которые нам нужны, при условии, что мы настроим нашу модель Restaurant
и нашу RestaurantsCollection
с правильными URL-адресами.
Как мы собираемся проверить это? Мы можем использовать Sinon.js , очень мощную библиотеку- макет JavaScript, которая также может создавать поддельный объект сервера, который будет перехватывать все запросы XHR. Еще раз, мы можем просто:
1
|
curl -o javascript/vendor/sinon.js https://sinonjs.org/releases/sinon-1.4.2.js
|
Не забудьте добавить его в файл SpecRunner.html
сразу после Jasmine.
Теперь мы можем начать думать об API сервера. Можно предположить, что он следует архитектуре RESTful (прямое следствие выбора Rails в качестве бэкэнда) и использует формат JSON. Поскольку мы управляем ресторанами, мы также можем предположить, что базовым URL для каждого запроса будет /restaurants
.
Мы можем добавить две спецификации в файл models/restaurant_spec.coffee
чтобы убедиться, что коллекция и модель настроены правильно:
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
|
…
it «should have default attributes», ->
expect(ritz.attributes.name).toBeDefined()
expect(ritz.attributes.postcode).toBeDefined()
expect(ritz.attributes.rating).toBeDefined()
it «should have the right url», ->
expect(ritz.urlRoot).toEqual ‘/restaurants’
…
it «should use the Restaurant model», ->
expect(restaurants.model).toEqual Gourmet.Models.Restaurant
it «should have the right url», ->
expect(restaurants.url).toEqual ‘/restaurants’
|
Чтобы реализовать это, нам нужно определить два метода для модели RestaurantsCollection
класса RestaurantsCollection
:
01
02
03
04
05
06
07
08
09
10
11
|
class Gourmet.Models.Restaurant extends Backbone.Model
urlRoot: ‘/restaurants’
…
class Gourmet.Collections.RestaurantsCollection extends Backbone.Collection
url: ‘/restaurants’
model: Gourmet.Models.Restaurant
|
Не упустите другое имя метода!
Отделение нашего приложения Backbone от серверной части делает его еще одним клиентом.
Это то, что нужно для настройки интеграции сервера. Магистраль позаботится об отправке правильных запросов Ajax. Например, создание нового ресторана вызывает запрос POST
для /restaurants
с новыми атрибутами ресторана в формате JSON. Поскольку эти запросы всегда одинаковы (что гарантировано адаптером rails_sync
), мы можем надежно проверить, что взаимодействие на странице вызовет эти запросы.
Давайте откроем файл views/restaurants_spec.coffee
и настроим Sinon. Мы будем использовать его fakeServer
для проверки запросов, отправленных на сервер. В качестве первого шага нам нужно создать экземпляр сервера beforeEach
блоке beforeEach
. Мы также должны убедиться, что восстановили нормальную функциональность сразу после запуска наших спецификаций. Это хорошая практика, чтобы убедиться, что мы используем фальшивый сервер только там, где нам нужно, сводя к минимуму помехи для остального набора тестов.
1
2
3
4
5
6
7
8
9
|
beforeEach ->
@server = sinon.fakeServer.create()
@restaurants_collection = new Gourmet.Collections.RestaurantsCollection restaurants_data @restaurants_view = new Gourmet.Views.RestaurantsView collection: @restaurants_collection el: invisible_table afterEach -> @server.restore() |
Затем мы добавляем спецификацию, чтобы проверить, что запрос DELETE отправляется на сервер, когда мы нажимаем значок удаления для ресторана:
01
02
03
04
05
06
07
08
09
10
11
|
it "should remove a restaurant from the collection" , -> evt = { target: { id: 1 } } @restaurants_view.removeRestaurant evt expect(@restaurants_collection.length).toEqual 2 it "should send an ajax request to delete the restaurant" , -> evt = { target: { id: 1 } } @restaurants_view.removeRestaurant evt expect(@server.requests.length).toEqual 1 expect(@server.requests[0].method).toEqual( 'DELETE' ) expect(@server.requests[0].url).toEqual( '/restaurants/1' ) |
Мы можем легко проверить @server.requests
массив всех запросов XHR, сделанных в тесте. Мы проверяем протокол и URL первого запроса и проверяем его на соответствие ожиданиям. Если вы запустите спецификацию, она потерпит неудачу, потому что наша текущая логика просто удаляет ресторан из коллекции, не удаляя ее. Давайте откроем views/restaurants.coffee
и пересмотрим removeRestaurant
метод:
1
2
3
4
5
|
removeRestaurant: (evt) => id = evt.target.id model = @collection.get id @collection.remove model model.destroy() |
Вызывая destroy
, мы эффективно запускаем запрос DELETE, делая нашу спецификацию проходной.
Далее, ресторанная форма. Мы хотим проверить, что каждый раз при отправке формы с действительными данными на сервер отправляется запрос POST с правильными данными. Мы также проведем рефакторинг наших тестов, чтобы изолировать действительные и недействительные атрибуты в двух переменных; это уменьшит количество повторений, которое у нас уже есть. Для ясности, вот полный Form submit
блок из views/restaurant\_form\_spec.coffee
:
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
|
describe "Form submit" , -> # attrs need to be alphabetical ordered! validAttrs = name: 'Panjab' , postcode: '123456' , rating: '5' invalidAttrs = name: »,
postcode: '123456' , rating: '5' beforeEach -> @server = sinon.fakeServer.create() @serialized_data = [ {
name: 'restaurant[name]' , value: 'Panjab' },
{
name: 'restaurant[rating]' , value: '5' },
{
name: 'restaurant[postcode]' , value: '123456' }
]
spyOn(@restaurant_form.$el, 'serializeArray' ).andReturn @serialized_data afterEach -> @server.restore() it "should parse form data" , -> expect(@restaurant_form.parseFormData(@serialized_data)).toEqual validAttrs it "should add a restaurant when form data is valid" , -> spyOn(@restaurant_form, 'parseFormData' ).andReturn validAttrs @restaurant_form.save() # we mock the click by calling the method expect(@restaurant_form.collection.length).toEqual 1 it "should not add a restaurant when form data is invalid" , -> spyOn(@restaurant_form, 'parseFormData' ).andReturn invalidAttrs @restaurant_form.save() expect(@restaurant_form.collection.length).toEqual 0 it "should send an ajax request to the server" , -> spyOn(@restaurant_form, 'parseFormData' ).andReturn validAttrs @restaurant_form.save() expect(@server.requests.length).toEqual 1 expect(@server.requests[0].method).toEqual( 'POST' ) expect(@server.requests[0].requestBody).toEqual JSON.stringify(validAttrs) it "should show validation errors when data is invalid" , -> spyOn(@restaurant_form, 'parseFormData' ).andReturn invalidAttrs @restaurant_form.save() expect($( '.error' , $(@invisible_form)).length).toEqual 1 |
Шаблон точно такой же, как тот, который мы использовали в предыдущей спецификации: мы создаем экземпляр сервера sinon и проверяем requests
массив на POST-запрос с действительными атрибутами.
Чтобы реализовать это, нам нужно изменить строку в views/restaurant_form.coffee
:
1
2
3
4
5
|
save: -> data = @parseFormData(@$el.serializeArray()) new_restaurant = new Gourmet.Models.Restaurant data errors = new_restaurant.validate(new_restaurant.attributes) if errors then @handleErrors(errors) else @collection.create new_restaurant |
Вместо того, чтобы просто добавить ресторан в коллекцию, мы вызываем create
метод для запуска сохранения на сервере.
Вывод
Если вы никогда раньше не работали с Backbone и Jasmine, это много, что нужно переварить, однако реальным преимуществом является возможность эффективно работать с тестируемыми функциональными частями, которые следуют предсказуемым шаблонам. Вот несколько советов о том, как это улучшить:
- Можно ли добавить сообщение об ошибках проверки?
- Как мы можем сбросить форму после добавления ресторана?
- Как мы могли редактировать ресторан?
- Что если нам нужно разбить таблицу на страницы?
Попробуйте и дайте мне знать в комментариях!