Тестирование веб-приложений — это объемная и сложная тема. Обычно различают функцио­нальное, нагрузочное, регрессивное, модульное и интеграционное тестирование. В этой статье мы будем говорить лишь о модульном тестировании (от англ. unit), так как это наиболее часто встречающийся вид тестирования в мире веб-разработки.

В идеале тестирование должно быть неотъемлемым процессом разработки и, опять таки, в идеале тестирование должно основываться и придерживаться какой-нибудь методологии, например, TDD. Разработка через тестирование (от англ. test-driven development) — это методика, предлагающая вам написать сначала тест, а затем уже код, проходящий этот тест и только после этого, при необходимости, заниматься его рефакторингом. Однако, как мы все с вами хорошо знаем, заставить себя писать код для кода — задача из разряда сверхъестественного. Поэтому рассматривать методологии в этой статье я не вижу смысла.

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

Шаг нулевой. Xo-xo-xo

Нет, в заголовок не закралась шутка и это не новогоднее настроение, просто сейчас речь пойдет о таком инструменте как XO. XO — это обертка великолепного инструмента для проверки кода ESLint от Николаса Закаса, разработанная, пожалуй, самым знаменитым npm-пользователем — Синдре Сорхусом. Я не буду еще раз говорить про то, как важно держать свой код в строгом порядке, так как разнорогласия здесь неуместны.

Основная идея XO в упрощении конфигурации ESLint (имеется предустановленный конфиг) и переносе настроек из файла .eslint в файл package.json. То есть конфигурация XO будет состоять из заранее определенных правил проверки в самом пакете и их переопределении в файле package.json:

{
  "name": "...",
  ...
  "xo": {
    "space": true,
    "rules": {
      "object-curly-spacing": [2, "always"],
      "space-before-function-paren": 0
    },
    "envs": [
      "node"
    ]
  }
}

Стоит упомянуть, что ESLint — это гибкий аналог частенько вместе используемых JSHint и JSCS. Поэтому теперь необходимость использования этих инструментов полностью отпадает.

AVA, как Grunt, но для тестов

Как я уже говорил ранее, тестирование — это сложная тема и, как это не удивительно, есть большое количество инструментов, упрощающих этот процесс. Например, есть целый класс инструментов для автоматизации тестирования. Из всего многообразия (QUnit, Nodeunit, Mocha и т.д.) мне нравится AVA, написанный, все тем же Синдре Сорхусом.

Для примера напишем функцию, которая всегда возвращает true и протестируем ее

var test = require('ava');

function thisIsTrue() {
  return true;
}

test('True is true?', function(t) {
  t.is(thisIsTrue(), true);
  t.end();
});

Вот и все. Никаких сложных действий не требуется — только сравнить возвращаемое значение функцией с каким-то эталонным результатом, используя метод .is(). Если функция вернет true, то тест будет пройден.

Ava (ava.js)

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

  • test.serial() — создание очереди на основе следования тестов в файле
  • test.before() — запуск перед всеми тестами
  • test.after() — запуск после всех тестов

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

  • .pass() — пропуск теста
  • .ok() — проверка истинности первого параметра
  • .fail() — провоцирование ошибки
  • .is() — проверка равенства первого и второго параметра
  • .not() — противоположный метод для .is()
  • .same() — строгое сравнение первого и второго параметра
  • .notSame() — противоположный метод для .same()
  • .regexTest() — проверка с помощью регулярного выражения

О других вы с превеликим удовольствием сможете узнать из документации.

Тестирование, тестирование, тестирование...

Пожалуй, теперь перейдем к тестированию чего-то более реального и рассмотрим пару тестов. Для этого я предлагаю обратиться к недавно написанному плагину grunt-bower-sync, основное предназначение которого заключается в синхронизации двух директорий, на основе файла bower.json.

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

  • Чтение файла bower.json
  • Составление списка на основе выбранных в опциях секций (dependencies, devDependencies и peerDependencies)
  • Копирование всех зависимостей из директории bower_components в целевую директорию с перезаписью
  • Получение списка зависимостей в целевой директории
  • Поиск разности списков зависимостей в директории bower_components и целевой директории
  • Удаление лишних зависимостей в целевой директории

Что здесь можно протестировать? Буквально все, но делать этого я не буду. Дело в том, что меня здесь интересует лишь конечный результат.

Первым делом нужно создать директорию test/fixtures и поместить туда файлы, который потребуются для тестирования. В нашем случае это директория bower_components, содержащая фейковые директории и файлы зависимостей, а также сам файл bower.json.

Теперь нужно натравить Grunt на наши фейковые данные и заставить его отработать их. Создадим задачу в Grunt-файле и укажем все необходимые данные для работы плагина:

bowersync: {
  options: {
    bowerFile: 'test/fixtures/bower.json'
  },
  copySingle: {
    files: {
      'tmp/copySingle': 'test/fixtures/bower_components'
    }
  }
}

Затем напишем тест, который сначала получает список зависимостей в целевой директории и затем сравнивает их с эталоном:

var fs = require('fs');
var test = require('ava');

test('copySingle | Copy only one dependency', function(t) {
  var actual = fs.readdirSync('tmp/copySingle').toString();
  t.is(actual, 'jquery');
  t.end();
});

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

bowersync: {
  options: {
    bowerFile: 'test/fixtures/bower.json'
  },
  removeSingle: {
    options: {
      devDependencies: true,
      peerDependencies: true
    },
    files: {
      'tmp/removeSingle': 'test/fixtures/bower_components'
    }
  }
}

В этом случае тест будет выглядить малость сложнее:

var fs = require('fs');
var test = require('ava');
var Fsys = require('../tasks/lib/fsys');

test('removeSingle | Delete only one dependency', function(t) {
  var fsys = new Fsys({ updateAndDelete: true });

  // Сначала удаление зависимостей
  fsys.removeDependencies('tmp/removeSingle', ['bootstrap', 'salvattore']).then(function(err) {
    if (err) {
      throw new Error(err);
    }

    // И лишь затем тестирование
    var actual = fs.readdirSync('tmp/removeSingle').toString();
    t.is(actual, 'bootstrap,salvattore');
    t.end();
  });
});

К сожалению, весь список тестов написать сразу не получится из-за того, что вы разработчик и знаете как использовать свое творение. После релиза этого плагина и его применения в rwk пришлось добавить тест и немного подправить логику плагина в случае, если директории bower_components не существует или файл bower.json не имеет вообще никаких зависимостей.

Автоматизация по крупному

Сейчас для того, чтобы запустить тесты, нужно всего лишь написать в консоль npm test, однако такой подход начинает давать сбои, когда ваш проект начинает получать помощь из вне и когда:

  • Кроме вашей ОС есть и другие
  • Кроме вас в проекте могут участвовать другие люди
  • Кроме вас и ваших людей в проект могут присылать PR

Окей, здесь два варианта решения проблемы:

  • Вручную проверять каждый раз все изменения в проекте
  • Использовать Travis Ci

Travis Ci — это синоним непрерывной интеграции. Сервис, который следит за изменениями в вашем репозитории и запускает описанные вами задачи (по умолчанию npm test) каждый раз, когда вы изменяете в нем код. Кроме того каждый PR будет проверяться на прохождение уже написанных тестов и тестов, которые допишет автор PR.

Для примера обратимся к пакету windows-ls, который представляет собой имплементацию команды ls из Linux для Windows систем.

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

language: node_js
node_js:
  - "0.10"
  - "0.12"
  - iojs
  - "stable"

Кроме базовых настроек, существуют и более продвинутые. Например, можно намекнуть Трэвису, что перед выполнением любых команд, нужно установить какие-либо пакеты:

language: node_js
node_js:
  - "0.10"
  - "0.12"
  - iojs
  - "stable"

before_script:
  - npm install -g grunt-cli

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

Каждый раз при изменении содержимого репозитория и освобождения у Travis Ci юнит-сервера будет начинаться автоматическая сборка проекта и его тестирование. К сожалению, мое первое знакомство с сервисом было сложным из-за возможности тестировать код только на Linux и OS X:

Travis Ci

Пришлось избавиться от одного из тестов именно для этой платформы, используя проверку os.type() и изменить уже написанные тесты из-за сортировки файлов конкретно в этой ОС.

После проведения тестов станет доступна их история и подробный отчет о проведенных операциях и ошибках при условии, что они вообще были. Заметьте, что перед тестами также запускается XO, который описан у меня в задаче npm test:

# Копирование репозитория
$ git clone --depth=50 --branch=master https://github.com/mrmlnc/windows-ls.git mrmlnc/windows-ls

# Настройка версии Node.js
$ nvm install stable

$ node --version
v4.0.0
$ npm --version
2.14.2
$ nvm --version
0.23.3

# Установка зависимостей и запуск тестов
$ npm install 
$ npm test

> windows-ls@0.1.3 test /home/travis/build/mrmlnc/windows-ls
> xo --ignore=test/fixtures/** && node test/test.js

# Проверка кода с помощью XO
/home/travis/build/mrmlnc/windows-ls/test/test.js
  19:55  warning  Expected error to be handled  handle-callback-err
  26:58  warning  Expected error to be handled  handle-callback-err
  33:58  warning  Expected error to be handled  handle-callback-err
  40:58  warning  Expected error to be handled  handle-callback-err
  47:58  warning  Expected error to be handled  handle-callback-err
  54:58  warning  Expected error to be handled  handle-callback-err
  61:60  warning  Expected error to be handled  handle-callback-err
  68:59  warning  Expected error to be handled  handle-callback-err
  76:66  warning  Expected error to be handled  handle-callback-err

# Запуск самих тестов
✖ 9 problems (0 errors, 9 warnings)
  ✔ ls glob
  ✔ ls -a
  ✔ ls
  ✔ ls -p
  ✔ ls -lh
  ✔ ls -F
  ✔ ls -R
  ✔ ls -laF
  ✔ ls -l

  9 tests passed

Тестирование API

Если плагины для Grunt, да и любые другие пакеты, работающие внутри системы, можно назвать локальными, то внешние пакеты (веб-приложения), отвечающие на запросы из внешнего мира, тестировать немного сложнее. Дело в том, что если вы собираетесь тестировать API веб-приложения, то, скажем так, тестирование на «продакшене» недопустимо. Действительно, не будете же вы тестировать создание и удаление серверов, аккаунтов и прочие API прямо на боевом проекте? Для этого придется поднять фейковый сервер, на котором будет доступно фейковое API. Для таких нужд я часто использую Sandbox.

Sandbox позволяет в полуавтоматическом режиме строить API, используя для этого всем известные пакеты Express и Lodash, а также несколько GUI элементов. Первый отвечает за прием и отправку данных, а второй - за их обработку. К слову, можно использовать этот сервис лишь как конструктор API, так как он предоставляет код, который можно с легкостью переделать под «чистый» Express:

// Using named route parameters to simulate getting a specific user
Sandbox.define('/users/{username}', 'GET', function(req, res) {
    // retrieve users or, if there are none, init to empty array
    state.users = state.users || [];

    // route param {username} is available on req.params
    var username = req.params.username;

    // log it to the console
    console.log("Getting user " + username + " details");

    // use lodash to find the user in the array
    var user = _.find(state.users, { "username": username});
    
    return res.json(user);
});

Выводы

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

Не стоит также забывать о тестировании клиентского JavaScript-кода, с которым не все так просто. Однако, по этому поводу я ничего рассказать читателям не могу.

Ссылки

Инструменты:

Статьи на тему: