Статьи

Функциональное тестирование Node.js с использованием Mocha и Zombie.js

В гибкой разработке разработчики пишут тесты перед реализацией функции. Модульные тесты уже должны быть в вашей ADN, поэтому давайте поговорим о функциональных тестах . Функциональные тесты — это технический перевод приемочных тестов, написанных заказчиком на оборотной стороне пользовательской истории . Давайте посмотрим, как выполнить функциональное тестирование для приложений Node.js.

Контакт должен быть легким

Клиент хочет страницу контактов. Она написала следующую историю пользователя:

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

Вот макет, связанный с историей:

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

  • На странице контактов должна отображаться форма контакта
  • Страница контактов должна отказаться от пустых представлений
  • На странице контактов следует отказаться от частичных представлений
  • Страница контактов должна содержать значения при частичной подаче
  • Страница контактов должна отклонять недействительные электронные письма
  • Страница контактов должна принимать полные представления

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

Сначала напишите тесты

При разработке через тестирование, вы должны сначала написать тесты. Давайте загрузим файл с именем test/functional/contact.js:

describe('contact page', function() {
  it('should show contact a form');
  it('should refuse empty submissions');
  it('should refuse partial submissions');
  it('should keep values on partial submissions');
  it('should refuse invalid emails');
  it('should accept complete submissions');
});

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

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

$ npm install -g mocha

Теперь вы можете запустить новый (пустой) функциональный тест:

$ mocha test/functional/contact.js

  ......

  ✔ 6 tests complete (2ms)
  • 6 tests pending

Пока все хорошо: все тесты находятся на рассмотрении.

Функциональные тесты как спецификация приложения

Перед реализацией функциональных тестов давайте переключимся на другой способ вывода результатов тестов мокко: репортер «spec». Для этого либо добавьте эту --reporter specопцию при вызове mochaкоманды, либо, что еще лучше, добавьте ее в test/mocha.optsфайл, и вам никогда не придется добавлять ее снова. А поскольку вы добавляете параметры командной строки для mocha, выберите --recursiveопцию, которая указывает mocha искать файлы JavaScript с describe()инструкциями во всей test/иерархии папок.

--reporter spec
--recursive

Теперь запуск теста стал проще (не нужно указывать тест для запуска) и требует совершенно нового измерения:

$ mocha

  contact page
    - should show a contact form
    - should refuse empty submissions
    - should refuse partial submissions
    - should keep values on partial submissions
    - should refuse invalid emails
    - should accept complete submissions


  ✔ 6 tests complete (2ms)
  • 6 tests pending

«Спекуляция» репортер использует преимущества describe()и it()описание для отображения отформатированного списка требований приложений. Таким образом, вы даже можете позволить владельцу Продукта посмотреть результаты теста, и тесты документируют код.

Настройте приложение

Функциональные тесты должны просматривать специальную версию веб-приложения — «тестовое» приложение. Эта версия должна использовать тестовые данные, тестовую конфигурацию и не должна мешать разработке или рабочей версии. Хорошей практикой является настройка тестовых данных и запуск нового экземпляра приложения внутри каждого функционального теста. Идеальное место для размещения связанного кода — это before()функция, которую mocha выполняет … перед тестами. И, конечно же, не забудьте закрыть тестовый экземпляр приложения после его завершения.

// force the test environment to 'test'
process.env.NODE_ENV = 'test';
// get the application server module
var app = require('../../server');

describe('contact page', function() {
  before(function() {
    this.server = http.createServer(app).listen(3000);
  });

  it('should show contact a form');
  it('should refuse empty submissions');
  // ...

  after(function(done) {
    this.server.close(done);
  });
});

Совет : before()Функция запускает сервер на пользовательском порту, чтобы избежать побочных эффектов для версии приложения. Чтобы разрешить запуск сценария сервера Node.js функциональными тестами, его следует экспортировать как модуль и запускать (или «слушать») только при непосредственном вызове. Вот как server.jsдолжен заканчиваться основной файл:

// app is a callback function or an express application
module.exports = app;
if (!module.parent) {
  http.createServer(app).listen(process.env.PORT, function(){
    console.log("Server listening on port " + app.get('port'));
  });
}

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

Настройте браузер

Функциональные тесты — это сценарии, имитирующие действия пользователя через браузер. Вам понадобится простой браузер для тестовой страницы контактов. Фактически, безголовый браузер (без GUI) с ограниченными возможностями JavaScript должен справиться с задачей. Это именно то, что Zombie.js , и это идеальный инструмент для работы.

Добавить zombieв package.jsonфайл:

{
  "name": "example",
  "version": "0.0.1",
  "private": true,
  "devDependencies": {
    "zombie": "2.0.0-alpha11",
  }
}

Затем установите его (с помощью npm install) и снова отредактируйте файл функционального теста контактов:

process.env.NODE_ENV = 'test';
var app = require('../../server');
// use zombie.js as headless browser
var Browser = require('zombie');

describe('contact page', function() {
  before(function() {
    this.server = http.createServer(app).listen(3000);
    // initialize the browser using the same port as the test application
    this.browser = new Browser({ site: 'http://localhost:3000' });
  });

  // load the contact page
  before(function(done) {
    this.browser.visit('/contact', done);
  });

  it('should show contact a form');
  it('should refuse empty submissions');
  // ...

});

Вы можете добавить столько before()вызовов, сколько пожелаете в функциональном тесте; Мокко выполняет их в серии. Второй before()вызов асинхронный — вы можете узнать по doneобратному вызову, переданному в качестве параметра before()функции аргумента. В этой функции метод браузера visit()загружает страницу контакта, ожидает, пока страница полностью загрузится и обработает события, а затем вызывает doneфункцию обратного вызова, что позволяет mocha запустить реальные тесты.

Самый первый тест

Функциональный тест — это простая функция обратного вызова. Мокко считает тест действительным, если функция не выдает никакой ошибки. Вы можете либо проверить утверждения вручную и выдать ошибки при неожиданном результате, либо использовать модуль подтверждения. Node.js поставляется с в assertмодуле , который является более чем достаточно для контакта страниц функциональных тестов.

// ...
// load Node.js assertion module
var assert = require('assert');

describe('contact page', function() {
  // ...

  it('should show contact a form', function() {
    assert.ok(this.browser.success);
    assert.equal(this.browser.text('h1'), 'Contact');
    assert.equal(this.browser.text('form label'), 'First NameLast NameEmailMessage');
  });

  // ...

});

Эти три утверждения довольно просты. Они проверяют, возвращает ли страница HTTP-код 200, является ли она страницей контактов, и содержит ли она форму с несколькими полями, помеченными как в макете. Функция браузера Zombie  text()принимает селектор CSS в качестве аргумента и возвращает текст соответствующего элемента (ов) в DOM. Это очень простой способ реализовать утверждения функциональных тестов.

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

$ mocha

  contact page
    1) should show a contact form
    - should refuse empty submissions
    - should refuse partial submissions
    - should keep values on partial submissions
    - should refuse invalid emails
    - should accept complete submissions


  ✖ 1 of 6 tests failed:

  1) contact page should show a contact form:
     Error:...

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

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

$ mocha

  contact page
    ✓ should show a contact form
    - should refuse empty submissions
    - should refuse partial submissions
    - should keep values on partial submissions
    - should refuse invalid emails
    - should accept complete submissions


  6 tests complete (20 ms)
  5 tests pending

Асинхронные тесты

Второй тест должен проверить, что отправка пустой формы снова отображает форму с ошибкой. Независимо от того, решите ли вы реализовать эту сторону на стороне клиента или на стороне сервера (вы должны сделать и то, и другое), это подразумевает взаимодействие с формой контакта. Браузер Zombie предоставляет полнофункциональный API для взаимодействия с содержимым страницы , включая нажатие кнопки отправки с помощью pressButton():

  it('should refuse empty submissions', function(done) {
    var browser = this.browser;
    browser.pressButton('Send', function(error) {
      if (error) return done(error);
      assert.ok(browser.success);
      assert.equal(browser.text('h1'), 'Contact');
      assert.equal(browser.text('div.alert'), 'Please fill in all the fields');
      done();
    });
  });

pressButton()Метод является асинхронным; он нажимает кнопку, отправляет форму, загружает ответ сервера и выполняет все события браузера, пока не останется никого. Вот почему он принимает обратный вызов в качестве параметра — а также почему тест с включенным мокко должен также рассматриваться как асинхронный. Поэтому в it()тесте мокко используется doneобратный вызов, который вызывается по завершении теста. Мокко имеет дело с асинхронными функциями, it()как и в before().

Судя по всему, этот тест работает нормально. Однако, если какое-либо из утверждений когда-либо завершится неудачей, поток выполнения pressButton()обратного вызова останавливается и  done() никогда не вызывается. Таким образом, Мокко сообщит, что страница истекла, хотя проблема иного характера.

Зомби поддерживает обещания (питание от q ), чтобы преодолеть эту проблему. Обещания неявно распространяют ошибки до последнего вызова и поэтому совместимы с тестами. Вот как переписать предыдущий тест с помощью Promises:

  it('should refuse empty submissions', function(done) {
    var browser = this.browser;
    browser.pressButton('Send').then(function() {
      assert.ok(browser.success);
      assert.equal(browser.text('h1'), 'Contact');
      assert.equal(browser.text('div.alert'), 'Please fill in all the fields');
    }).then(done, done);
  });

Теперь, если одно из утверждений завершается неудачно и выдает ошибку, ошибка попадает в первый метод  then () , а затем передается во второй в  then() качестве аргумента для первого обратного вызова, что приводит к done(error). Мокко будет считать, что тест не пройден. Если не выдано никакой ошибки, второй обратный вызов, переданный последнему, then()будет вызван без аргументов вообще, что приведет к done(). Мокко будет считать тест пройденным.

На then(done, done)первый взгляд конструкция может показаться странной, но это эффективный способ использовать один и тот же обратный вызов для ошибки и успеха, и это официальный способ заставить зомби и мокко работать вместе .

Как добиться цели

Теперь, когда вы знаете, как выполнять асинхронные тесты с mocha и zombie.js, вам не составит труда разобраться с остальными тестами:

  it('should refuse partial submissions', function(done) {
    var browser = this.browser;
    browser.fill('first_name', 'John');
    browser.pressButton('Send').then(function() {
      assert.ok(browser.success);
      assert.equal(browser.text('h1'), 'Contact');
      assert.equal(browser.text('div.alert'), 'Please fill in all the fields');
    }).then(done, done);
  });

  it('should keep values on partial submissions', function(done) {
    var browser = this.browser;
    browser.fill('first_name', 'John');
    browser.pressButton('Send').then(function() {
      assert.equal(browser.field('first_name').value, 'John');
    }).then(done, done);
  });

  it('should refuse invalid emails', function(done) {
    var browser = this.browser;
    browser.fill('first_name', 'John');
    browser.fill('last_name', 'Doe');
    browser.fill('email', 'incorrect email');
    browser.fill('message', 'Lorem ipsum');
    browser.pressButton('Send').then(function() {
      assert.ok(browser.success);
      assert.equal(browser.text('h1'), 'Contact');
      assert.equal(browser.text('div.alert'), 'Please check the email address format');
    }).then(done, done);
  });

  it('should accept complete submissions', function(done) {
    var browser = this.browser;
    browser.fill('first_name', 'John');
    browser.fill('last_name', 'Doe');
    browser.fill('email', '[email protected]');
    browser.fill('message', 'Lorem ipsum');
    browser.pressButton('Send').then(function() {
      assert.ok(browser.success);
      assert.equal(browser.text('h1'), 'Message Sent');
      assert.equal(browser.text('p'), 'Thank you for your message. We\'ll answer you shortly.'');
    }).then(done, done);
  });

Запустите новые тесты. Они должны потерпеть неудачу. Теперь вам просто нужно реализовать достаточно серверной логики для прохождения тестов:

$ mocha

  contact page
    ✓ should show a contact form
    ✓ should refuse empty submissions (207ms)
    ✓ should refuse partial submissions (138ms)
    ✓ should keep values on partial submissions (142ms)
    ✓ should refuse invalid emails (142ms)
    ✓ should accept complete submissions (143ms)

  6 tests complete (1 seconds)

И вы сделали! Или ты?

Ваши тесты неверны

Вы считаете эти тесты правильными? Попробуйте изменить порядок их размещения, поместив последний первым, и снова запустите пакет:

$ mocha

  contact page
    ✓ should accept complete submissions (191ms)
    1) should show a contact form
    2) should refuse empty submissions
    3) should refuse partial submissions
    4) should keep values on partial submissions
    5) should refuse invalid emails

  ✖ 5 of 6 tests failed:
     ...

Первый тест проходит, но все последующие тесты не пройдены. Зачем? Все просто: before()страница загружает контактную форму раз и навсегда. Тест «форма контакта должна принимать полные представления» изменяет страницу браузера на страницу «сообщение отправлено», где форму контакта нигде не найти. Логически, все последующие тесты терпят неудачу, потому что они пытаются отправить форму, которая не существует.

Решение? Переименуйте before()шаг загрузки формы в beforeEach(), чтобы mocha перезагрузил контактную форму перед выполнением каждого it()оператора.

Итак, вот последняя функциональная проверка страницы контактов:

process.env.NODE_ENV = 'test';
var app = require('../../server');
var assert = require('assert');
var Browser = require('zombie');

describe('contact page', function() {

  before(function() {
    this.server = http.createServer(app).listen(3000);
    this.browser = new Browser({ site: 'http://localhost:3000' });
  });

  // load the contact page before each test
  beforeEach(function(done) {
    this.browser.visit('/contact', done);
  });

  it('should show contact a form', function() {
    assert.ok(this.browser.success);
    assert.equal(this.browser.text('h1'), 'Contact');
    assert.equal(this.browser.text('form label'), 'First NameLast NameEmailMessage');
  });

  it('should refuse empty submissions', function(done) {
    var browser = this.browser;
    browser.pressButton('Send').then(function() {
      assert.ok(browser.success);
      assert.equal(browser.text('h1'), 'Contact');
      assert.equal(browser.text('div.alert'), 'Please fill in all the fields');
    }).then(done, done);
  });

  it('should refuse partial submissions', function(done) {
    var browser = this.browser;
    browser.fill('first_name', 'John');
    browser.pressButton('Send').then(function() {
      assert.ok(browser.success);
      assert.equal(browser.text('h1'), 'Contact');
      assert.equal(browser.text('div.alert'), 'Please fill in all the fields');
    }).then(done, done);
  });

  it('should keep values on partial submissions', function(done) {
    var browser = this.browser;
    browser.fill('first_name', 'John');
    browser.pressButton('Send').then(function() {
      assert.equal(browser.field('first_name').value, 'John');
    }).then(done, done);
  });

  it('should refuse invalid emails', function(done) {
    var browser = this.browser;
    browser.fill('first_name', 'John');
    browser.fill('last_name', 'Doe');
    browser.fill('email', 'incorrect email');
    browser.fill('message', 'Lorem ipsum');
    browser.pressButton('Send').then(function() {
      assert.ok(browser.success);
      assert.equal(browser.text('h1'), 'Contact');
      assert.equal(browser.text('div.alert'), 'Please check the email address format');
    }).then(done, done);
  });

  it('should accept complete submissions', function(done) {
    var browser = this.browser;
    browser.fill('first_name', 'John');
    browser.fill('last_name', 'Doe');
    browser.fill('email', '[email protected]');
    browser.fill('message', 'Lorem ipsum');
    browser.pressButton('Send').then(function() {
      assert.ok(browser.success);
      assert.equal(browser.text('h1'), 'Message Sent');
      assert.equal(browser.text('p'), 'Thank you for your message. We\'ll answer you shortly.'');
    }).then(done, done);
  });

  after(function(done) {
    this.server.close(done);
  });

});

Я уверен, что вы можете написать контактную форму, которая удовлетворяет всем этим тестам. Но как насчет проверки того факта, что контактная форма действительно отправляет электронное письмо на адрес владельца сайта? Что ж, прочитайте еще раз начало этого поста: владелец продукта никогда не писал такой приемочный тест, так что это другая история. Или это может быть вспомогательный аргумент для другого поста, озаглавленного «почему разработчики должны помогать владельцам продуктов писать приемочные тесты».

Вы должны сделать тесты

Помимо асинхронности, написание функциональных тестов в Node.js довольно просто. Используя модули assert, mocha и zombie.js, вы сможете реализовать большинство сценариев, представленных владельцем продукта, и никогда больше не проводить ручную проверку браузера.

Однако следует помнить о некоторых ограничениях. Zombie.js не настоящий браузер, поэтому он может вести себя иначе, чем ваш (возглавляемый) браузер на рабочем столе. При работе со сложным клиентским JavaScript вы, вероятно, должны заменить zombie.js на phantomjs , который является полнофункциональным браузером webkit. Единственная проблема заключается в том, что он также намного медленнее zombie.js и поставляется со своим собственным стеком выполнения JavaScript.

И, несмотря на относительную молодость стека Node.js, этот пост показывает, что Node уже совместим с управляемым тестами рабочим процессом разработки и с гибкими методологиями. Так что не ждите, прыгайте на подножку!