Showcase: Пример разработки frontend-приложения при помощи современных инструментов

Митяков Александр Владимирович (avmityakov@gmail.com)

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

В качестве примера приложения мы реализуем простой "компонент" для создания блог-поста на основе markdown-разметки. В ходе работы мы будем использовать следующие компоненты и инструменты:

Задача

Итак, что мы будет реализовывать? В качестве примера мы сделаем форму создания блог-поста. У поста будут следующие поля:

  • Название
  • Содержимое (markdown-разметка)
Помимо полей для создания поста, на второй части экрана, справа будет живое превью поста. То есть пример того, как он будет выглядеть после публикации, а именно:
  • Заголовок
  • markdown-текст преобразован в HTML
  • Примерное время чтения поста (количество символов / средняя скорость чтения в зн./мин.)
При любом изменении полей, превью будет соответствующим образом изменяться.

Содержание

  1. Результат
  2. Разворачиваем окружение
  3. Установка клиентских библиотек
  4. Подключаем RequireJS
  5. Автоматизация конфигурации RequireJS
  6. Разработка приложения на Backbone
  7. Unit-тесты
  8. Препроцессор LESS
  9. Сборка RequireJS-файлов
  10. Grunt задача "build"

Результат

Код итогового приложения доступен на github, а результат выглядит следующим образом:

Разворачиваем окружение

Первым делом нам необходимо создать файл-описание нашего проекта для менеджера пакетов npm. Файл имеет имя - "package.json", в нем, помимо базовой информации о проекте (название, описание, ...), будут хранится все серверные зависимости нашего приложения (инструменты и ресурсы, которые необходимы для работы). Задачей этого файла является возможность в будущем развернуть аналогичное окружение на другой машине будь то рабочая станция вашего коллеги или же production-сервер. Файл можно создать вручную, но для простоты лучше воспользоваться специальной командой npm:

npm init

Вам будут заданы вопросы касательно вашего приложения/пакета, после ответов на которые будет сгенерирован файл package.json примерно следующего содержания:


{
    "name": "Demo",
    "version": "1.0.0",
    "description": "Showcase: современный процесс разработки web-приложений",
    "main": "app.js",
    "scripts": {
        "test": "echo \"Error: no test specified\" && exit 1"
    },
    "author": "krustnic",
    "license": "MIT"
}

Теперь с помощью npm мы можем установить необходимые на серверные компоненты. Первым из которым мы установим bower. Bower - это менеджер пакетов, как и npm, но заточенный чисто на frontend.

И так, чтобы установить bower выполним следующую команду:

npm install bower --save

Важным моментом здесь является ключ "--save". Он говорит npm о том, что нужно добавить информацию об этом пакете (bower) в список зависимостей для нашего проекта. Зависимости описываются в конфигурационном файле нашего проекта/пакета "package.json". После выполнения этой команды файл должен выглядеть следующим образом:


{
    "name": "Demo",
    "version": "1.0.0",
    "description": "Showcase: современный процесс разработки web-приложений",
    "main": "app.js",
    "scripts": {
        "test": "echo \"Error: no test specified\" && exit 1"
    },
    "author": "krustnic",
    "license": "MIT",
    "dependencies": {
        "bower": "^1.4.1"
    }
}

Так же как и npm, bower, сохраняет зависимости вашего проекта в свой конфигурационный файл "bower.json". Поэтому нам так же как и с npm вначале нужно его создать:

bower init

В процессе вам будет задан ряд вопросов, ответив на которые вы получите файл "bower.json". Отметим, что на вопрос о том какую технологию загрузки модулей мы собираемся использовать следует отметить "amd", так как мы планируем использовать библиотеку RequireJS.


{
  "name": "Demo",
  "version": "1.0",
  "authors": [
    "krustnic "
  ],
  "description": "Showcase: современный процесс разработки web-приложений",
  "main": "app.js",
  "moduleType": [
    "amd"
  ],
  "license": "MIT",
  "private": true,
  "ignore": [
    "**/.*",
    "node_modules",
    "bower_components",
    "test",
    "tests"
  ]
}
Установка клиентских библиотек

Установив bower воспользуемся им для установки необходимых нам клиентских библиотек, а именно:

  • jQuery
  • Bootstrap
  • Underscore
  • Backbone
  • RequireJS
Обратите внимание, что ряд библиотек зависит друг от друга. Например jQuery нужен для Bootstrap и Backbone, Underscore для Backobone. Bower знает об этом, поэтому эти зависимые библиотеки мы не будем указывать (хотя никто и не запрещает):

bower install bootstrap backbone requirejs --save

Так же как и с npm важно указать ключ "--save", чтобы установленные библиотеки добавились в качестве зависимостей к нашему проекту в файл "bower.json":


{
  "name": "Demo",
  "version": "1.0",
  "authors": [
    "krustnic "
  ],
  "description": "Showcase: современный процесс разработки web-приложений",
  "main": "app.js",
  "moduleType": [
    "amd"
  ],
  "license": "MIT",
  "private": true,
  "ignore": [
    "**/.*",
    "node_modules",
    "bower_components",
    "test",
    "tests"
  ],
  "dependencies": {
    "bootstrap": "~3.3.4",
    "backbone": "~1.1.2",
    "requirejs": "~2.1.17"
  }
}

Все устанавливаемые библиотеки сохраняются в папку "bower_components" и если мы заглянем в нее, то увидим все наши библиотеки и их зависимости. Из этой папки мы и будет загружать их к нам на страницу.

bower_components
├── backbone
├── bootstrap
├── jquery
├── requirejs
└── underscore
Подключаем RequireJS

Ну вот и добралась до самого frontend-а. Так как мы будем использовать загрузчик модулей - RequireJS, то первым делом нам нужно его настроить.

RequireJS позволяет определить модуль (блок кода) и его зависимости, а затем загрузить их на страницу без необходимости прописывать каждый <script> отдельно:

define( [ "a", "b" ], function( a, b ) {
    var c = function() {};
    
    return c;
});

В данном примере объявлен модуль с двумя зависимостями a и b. Это означает, что перед добавлением этого модуля на страницу вначале будут загружены модули a и b. Для того чтобы импортировать модули существует функция require. В качестве имени модуля, с помощью которого его можно импортировать служит путь к этому файлу с опущенным расширением ".js" (Для задания пути к корневой папке скриптов есть специальное свойство в конфигурационном файле RequireJS).

require( [ "c" ], function( c ) {
    // Using module "c"
});

Работу с библиотекой RequireJS мы начнем с создания её конфигурационного файла. Создадим файл "assets/js/requirejs-config.js" следующего содержания:

require.config({
    // Путь относительно, которого будет осуществляться поиск модулей
    baseUrl: "/assets/js",
    // Псевдонимы для наших модулей/библиотек
    paths: {        
        bootstrap: "../../bower_components/bootstrap/dist/js/bootstrap",
        jquery: "../../bower_components/jquery/dist/jquery"        
    },
    // Описание зависимостей для библиотек, которые не рассчитаны на работу с модулями AMD.
    // Для них мы задаем зависимости вручную.
    shim: {
        bootstrap: {
            deps: [ "jquery" ]
        }
    }
});

// Загружаем наше приложение (главный скрипт)
require( [ "app" ] );

И создадим файл который будет точкой входа в наше приложение assets/js/app.js:

require( [ "bootstrap" ], function() {    
    // Проверим подгрузилась ли вместе с "bootstrap" его зависимость - jQuery
    console.info( "jQuery exists: " + (typeof jQuery == "function" ? true : false ) );    
});

Теперь создадим файл index.html со следующим содержимым и проверим работу RequireJS:

<!DOCTYPE html>
<html>
<head>
    <title>Showcase: современный процесс разработки web-приложений</title>
    
    <link href="bower_components/bootstrap/dist/css/bootstrap.min.css" rel="stylesheet">
    <script data-main="assets/js/requirejs-config" src="bower_components/requirejs/require.js"></script>
</head>
<body>

</body>
</html>

Теперь если мы откроем это страницу в браузере и посмотрим в консоль, то увидим сообщение о том, что jQuery успешно загрузилось вместе со скриптами "bootstrap".

Автоматизация конфигурации RequireJS

Теперь, разобравшись с конфигурационным файлом RequireJS нам нужно добавить в него пути к остальным загруженным нами библиотекам. Не будем спешить делать это в ручную. Большой частью современной разработки web-приложений является автоматизация рутинных действий web-разработчика таких как: минификация, конкатенация, проверка принятого стиля кодирования и прочих. Не исключением является и обновление конфигурационного файла RequireJS. Действительно, при каждой установке нового пакета при помощи bower нам необходимо обновить конфигурационный файл RequireJS. Почему бы не автоматизировать этот процесс?

Для автоматизации различных действий существуют различные системы сборки. Наиболее популярными для веб-разработки сейчас являются системы Grunt, Gulp, Volo (в меньшей степени). Мы будем использовать систему Grunt как ниболее "классическую".

Для начала нам нужно её установить. Так как это серверный компонент, то устанавливается он при помощи пакетного менеджера npm:

npm install grunt grunt-cli --save-dev

Мы утсанавливаем два пакета: grunt и grunt-cli. Первый - это сама система сборки, а второй - это инструменты командной строки, которые позволят нам запускать grunt из консоли.

Так же, обратите внимание, что в этот раз вместо ключа "--save" мы используем ключ "--save-dev". Отличие заключается в том, что устанавливаемый пакет будет добавлен в конфигурационный файл "package.json" не в секцию "dependencies", а в секцию "devDependencies" (зависимости необходимые только в процессе разработки), что позволит нам не устанавливать их на "production" систему.

Теперь пакет grunt загружен и лежит в папке "node_modules". Сразу же скачаем пакет - задачу для нашей системы сборки, которая будет обновлять за нас конфигурационный файл RequireJS на основе данных из файла "bower.json":

npm install grunt-bower-requirejs --save-dev

Теперь необходимо описать системе grunt что мы от нее хотим. Описание задач для системы сборки происходит в её конфигурационном файле "Gruntfile.js". Давайте создаим его со следующим содержимым:

module.exports = function(grunt) {    
    // Конфигуграция задач
    grunt.initConfig({
        bowerRequirejs : {
            target: {
                // Путь к конфигурационному файлу RequireJS
                rjsConfig: 'assets/js/requirejs-config.js'
            },
            options: {
                // Опция указывающая, что зависимости установленных пакетов так же следует
                // добавлять в конфигурационный файл RequireJS
                // Например: при установке "bootstrap" добавится две записи: "bootstrap" и "jquery"
                transitive: true
            }
        }
    });

    // Загружаем задачу
    grunt.loadNpmTasks('grunt-bower-requirejs');
    
    // Создаем другое имя задачи по которому мы будем ее вызывать
    grunt.registerTask('update-requirejs', ['bowerRequirejs']);    
}

Все, задача готова, теперь ее можно запустить:

node_modules/grunt-cli/bin/grunt update-requirejs

После сообщения о том, что задача "bowerRequirejs" успешно отработала, конфигурационный файл RequireJS принял следующий вид:

require.config({
    baseUrl: "assets/js",
    paths: {
        bootstrap: "../../bower_components/bootstrap/dist/js/bootstrap",
        jquery: "../../bower_components/jquery/dist/jquery",
        backbone: "../../bower_components/backbone/backbone",
        underscore: "../../bower_components/underscore/underscore",
        marked: "../../bower_components/marked/lib/marked",
        requirejs: "../../bower_components/requirejs/require"
    },
    shim: {
        bootstrap: {
            deps: [
                "jquery"
            ]
        }
    },
    packages: [

    ]
});
require( [ "app" ] );

Теперь после установки новых пакетов с помощью bower нам достаточно просто запустить эту задачу и конфигурационный файл RequireJS будет обновлен автоматически.

Разработка приложения на Backbone

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

  • Model
  • Collection
  • View
  • Router
В нашем приложении мы будем использовать всё кроме роутера и коллекций. Роутер необходим в первую очередь для реализации навигации в SPA (single page application), а коллекции это просто набор моделей с приятными дополнительными возможностями (получение с сервера, поиск, фильтрации и т. д.).

Для начала определимся с файловой структурой нашего приложения. При работе с Bacbkone я предпочитаю создавать папку на каждый логический блок интерфейса, а внутри делить все файлы по типам: models, collections, views, templates. Для нашей задачи я предлагаю следующую структуру файлов:

assets
└── js
    └── create-post
        ├── templates
        ├── views
        ├── models
        └── collections

Теперь определимся с иерархией отображений (Views). На самом верхнем уровне будет простая структура - MainView. В ней две дочерние CreateView и PreviewView. CreateView сразу будет отобржать поля для ввода: заголовок, текст. PreviewView в свою очередь будет состоять из трех других: TitleView, MetaView и TextView.

MainView

Приступим к созданию этих отображений. Начнем с верстки, создадим файл assets/js/create-post/templates/main:

<div class="row">
    <div data-eid="create-view" class="col-md-6">CreateView</div>    
    <div data-eid="preview-view" class="col-md-6">PreviewView</div>
</div>

Так как все зависимости мы собираемся загружать через RequireJS то этот модуль, кусок html-верстки так же следует загружать при помощи RequireJS. Из коробки RequireJS умеет загружать только скрипты, однако он поддержиет добавление различных плагинов. Плагин для загрузки текстовых файлов называется text и доступен в bower под именем "requirejs-text". Установим его:

node_modules/bower/bin/bower install requirejs-text --save

И сразу же обновим конфигурационный файл RequireJS при помощи нашей grunt задачи:

node_modules/grunt-cli/bin/grunt update-requirejs

Теперь мы можем загружать обычные текстовые файлы при помощи RequireJS. Для активации плагина в пути к файлу необходимо добавить префикс "requirejs-text!" (название модуля с плагином и восклицательный знак на конце). Создаим файл assets/js/create-post/views/main:


define([
    "backbone",    
    "requirejs-text!../templates/main.html"
], function( Backbone, tpl ) {

    var view = Backbone.View.extend({
        // Кэшируем html-шаблон
        template : _.template( tpl ),

        initialize : function( options ) {

        },

        render : function() {
            // Отключаем привязанные события, очищаем элемент и
            // добавляем в верстку шаблон
            this.$el.empty().append( this.template() );            

            return this;
        }

    });

    return view;
});

Немного отредактируем верстку нашей основной страницы - добавим мето куда будем добавлять нашу MainView. Файл index.html:

...
<body>
    <div id="content" class="container"></div>    
</body>
...

Теперь осталось лишь создать экземпляр нашего отображения MainView и добавить его в верстку. Отредактируем файл assets/js/app.js:

require( [ "create-post/views/main", "bootstrap" ], function( MainView ) { 
    var mainView = new MainView();    
    $("#content").append( mainView.render().$el );
});

Перед тем как приступить к созданию самой формы блог-поста, вначале сделаем модель, которая будет хранить все введенные данные (заголовок, текст). Создадим файл assets/js/models/post:

define( [ "backbone" ], function( Backbone ) {
    
    var model = Backbone.Model.extend({ 
        // Значения по-умолчанию
        defaults : {
            "title" : "",            
            "text"  : ""
        },

        initialize : function( options ) {
            
        }
    });

    return model;
});

CreateView

Теперь приступим к реализации формы создания блог-поста CreateView. По аналогии с MainView создадим два файла assets/js/create-post/templates/create и assets/js/create-post/views/create:

<!-- assets/js/create-post/templates/create -->

<div class="row">
    <div class="col-md-12">
        <div class="form-group">
            <label>Заголовок</label>
            <input data-eid="title" type="text" class="form-control">
        </div>
    </div>
</div>

<div class="row">
    <div class="col-md-12">
        <div class="form-group">
            <label>Текст</label>
            <textarea data-eid="text" cols="30" rows="10" class="form-control"></textarea>
        </div>
    </div>
</div>
// assets/js/create-post/views/create

define([
    "backbone",    
    "requirejs-text!../templates/create.html"
], function( Backbone, tpl ) {

    var view = Backbone.View.extend({
        template : _.template( tpl ),
        
        events : {
            "input [data-eid=title]" : "update",            
            "input [data-eid=text]"  : "update"
        },

        initialize : function( options ) {

        },

        render : function() {
            this.$el.empty().append( this.template() );
            return this;
        },
        
        update : function() {
            this.model.set( "title", this.$("[data-eid=title]").val() );            
            this.model.set( "text" , this.$("[data-eid=text]").val() );
        }

    });

    return view;
});

Здесь мы подразумеваем, что в CreateView уже есть модель. Мы добавили два слушателя событий, которые следят за любым изменением полей поста и тут же изменяют модель. Теперь нам нужно добавить отрисовку CreateView в MainView. Модифицируем assets/js/views/main:

define([
    "backbone",    
    "../models/post",
    "../views/create",
    "requirejs-text!../templates/main.html"
], function( Backbone, PostModel, CreateView, tpl ) {

    var view = Backbone.View.extend({
        template : _.template( tpl ),        

        initialize : function( options ) {
            this.model = new PostModel();
        },

        render : function() {            
            this.$el.empty().append( this.template() ); 
            
            var createView = new CreateView({
                model : this.model
            });    
            
            this.$("[data-eid=create-view]").empty().append( createView.render().$el );

            return this;
        }

    });

    return view;
});

При инициализации MainView создается модель для нашего поста и передается во вновь созданное отображение "CreateView". Благодаря тому, что сама модель создается в MainView в будущем мы сможем передать тот же экземпляр модели в отображение PreviewView и изменять его при каждом ее изменении.

PreviewView

Создадим отображение PreviewView. По аналогии создаем два файла assets/js/create-post/templates/preview и assets/js/create-post/views/preview

<!-- assets/js/create-post/templates/preview -->
<div data-eid="title-view"></div>    
<div data-eid="meta-view"></div>
<div data-eid="text-view"></div>
// assets/js/create-post/views/preview

define([
    "backbone",    
    "requirejs-text!../templates/preview.html"
], function( Backbone, tpl ) {

    var view = Backbone.View.extend({
        template : _.template( tpl ),       

        initialize : function( options ) {

        },

        render : function() {
            this.$el.empty().append( this.template() );
            return this;
        }

    });

    return view;
});

Так же как и в случае с CreateView, обновим MainView добавив отрисовку вновь созданного отображения.

...
var previewView = new PreviewView({
    model : this.model
});    

this.$("[data-eid=preview-view]").empty().append( previewView.render().$el );
...

TitleView

Создадим TitleView assets/js/create-post/views/title и assets/js/create-post/templates/title

<!-- assets/js/create-post/templates/title -->
<div class="row post-title">
    <div class="col-md-12">
        <div><%= title %></div>
    </div>
</div>
// assets/js/create-post/views/title

define([
    "backbone",    
    "requirejs-text!../templates/title.html"
], function( Backbone, tpl ) {

    var view = Backbone.View.extend({
        template : _.template( tpl ),       

        initialize : function( options ) {
            // При изменения поля "заголовок" в модели - перерисовывем 
            // отображение
            this.listenTo( this.model, "change:title", this.render );
        },

        render : function() {
            // Передаем данные из модели в шаблон
            this.$el.empty().append( this.template( this.model.toJSON() ) );
            return this;
        }

    });

    return view;
});

Добавим в PreviewView код для отображения TitleView. assets/js/create-post/views/preview:

// assets/js/create-post/views/preview

define([
    "backbone",    
    "../views/title",
    "requirejs-text!../templates/preview.html"
], function( Backbone, TitleView, tpl ) {

    var view = Backbone.View.extend({
        template : _.template( tpl ),       

        initialize : function( options ) {

        },

        render : function() {
            this.$el.empty().append( this.template() );
            
            var titleView = new TitleView({
                model : this.model
            });
            
            this.$("[data-eid=title-view]").empty().append( titleView.render().$el );
            
            return this;
        }

    });

    return view;
});

Теперь по мере ввода текста в поле "Заголовок" в CreateView, благодаря передаче данных через модель, TitleView будет соотвественно изменяться.

TextView

Следующее на очереди отображение - TextView, которое должно реагировать на изменение поля "Текст" в CreateView, преобразовывать Markdown-текст в HTML и выводить его на экран. Для преобразования Markdown в HTML воспользуемся библотекой marked. Для начала установим ее при помощи bower:

node_modules/bower/bin/bower install marked --save

И, конечно же, не забудем обновить конфигурационный файл RequireJS

node_modules/grunt-cli/bin/grunt update-requirejs

Теперь приступим к созданию TextView. assets/js/create-post/templates/text и assets/js/create-post/views/text:

<!-- assets/js/create-post/templates/text -->
<div class="row post-text">
    <div class="col-md-12 ">
        <%= html %>
    </div>
</div>
// assets/js/create-post/views/text

define([
    "backbone",    
    "marked",
    "requirejs-text!../templates/text.html"    
], function( Backbone, marked, tpl ) {

    var view = Backbone.View.extend({
        template : _.template( tpl ),       

        initialize : function( options ) {
            // При изменения поля "текст" в модели - перерисовывем 
            // отображение
            this.listenTo( this.model, "change:text", this.render );
        },

        render : function() {
            var html = marked( this.model.get("text") );
            
            // Передаем в шаблон HTML-представление текста
            this.$el.empty().append( this.template( {
                html : html
            } ) );
            return this;
        }

    });

    return view;
});

Не забудем так же обновить PreviewView для отрисовки нового отображения assets/js/create-view/views/preview:

...
var textView = new TextView({
    model : this.model
});
            
this.$("[data-eid=text-view]").empty().append( textView.render().$el );
...

Теперь мы можем видеть преобразование из Markdown в HTML по мере ввода текста.

MetaView

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

assets/js/create-post/templates/meta.html и assets/js/create-post/views/meta.html

<!-- assets/js/create-post/templates/meta -->
<div class="row post-meta">
    <div class="col-md-12">
        Время чтения: ~<%= readingTime %> минут
    </div>
</div>
// assets/js/create-post/views/meta
define([
    "backbone",    
    "requirejs-text!../templates/meta.html"
], function( Backbone, tpl ) {

    var view = Backbone.View.extend({
        template : _.template( tpl ),    
        
        // Средняя скорость чтения
        SYMBOLS_PER_MINUTE : 2500,

        initialize : function( options ) {
            // При изменения поля "текс" в модели - перерисовывем 
            // отображение
            this.listenTo( this.model, "change:text", this.render );
        },

        render : function() {
            var readingTime = this.getReadingTime();
            
            // Передаем в шаблон время чтения
            this.$el.empty().append( this.template( {
                readingTime : readingTime
            } ) );
            return this;
        },
        
        // Считаем скорость чтения
        getReadingTime : function() {
            var symbolsCount = this._getSymbolsCount( this.model.get("text") );
            
            return parseFloat( symbolsCount / this.SYMBOLS_PER_MINUTE ).toFixed (2);
        },
        
        // Получаем количество символов в посте
        _getSymbolsCount : function( text ) {
            var count = text.length
            return count;
        }

    });

    return view;
});

Так же внесем измения в PreviewView assets/js/create-post/views/preview:

...
var metaView = new MetaView({
    model : this.model
});
            
this.$("[data-eid=meta-view]").empty().append( metaView.render().$el );
...

Теперь по мере ввода текста будет изменяться примерное время чтения поста. Пока вычисление времени уж очень примерное и не учитывает большое количиство символов разметки Markdow. Однако мы немного улучшим расчет в следующем разделе.

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

Unit-тесты

Важность тестирования приходит с опытом. На данный момент тестирование - это единственный способ избежать возникновения новых ошибок в процессе рефакторинга или изменения кода. Видов тестирования существует большое множество, но для frontend-а я бы выделил два основных:

  • Unit-тесты
  • Приемочные, функциональные тесты

Функциональные тесты проверяют не столько ваш код сколько ваш интерфейс и функционал. Тесты представляют собой не последовательность вызовов ваших функций, а последовательность действий пользователя вашего приложения (клик, набор текста, нажатие на кнопку). Когда запускаются функциональные тесты они имитируют работу обычного человека, который пользуется вашим приложением. Для написания таких тестов существует ряд инструментов таких как Selenium, CaspersJS, JarvisJS, Webdriver.io, DalekJS и другие. В текущем примере функциональные тесты мы рассматривать не будем.

Unit-тесты в свою очередь проверяют именно ваш код, соответствие результатов работы функции некоторым референсным значениям. Рассмотрим пример разработки тестов при помощи фреймворка Jasmine на примере нашей функции вычисления среднего времени чтения поста.

В свою очередь unit-тесты можно разделить на два типа:

  • Запускаемые в браузере
  • Запускаемые на сервере

В браузере запускаются тесты, которым нужно соответсвующее окружение, например, нужен DOM. Если бы мы хотели протестировать наши Backbone-отображения, то следовало бы запускать наши тесты в браузере. Минусом запуска тестов в браузере является относительная сложность автоматизации тестирования - для этих целей нужно подключать и настраивать PhantomJS или тот же Selenium.

Тестирование на сервере наоборот легко поддается автоматизации, так как не зависит ни от чего кроме NodeJS. Так как отображения в нашем примере довольно простые и у нас есть намерение максимально использовать grunt в процессе нашей разработки, то мы займемся тестированием именно на стороне сервера.

Первым делом нам нужно немного отрефакторить наш код. Мы хотим покрыть тестами функционал вычисления примерного времини чтения нашего поста. Сейчас он находится в Backbone-view, что услажняет нам тестирование на стороне сервера. Вынесем функции расчета в отделный файл assets/js/create-post/utils/tools.js:

define( function() {
    return {
        
        // Считаем скорость чтения
        getReadingTime : function( text, speed ) {
            var symbolsCount = this.getSymbolsCount( text );
            
            return parseFloat( symbolsCount / speed ).toFixed (2);
        },
        
        // Получаем количество символов в строке
        getSymbolsCount : function( text ) {
            var count = text.length
            return count;
        }
        
    };
} );

И соответствующим образом поправим MetaView, assets/js/create-post/views/meta:

// assets/js/create-post/views/meta
define([
    "backbone",   
    "../utils/tools",
    "requirejs-text!../templates/meta.html"
], function( Backbone, Tools, tpl ) {

    var view = Backbone.View.extend({
        template : _.template( tpl ),    
        
        // Средняя скорость чтения
        SYMBOLS_PER_MINUTE : 2500,

        initialize : function( options ) {
            // При изменения поля "текс" в модели - перерисовывем 
            // отображение
            this.listenTo( this.model, "change:text", this.render );
        },

        render : function() {
            var readingTime = Tools.getReadingTime( this.model.get("text"), this.SYMBOLS_PER_MINUTE );
            
            // Передаем в шаблон время чтения
            this.$el.empty().append( this.template( {
                readingTime : readingTime
            } ) );
            return this;
        }

    });

    return view;
});

Теперь функционал вычисления среднего времени чтения никак не связан с наличием DOM и мы можем легко тестировать наши функции на сервере. Перед тем как начать разработку тестов настроим рабочее окружение. Установим библиотеку Jasmine-node:

npm install jasmine-node --save-dev

Для запуска тестов jasmine-node необходимо указать папку, в которой jasmine-node будет искать файлы заканчивающиейся на *.spec.js. (на *spec.coffee и *spec.litcoffee если вы пишете на CoffeeScript). Я предпочитаю не выносить тесты в отдельную папку и хранить их вместе с тем кодом, который они покрывают. Создадим файл assets/js/create-post/utils/tools.spec.js со следующим содержимым:

describe("Пример теста", function() {
  it("Пример верного утверждения: true === true", function() {
    expect(true).toBe(true);
  });
  
  it("Пример не верного утверждения: true === false", function() {
    expect(true).toBe(false);
  });
});

Теперь перед нами пример самого простого теста. Глобальная функция describe создает конкретный тест, а функция it добавляет в него "утверждения" (конкретные проверки для теста). В одном файле может множетсво тестов (describe), а в каждом тесте может быть множество утверждений (it). Теперь запустим jasmine-node указав в качестве папки - текущую (корень нашего проекта):

node_modules/jasmine-node/bin/jasmine-node .

Как мы видим jasmine-node нашел спецификацию нашего теста и запустил её. Как и ожидалось в нашем тесте содержится одна ошибка. Теперь приступим к реализации тестов для нашего функционала. Для начала необходимо загрузить наш модуль (файл "tools.js") в тесте. Сложность заключается в том, что наш модуль описан в AMD-стиле, а из коробки NodeJS понимает только CommonJS-стиль. Для решения этой проблемы установим билиотеку для загрузки AMD-модулей, например, amdrequire:

npm install amdrequire --save-dev

Теперь используем её в нашем тесте, assets/js/create-post/utils/tools.spec.js:

require = require('amdrequire');

require( [ "./tools" ], function(Tools) {
   
    describe("Получение количества значащих символов в строке", function() {    
        it("Только буквы", function() {
            var text = "qwerty";

            expect( Tools.getSymbolsCount( text ) ).toBe( 6 );
        });  
    });
    
    describe("Вычисление примерного времени чтения поста", function() {    
        it("Пустая строка", function() {
            var text = "";

            expect( Tools.getReadingTime( text, 2500 ) == 0.00 ).toBe( true );
        });  
        
        it("Проверка алгоритма: 6 / 3", function() {
            var text = "qwerty";

            expect( Tools.getReadingTime( text, 3 ) == 2 ).toBe( true );
        });  
        
        it("Значение - вещественное число, округление до 2 знаков: 2 / 2500.", function() {
            var text = "ab";

            expect( Tools.getReadingTime( text, 2500 ) == 0.00 ).toBe( true );
        });  
    });
    
});

И так - простой набор тестов реализован, теперь добавим в grunt задачу для запуска наших тестов. Для этого установим пакет grunt-jasmine-node:

npm install grunt-jasmine-node --save-dev

И обновим конфигурационный файл grunt Gruntfile.js

module.exports = function(grunt) {    
    // Конфигуграция задач
    grunt.initConfig({
        bowerRequirejs : {
            target: {
                // Путь к конфигурационному файлу RequireJS
                rjsConfig: 'assets/js/requirejs-config.js'
            },
            options: {
                // Опция указывающая, что зависимости установленных пакетов так же следует
                // добавлять в конфигурационный файл RequireJS
                // Например: при установке "bootstrap" добавится две записи: "bootstrap" и "jquery"
                transitive: true
            }
        },
        
        jasmine_node: {
            options: {
                forceExit: true,
                match: '.',
                matchall: false,
                extensions: 'js',
                specNameMatcher: 'spec'
            },
            all: ['.']
        }
    });

    // Загружаем задачи
    grunt.loadNpmTasks('grunt-bower-requirejs');
    grunt.loadNpmTasks('grunt-jasmine-node');
    
    // Создаем другое имя задачи по которому мы будем ее вызывать
    grunt.registerTask('update-requirejs', ['bowerRequirejs']);    
    grunt.registerTask('test', ['jasmine_node']);    
}

Теперь запускать тесты можно с помощью задачи grunt:

node_modules/grunt-cli/bin/grunt test

Чтож, теперь давайте немного улучшим наш алгоритм определения среднего времени чтения блог-поста:

  • Изменим функцию получения количества символов так, чтобы она считала только значащие символы (исключая пробелы, знаки припинания, markdown-разметку)
  • Изменим функцию вычисления времени так, чтобы она выдавала более человеко-читаемые формулировки ( "меньше минуты", "около 3-ех минут", "меньше 5 минут", "около 10 минут", "стоит запастись терпением")
Помимо облегчения рефакторинга - важной функцией тестов ялвяется самодокументирование кода. Читая хорошо написанные тесты легко понять что делает определеный функционал и как его использовать. Перед тем как реализовать запланированный функционал вначале напишем для него тесты, assets/js/create-post/utils/tools.spec.js:

require = require('amdrequire');

require( [ "./tools" ], function(Tools) {
   
    describe("Получение количества значащих символов в строке.", function() {    
        it("Только буквы", function() {
            var text = "qwerty";

            expect( Tools.getSymbolsCount( text ) ).toBe( 6 );
        });  
        
        it("Исключение знаков препинания", function() {
            var text = "qwe,,..  ,rty,..!   ";

            expect( Tools.getSymbolsCount( text ) ).toBe( 6 );
        });
        
        it("Исключение специальных знаков", function() {
            var text = "qwe##r#__t__-=*";

            expect( Tools.getSymbolsCount( text ) ).toBe( 6 );
        });
    });
    
    describe("Вычисление примерного времени чтения поста.", function() {    
        it("Пустая строка", function() {
            var text = "";

            expect( Tools.getReadingTime( text, 2500 ) == 0.00 ).toBe( true );
        });  
        
        it("Проверка алгоритма: 6 / 3", function() {
            var text = "qwerty";

            expect( Tools.getReadingTime( text, 3 ) == 2 ).toBe( true );
        });  
        
        it("Значение - вещественное число, округление до 2 знаков: 2 / 2500.", function() {
            var text = "ab";

            expect( Tools.getReadingTime( text, 2500 ) == 0.00 ).toBe( true );
        });  
    });
    
    describe("Получение человеко-читаемого обозначения времени.", function() {    
        it("Меньше минуты", function() {            
            expect( Tools.getReadingTimeText( 0.5 ) == "меньше минуты" ).toBe( true );
            expect( Tools.getReadingTimeText( 1.5 ) == "меньше минуты" ).toBe( true );
        });  
        
        it("Около трех минут", function() {            
            expect( Tools.getReadingTimeText( 1.6  ) == "около трех минут" ).toBe( true );
            expect( Tools.getReadingTimeText( 2.5  ) == "около трех минут" ).toBe( true );
            expect( Tools.getReadingTimeText( 3    ) == "около трех минут" ).toBe( true );            
            expect( Tools.getReadingTimeText( 3.5  ) == "около трех минут" ).toBe( true );            
        });  
        
        it("Меньше 5 минут", function() {            
            expect( Tools.getReadingTimeText( 3.6 ) == "меньше 5 минут" ).toBe( true );
            expect( Tools.getReadingTimeText( 4   ) == "меньше 5 минут" ).toBe( true );
            expect( Tools.getReadingTimeText( 5   ) == "меньше 5 минут" ).toBe( true );
            expect( Tools.getReadingTimeText( 5.5 ) == "меньше 5 минут" ).toBe( true );
        });
        
        it("Около 10 минут", function() {            
            expect( Tools.getReadingTimeText( 5.6 ) == "около 10 минут" ).toBe( true );
            expect( Tools.getReadingTimeText( 7   ) == "около 10 минут" ).toBe( true );
            expect( Tools.getReadingTimeText( 12  ) == "около 10 минут" ).toBe( true );
        });
        
        it("Стоит запастись терпением", function() {            
            expect( Tools.getReadingTimeText( 13 ) == "стоит запастись терпением" ).toBe( true );
            expect( Tools.getReadingTimeText( 30 ) == "стоит запастись терпением" ).toBe( true );
        });
    });
    
});

Теперь у нас простая задача - сделать так, чтобы наши тесты проходили. Отредактируем файл assets/js/create-post/utils/tools:

define( function() {
    return {
        
        // Считаем скорость чтения
        getReadingTime : function( text, speed ) {
            var symbolsCount = this.getSymbolsCount( text );
            
            return parseFloat( symbolsCount / speed ).toFixed (2);
        },
        
        getReadingTimeText : function( minutes ) {
            var text = "стоит запастись терпением";
            
            if ( minutes <= 1.5 ) {
                return "меньше минуты";
            }
            
            if ( minutes <= 3.5 ) {
                return "около трех минут";
            }
            
            if ( minutes <= 5.5 ) {
                return "меньше 5 минут";
            }
            
            if ( minutes <= 12 ) {
                return "около 10 минут";
            }
                
            return text;                
        },
        
        // Получаем количество символов в строке
        getSymbolsCount : function( text ) {
            // Предварительно уберем все незначащие символы
            var re;
            var special = " .,!#?-_+*=[]{}()<>$@%^&";
            
            for( var i = 0; i < special.length; i++ ) {
                var s = special[i];
                
                re = new RegExp("\\" + s, "g");
                text = text.replace( re, '' );
            };
            
            var count = text.length
            return count;
        }
        
    };
} );

Теперь обновим MetaView, так чтобы она использовала новую функцию getReadingTimeText. assets/js/create-post/templates/meta и assets/js/create-post/views/meta:

<!-- assets/js/create-post/templates/meta -->
<div class="row post-meta">
    <div class="col-md-12">
        Время чтения: <%= readingTime %>
    </div>
</div>
// assets/js/create-post/views/meta
define([
    "backbone",   
    "../utils/tools",
    "requirejs-text!../templates/meta.html"
], function( Backbone, Tools, tpl ) {

    var view = Backbone.View.extend({
        template : _.template( tpl ),    
        
        // Средняя скорость чтения
        SYMBOLS_PER_MINUTE : 2500,

        initialize : function( options ) {
            // При изменения поля "текс" в модели - перерисовывем 
            // отображение
            this.listenTo( this.model, "change:text", this.render );
        },

        render : function() {
            var readingTime = Tools.getReadingTimeText( Tools.getReadingTime( this.model.get("text"), this.SYMBOLS_PER_MINUTE ) );
            
            // Передаем в шаблон время чтения
            this.$el.empty().append( this.template( {
                readingTime : readingTime
            } ) );
            return this;
        }

    });

    return view;
});
Препроцессор LESS

Займемся теперь внешним видом нашего приложения. Для создания CSS стилей мы воспользуемся препроцессором. Препроцессоры CSS - это языки, призванные исправить недостатки CSS. Препроцессор предлагает альтернативный синтаксис и новые возможности такие как: переменные, примеси, продвинутое наследование и т. п. Затем код написанный на препроцессоре конвертируется в обычный CSS. Существует несколько популярных на данный момент препроцессоров это: LESS, SASS, Stylus. В нашем примере мы воспользуемся препроцессором LESS. Начнем с того, что создадим две папки: css и less. И создадим файл assets/less/main.less:

// Цвет заголовка и мета-информации
@titleColor : #8E8E8E;

#content {
    margin-top: 50px;
}

.post-title {
    min-height: 50px;
    font-size: 24px;
    font-style: italic;
    margin-top: 24px;
    color: @titleColor;
}

.post-meta {
    text-align: right;
    color : @titleColor;
}

.post-text {
    border: 1px solid rgb(208, 208, 208);
    border-radius: 4px;
    margin-top: 6px;
    min-height: 50px;
}

Для того, чтобы преобразовать less-файл в css-файл нам понадобиться установить одноименный пакет less.js:

npm install less --save-dev

И сразу же установим grunt-задачу grunt-contrib-less

npm install grunt-contrib-less --save-dev

Добавим задачу в конфигурационный файл Gruntfile.js:

module.exports = function(grunt) {    
    // Конфигуграция задач
    grunt.initConfig({
        bowerRequirejs : {
            target: {
                // Путь к конфигурационному файлу RequireJS
                rjsConfig: 'assets/js/requirejs-config.js'
            },
            options: {
                // Опция указывающая, что зависимости установленных пакетов так же следует
                // добавлять в конфигурационный файл RequireJS
                // Например: при установке "bootstrap" добавится две записи: "bootstrap" и "jquery"
                transitive: true
            }
        },
        
        jasmine_node: {
            options: {
                forceExit: true,
                match: '.',
                matchall: false,
                extensions: 'js',
                specNameMatcher: 'spec'
            },
            all: ['.']
        },
        
        less: {
            compile: {                
                files: {
                    'assets/css/main.css': 'assets/less/main.less'
                }
            }
        }
            
    });

    // Загружаем задачи
    grunt.loadNpmTasks('grunt-bower-requirejs');
    grunt.loadNpmTasks('grunt-jasmine-node');
    grunt.loadNpmTasks('grunt-contrib-less');
    
    // Создаем другое имя задачи по которому мы будем ее вызывать
    grunt.registerTask('update-requirejs', ['bowerRequirejs']);    
    grunt.registerTask('test', ['jasmine_node']);        
}

Теперь запустив grunt-задачу "less" мы получим файл assets/css/main.css:

node_modules/grunt-cli/bin/grunt less
#content {
  margin-top: 50px;
}
.post-title {
  min-height: 50px;
  font-size: 24px;
  font-style: italic;
  margin-top: 24px;
  color: #8e8e8e;
}
.post-meta {
  text-align: right;
  color: #8e8e8e;
}
.post-text {
  border: 1px solid #d0d0d0;
  border-radius: 4px;
  margin-top: 6px;
  min-height: 50px;
}

Добавим загрузку файла assets/css/main.css в файл index.html. Теперь наши стили, описанные в less-файле применились к нашей странице. Используя LESS мы больше не будем работать с CSS-файлами напрямую, поэтому сразу займемся их минификацией. Для этого установим очередную grunt-задачу grunt-contrib-cssmin

npm install grunt-contrib-cssmin --save-dev

Добавим задачу в конфигурационный файл grunt Gruntfile.js:

module.exports = function(grunt) {    
    // Конфигуграция задач
    grunt.initConfig({
        bowerRequirejs : {
            target: {
                // Путь к конфигурационному файлу RequireJS
                rjsConfig: 'assets/js/requirejs-config.js'
            },
            options: {
                // Опция указывающая, что зависимости установленных пакетов так же следует
                // добавлять в конфигурационный файл RequireJS
                // Например: при установке "bootstrap" добавится две записи: "bootstrap" и "jquery"
                transitive: true
            }
        },
        
        jasmine_node: {
            options: {
                forceExit: true,
                match: '.',
                matchall: false,
                extensions: 'js',
                specNameMatcher: 'spec'
            },
            all: ['.']
        },
        
        less: {
            compile: {                
                files: {
                    'assets/css/main.css': 'assets/less/main.less'
                }
            }
        },
        
        cssmin: {
            // Минифицируем все файлы в папке assets/css и 
            // добавим им расширение ".min.css"
            target: {
                files: [{
                    expand: true,
                    cwd: 'assets/css',
                    src: ['*.css', '!*.min.css'],
                    dest: 'assets/css',
                    ext: '.min.css'
            }]
          }
        }
            
    });

    // Загружаем задачи
    grunt.loadNpmTasks('grunt-bower-requirejs');
    grunt.loadNpmTasks('grunt-jasmine-node');
    grunt.loadNpmTasks('grunt-contrib-less');
    grunt.loadNpmTasks('grunt-contrib-cssmin');
    
    // Создаем другое имя задачи по которому мы будем ее вызывать
    grunt.registerTask('update-requirejs', ['bowerRequirejs']);    
    grunt.registerTask('test', ['jasmine_node']);        
}

Теперь выполним grunt-задачу "cssmin" мы получим минифицированный файл assets/css/main.min.css

#content{margin-top:50px}.post-title{min-height:50px;font-size:24px;font-style:italic;margin-top:24px;color:#8e8e8e}.post-meta{text-align:right;color:#8e8e8e}.post-text{border:1px solid #d0d0d0;border-radius:4px;margin-top:6px;min-height:50px}
Сборка RequireJS-файлов

Разобравшись с минификацией CSS-файлов нужно сделать тоже и с javascript-файлами. Однако перед тем как минифицировать сначала нужно собрать все наши скрипты в один файл особым образом в порядке разрешения их зависимостей. Задача не самая простая, но к с счастью разработчики RequireJS так же предоставляют и инструмент для сборки и минификации скриптов написанных при помощи RequireJS. Инструмент называется r.js.

Как обычно установим grunt-задачу grunt-contrib-requirejs:

npm install grunt-contrib-requirejs --save-dev

И обновим Gruntfile.js:

...
requirejs: {
    compile: {
        options: {
            baseUrl: "assets/js",
            mainConfigFile: "assets/js/requirejs-config.js",
            name: "app",
            out: "assets/js/dist.js"
        }
    }
}
...

Теперь выполнив grunt-задачу "requirejs" мы получим один минифицированный файл со всеми нашими модулями собранными в нужно порядке. Теперь, если мы подменим вызов "app" на вызов "dist" в файле assets/js/requirejs-config.js, то получим окончательный результат - production-версию нашего приложения.

Grunt задача "build"

Теперь c помощью grunt мы умеем делать следующие вещи:

  • Обновлять конфигурацию RequireJS на основе файла "bower.json"
  • Запускать тесты
  • Преобразовывать LESS в CSS
  • Минифицировать CSS-файлы
  • Склеивать и минифицировать AMD-скрипты
Создадим одну задачу, которая будет выполнять все эти действия сразу. Для это добавим в конфигурационный файл grunt Gruntfile.js следующий код:

grunt.registerTask('build', [ 'bowerRequirejs', 'jasmine_node', 'less', 'cssmin', 'requirejs' ]);

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