Статьи

Изучение JavaScript-ориентированной разработки на примере

Разработка через тестирование: серия прототипов роботов-кофе бариста

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

Вы, наверное, уже знакомы с автоматизированным тестированием и его преимуществами. Наличие набора тестов для вашего приложения позволяет вам с уверенностью вносить изменения в свой код, зная, что тесты окажут вам поддержку, если вы что-то сломаете. Можно пойти еще дальше и написать свои тесты, прежде чем писать код; практика, известная как разработка через тестирование (TDD).

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

Обратите внимание, что эта статья будет посвящена тестированию внешнего кода. Если вы ищете что-то сфокусированное на серверной части, обязательно ознакомьтесь с нашим курсом: Разработка через тестирование в Node.js

Что такое TDD?

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

В дополнение к основанному на тестах подходу Extreme Programming , при котором разработчики пишут тесты перед реализацией функции или модуля, TDD также облегчает рефакторинг кода; это обычно упоминается как Красный-Зеленый-Реактор Цикл .

Цикл красно-зеленого-рефактора, связанный с разработкой, управляемой тестом

  • Напишите провальный тест — напишите тест, который вызывает вашу логику и утверждает, что производится правильное поведение.

    • В модульном тесте это будет утверждение возвращаемого значения функции или проверка того, что смоделированная зависимость была вызвана как ожидалось
    • В функциональном тесте это обеспечит предсказуемое поведение пользовательского интерфейса или API в ряде действий
  • Выполнить тест — выполнить минимальный объем кода, который приводит к прохождению теста, и убедиться, что все остальные тесты продолжают проходить

  • Рефакторинг реализации — обновить или переписать реализацию, не нарушая никаких публичных контрактов, чтобы улучшить ее качество, не нарушая новые и существующие тесты

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

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

  • Модульные тесты — убедитесь, что отдельные модули приложения, такие как функции и классы, работают должным образом. Проверка утверждений, что указанные блоки возвращают ожидаемый результат для любых заданных входов
  • Интеграционные тесты — убедитесь, что совместная работа подразделений работает так, как ожидалось. Утверждения могут проверять API, пользовательский интерфейс или взаимодействия, которые могут привести к побочным эффектам (таким как ввод-вывод базы данных, ведение журнала и т. Д.)
  • Сквозные тесты — убедитесь, что программное обеспечение работает, как и ожидалось, с точки зрения пользователя и что каждое устройство работает правильно в общем объеме системы. Утверждения в первую очередь проверяют пользовательский интерфейс

Преимущества разработки через тестирование

Немедленное тестовое покрытие

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

Рефакторинг с уверенностью

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

Дизайн по контракту

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

Избегайте лишнего кода

Пока каждый часто или даже автоматически запускает тесты после изменения связанной реализации, удовлетворение существующих тестов снижает вероятность ненужного дополнительного кода, возможно, приводя к созданию базы кода, которую легче поддерживать и понимать. Следовательно, TDD помогает человеку следовать принципу KISS (будь проще, глупый!) .

Нет зависимости от интеграции

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

Например, давайте рассмотрим функцию ниже, которая определяет, является ли пользователь администратором:

'use strict' function isUserAdmin(id, users) { const user = users.find(u => u.id === id); return user.isAdmin; } 

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

 const testUsers = [ { id: 1, isAdmin: true }, { id: 2, isAdmin: false } ]; const isAdmin = isUserAdmin(1, testUsers); // TODO: assert isAdmin is true 

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

Разработка через тестирование с использованием JavaScript

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

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

  1. Прочитайте значение из <input> которое должно быть проверено
  2. Вызвать правило (например, алфавитное, числовое) для указанного значения
  3. Если это неверно, предоставьте пользователю значительную ошибку
  4. Повторите для следующего проверяемого ввода

Для этого упражнения есть CodePen, который содержит некоторый шаблонный тестовый код, а также пустую функцию validateForm . Пожалуйста, раскошелитесь, прежде чем мы начнем.

Наш API проверки формы возьмет экземпляр HTMLFormElement ( <form> ) и проверит каждый вход, имеющий атрибут data-validation , возможные значения которого:

  • alphabetical — любая регистронезависимая комбинация из 26 букв английского алфавита
  • numeric — любая комбинация цифр от 0 до 9

Мы напишем сквозной тест, чтобы проверить функциональность validateForm реальных узлов DOM, а также для двух типов проверки, которые мы будем изначально поддерживать. Как только наша первая реализация заработает, мы будем постепенно реорганизовывать ее, записывая меньшие единицы, также следуя TDD.

Вот форма, которую будут использовать наши тесты:

 <form class="test-form"> <input name="first-name" type="text" data-validation="alphabetical" /> <input name="age" type="text" data-validation="numeric" /> </form> 

Между каждым тестом мы создаем новый клон формы, чтобы устранить риск возможных побочных эффектов. Параметр true передаваемый cloneNode гарантирует, что дочерние узлы формы также будут клонированы:

 let form = document.querySelector('.test-form'); beforeEach(function () { form = form.cloneNode(true); }); 

Написание нашего первого теста

Пакет describe('the validateForm function', function () {}) будет использоваться для тестирования нашего API. Внутри внутренней функции напишите первый контрольный пример, который обеспечит, чтобы допустимые значения как для алфавитных, так и для числовых правил были признаны действительными:

 it('should validate a form with all of the possible validation types', function () { const name = form.querySelector('input[name="first-name"]'); const age = form.querySelector('input[name="age"]'); name.value = 'Bob'; age.value = '42'; const result = validateForm(form); expect(result.isValid).to.be.true; expect(result.errors.length).to.equal(0); }); 

После сохранения изменений в вашей ветке вы должны увидеть, что тест не пройден:

Сбой теста Мокко

Теперь давайте сделаем этот тест зеленым! Помните, что мы должны постараться написать минимальное, разумное (без return true; ;!) Количество кода для выполнения теста, поэтому давайте пока не будем беспокоиться о сообщениях об ошибках.

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

 function validateForm(form) { const result = { errors: [] }; const inputs = Array.from(form.querySelectorAll('input')); let isValid = true; for (let input of inputs) { if (input.dataset.validation === 'alphabetical') { isValid = isValid && /^[az]+$/i.test(input.value); } else if (input.dataset.validation === 'numeric') { isValid = isValid && /^[0-9]+$/.test(input.value); } } result.isValid = isValid; return result; } 

Теперь вы должны увидеть, что наш тест проходит:

Проходной тест Мокко

Обработка ошибок

Ниже нашего первого теста давайте напишем еще один, который проверяет, что массив Error возвращаемого объекта result содержит экземпляр Error с ожидаемым сообщением, когда алфавитное поле недопустимо:

 it('should return an error when a name is invalid', function () { const name = form.querySelector('input[name="first-name"]'); const age = form.querySelector('input[name="age"]'); name.value = '!!!'; age.value = '42'; const result = validateForm(form); expect(result.isValid).to.be.false; expect(result.errors[0]).to.be.instanceof(Error); expect(result.errors[0].message).to.equal('!!! is not a valid first-name value'); }); 

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

 function validateForm(form) { const result = { get isValid() { return this.errors.length === 0; }, errors: [] }; const inputs = Array.from(form.querySelectorAll('input')); for (let input of inputs) { if (input.dataset.validation === 'alphabetical') { let isValid = /^[az]+$/i.test(input.value); if (!isValid) { result.errors.push(new Error(`${input.value} is not a valid ${input.name} value`)); } } else if (input.dataset.validation === 'numeric') { // TODO: we'll consume this in the next test let isValid = /^[0-9]+$/.test(input.value); } } return result; } 

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

 it('should return an error when an age is invalid', function () { const name = form.querySelector('input[name="first-name"]'); const age = form.querySelector('input[name="age"]'); name.value = 'Greg'; age.value = 'a'; const result = validateForm(form); expect(result.isValid).to.be.false; expect(result.errors[0]).to.be.instanceof(Error); expect(result.errors[0].message).to.equal('a is not a valid age value'); }); 

Как только вы стали свидетелем неудачного теста, обновите функцию validateForm :

 } else if (input.dataset.validation === 'numeric') { let isValid = /^[0-9]+$/.test(input.value); if (!isValid) { result.errors.push(new Error(`${input.value} is not a valid ${input.name} value`)); } } 

Наконец, давайте добавим тест, чтобы убедиться, что несколько ошибок обрабатываются:

 it('should return multiple errors if more than one field is invalid', function () { const name = form.querySelector('input[name="first-name"]'); const age = form.querySelector('input[name="age"]'); name.value = '!!!'; age.value = 'a'; const result = validateForm(form); expect(result.isValid).to.be.false; expect(result.errors[0]).to.be.instanceof(Error); expect(result.errors[0].message).to.equal('!!! is not a valid first-name value'); expect(result.errors[1]).to.be.instanceof(Error); expect(result.errors[1].message).to.equal('a is not a valid age value'); }); 

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

Рефакторинг нашего валидатора

Хотя у нас есть рабочая функция, которая покрыта тестами, она испускает несколько запахов кода:

  • Несколько обязанностей

    • Мы запрашиваем внутренние DOM-узлы нашего ввода, определяем наш набор правил и вычисляем наш общий результат в той же функции. С точки зрения принципов SOLID , это нарушает принцип Единой ответственности
    • Кроме того, отсутствие абстракции приводит к коду, который другим разработчикам труднее понять
  • Тесная связь

    • Наша текущая реализация переплетает вышеупомянутые обязанности таким образом, что делает обновления для каждой проблемы хрупкими; изменения в одной детали нашего большого метода затруднят отладку в случае, если мы представим проблему
    • Кроме того, мы не можем добавлять или изменять правила проверки без обновления операторов if . Это нарушает принцип Открытого / Закрытого SOLID
  • Дублирование логики — если мы хотим обновить формат наших сообщений об ошибках или передать другой объект в наш массив, то мы должны обновить это в двух местах

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

Давайте использовать TDD для написания отдельных функций для:

  1. Отображение наших входных данных в запросы проверки
  2. Чтение наших правил валидации из соответствующей структуры данных

Функция createValidationQueries

Сопоставляя наш NodeList HTMLInputElement с объектами, представляющими имя поля формы, тип, по которому оно должно быть проверено, и значение указанного поля, мы не только будем отделять функцию validateForm от DOM, но и будем способствовать валидации поиск правил, когда мы заменяем наши жестко закодированные регулярные выражения.

Например, объект запроса проверки для поля first-name :

 { name: 'first-name', type: 'alphabetical', value: 'Bob' } 

Над функцией validateForm создайте пустую функцию с именем createValidationQueries . Затем, вне describe suite описаний для validateForm , создайте другой describe suite описаний под названием «функция createValidationQueries».

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

 describe('the createValidationQueries function', function () { it( 'should map input elements with a data-validation attribute to an array of validation objects', function () { const name = form.querySelector('input[name="first-name"]'); const age = form.querySelector('input[name="age"]'); name.value = 'Bob'; age.value = '42'; const validations = createValidationQueries([name, age]); expect(validations.length).to.equal(2); expect(validations[0].name).to.equal('first-name'); expect(validations[0].type).to.equal('alphabetical'); expect(validations[0].value).to.equal('Bob'); expect(validations[1].name).to.equal('age'); expect(validations[1].type).to.equal('numeric'); expect(validations[1].value).to.equal('42'); } ); }); 

Как только вы увидели эту ошибку, напишите код для реализации:

 function createValidationQueries(inputs) { return Array.from(inputs).map(input => ({ name: input.name, type: input.dataset.validation, value: input.value })); } 

Когда это пройдет, обновите цикл for validateForm for вызова нашей новой функции и использования объектов запроса для определения достоверности нашей формы:

 for (let validation of createValidationQueries(form.querySelectorAll('input'))) { if (validation.type === 'alphabetical') { let isValid = /^[az]+$/i.test(validation.value); if (!isValid) { result.errors.push(new Error(`${validation.value} is not a valid ${validation.name} value`)); } } else if (validation.type === 'numeric') { let isValid = /^[0-9]+$/.test(validation.value); if (!isValid) { result.errors.push(new Error(`${validation.value} is not a valid ${validation.name} value`)); } } } 

Если и наш новый тест, и существующие тесты пройдут, как показано на этом рисунке , мы сможем внести еще большие изменения; разделение правил проверки.

Функция validateItem

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

Как и createValidationQueries , мы напишем новый набор тестов перед нашей реализацией. Над реализацией validateForm напишите пустую функцию validateItem . Затем в нашем основном наборе описаний напишите еще одно describe для нашего нового дополнения:

 describe('the validateItem function', function () { const validationRules = new Map([ ['alphabetical', /^[az]+$/i] ]); it( 'should return true when the passed item is deemed valid against the supplied validation rules', function () { const validation = { type: 'alphabetical', value: 'Bob' }; const isValid = validateItem(validation, validationRules); expect(isValid).to.be.true; } ); }); 

Мы явно передаем Map правил в нашу реализацию из теста, поскольку хотим проверить ее поведение независимо от нашей основной функции; это делает его модульным тестом. Вот наша первая реализация validateItem() :

 function validateItem(validation, validationRules) { return validationRules.get(validation.type).test(validation.value); } 

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

 it( 'should return false when the passed item is deemed invalid', function () { const validation = { type: 'alphabetical', value: '42' }; const isValid = validateItem(validation, validationRules); expect(isValid).to.be.false; } ); 

Наконец, напишите тестовый пример, чтобы определить, что validateItem возвращает false когда тип проверки не найден:

 it( 'should return false when the specified validation type is not found', function () { const validation = { type: 'foo', value: '42' }; const isValid = validateItem(validation, validationRules); expect(isValid).to.be.false; } ); 

Наша реализация должна проверить, существует ли указанный тип validationRules Map validationRules прежде чем проверять какие-либо значения на соответствие их регулярным выражениям:

 function validateItem(validation, validationRules) { if (!validationRules.has(validation.type)) { return false; } return validationRules.get(validation.type).test(validation.value); } 

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

 const validationRules = new Map([ ['alphabetical', /^[az]+$/i], ['numeric', /^[0-9]+$/] ]); 

Наконец, давайте проведем рефакторинг функции validateForm для использования новой функции и правил:

 function validateForm(form) { const result = { get isValid() { return this.errors.length === 0; }, errors: [] }; for (let validation of createValidationQueries(form.querySelectorAll('input'))) { let isValid = validateItem(validation, validationRules); if (!isValid) { result.errors.push( new Error(`${validation.value} is not a valid ${validation.name} value`) ); } } return result; } 

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

Завершение

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

Вы использовали TDD в реальном проекте? Ваше мнение? Если нет, убедила ли вас эта статья попробовать? Дай мне знать в комментариях!

Если вы хотите больше узнать о TDD с помощью JavaScript , ознакомьтесь с нашим коротким мини-курсом Test-Driven Development с Node.js.

Эта статья была рецензирована Vildan Softic . Спасибо всем рецензентам SitePoint за то, что сделали контент SitePoint как можно лучше!