Введние
Это продолжение цикла «Практика TDD/BDD на примере JavaScript». В первой, вводной статье, я попытался убедить разработчиков в необходимости, если не писать тесты на всех своих проектах, то хотя бы свободно владеть темой и знать зачем это им нужно.
Сегодня я расскажу что такое TDD (test-driven developement) и на простом примере покажу как это работает. Во второй части будет расмотрено BDD (behaviour-drive development) в сравнении с TDD и на практике.
TDD
Что такое TDD
Разработка через тестирование выражается в простом правиле: сначала тесты, а потом код.
Если вы знакомы с темой и тестовым фреймворком, которым вы пользуетесь, то выразить задачу в тестах даже проще, чем объяснить её вашему коллеге.
Когда у вас есть тесты, код писать очень просто, задача сводится к тому, чтобы удовлетворить описанные вами условия.
Вы четко понимаете задачу, у вас перед глазами пример использования будущего кода, что еще нужно для удачного дизайна и успешного решения?
Процесс
Как обычно выглядит TDD процесс? Если по шагам и вкратце, то так:
- Вы берете описание задачи из головы или бумаги и думаете над тем, с чего вы начнете её решение.
- Вы описываете небольшой участок кода (руководствуясь принципом разделяй и властвуй), проверяя результат выполнения еще не существующей функции (метода, класса etc). Если функция может принимать много аргументов или иметь несколько вариантов использования, то ограничиваетесь лишь 2-3 примерами, избегая подробного описания всех вариантов.
- Запускаете тесты. Тесты — красные (тут и далее, красные тесты — упавшие, зеленые — прошедшие)
- Пишите код, удовлетворяя условия написанных тестов.
- Запускаете тесты. Тесты — зеленые.
- Переходите к первому или ко второму шагу.
Теория — ничто, опыт — все. Давайте что-нибудь сделаем используя методолгию TDD.
Практика
Для того, чтобы наглядно продемонстрировать процесс, я придумал задачу:
Написать функцию X которая принимает другую функцию Y в качестве аргумента.
Функция Y принимает два аргумента: ключ и значение.
Результатом выполнения функции X(Y) будет функция Z которая принимает как два аргумента (ключ-значение), так и один (коллекция ключей-значений).
Функция Z должна вызывать функцию Y, N раз, где N соотвествует кол-ву пар ключ-значение переданное в Z.
Не понятна формулировка? Не страшно, вот примеры:
В jQuery есть функция $.fn.css которая принимает либо два параметра (ключ-значение) либо коллекцию таких пар:
$('body').css('background', '#BADA55');
$('body').css({ background: '#BADA55', color: 'black' });
У нас и должна получится функция которая позволяет делать такие (как $.fn.css) функции.
var printKeyValue = function (key, value) {
console.log('Key: ' + key + ', value: ' + value);
};
var whateveredFn = whatever(printKeyValue);
whateveredFn('awe', 'some');
// => Key: awe, value: some
whateveredFn({ one: 1, two: 2 });
// => Key: one, value: 1
// => Key: two, value: 2
Как вы помните (помните же?) понимание это важный, первый шаг в сторону успешного решения. Плюс вы наверняка отметили, насколько примеры понятнее четко сформулированной задачи ;-).
У меня уже есть готовая, настроенная тестовая среда. Как ее готовить, мы рассмотрим в следующей статьях.
В примерах я буду использовать CoffeeScript, вместо JavaScript. По двум причинам:
- эти примеры не требуют внимательного изучения кода, т.к в первую очередь демонстрируют процесс;
- я хочу понять насколько вы, читатели, хорошо понимате CoffeeScript.
Давайте напишем свой первый тест, чтобы убедиться, что все работает.
test 'whatever works properly', ->
assert.equal(typeof whatever, 'function')
Сохраняем файл, тестов и видим результат:
У меня настроено автоматическое тестирование, т.е. мне не нужно после каждого изменения запускать тесты в консоли, они делают это сами — при сохранении исходника или тестов.
Это важный атрибут TDD процесса, т.к. избавляет нас от рутины и усоряет процесс. Пишем код, сохраняем, видим результат.
Кроме того, результаты у меня показываются в notifications и дублируются синтезируемым голосом (спасибо talks, gem'у от моего коллеги, gazay).
Проверять является ли whatever функцией весьма безполезно. Как проверить что наша функция работает правильно? Ответ на этот вопрос я дал еще в описании задачи. Так давайте просто скопируем примеры в тесты:
array = []
# Проверять успешность решения нашей задачи,
# мы будем с помощью простой функции, которая добавляет пару ключей в массив `array`
add = (key, value) ->
array.push(key: key, value: value)
# А вот и наш, испытуемый
whatevered = whatever(add)
# Перед каждым тестом очищаем массив
setup -> array = []
suite 'whatevered function', ->
# Проверяем, что новая функция правильно
# принимает два аргумента:
test 'call with 2 arguments', ->
whatevered('awe', 'some')
assert(array[0].key, 'awe')
assert(array[0].value, 'some')
# Проверяем, что новая функция правильно
# принимает коллекцию ключей:
test 'call with collection of pairs', ->
whatevered(one: 1, two: 2)
assert(array[0].key, 'one')
assert(array[0].value, 1)
assert(array[1].key, 'two')
assert(array[1].value, 2)
Сохраняем и видим результат: оба написанных теста завалились. Отлично, давайте наконец приступим к коду.
Для начала напишим код для варианта, когда к нам приходят два параметра:
whatever = (fn) ->
(key, value) ->
fn(key, value)
Сохраняем и ура, 1 тест пройден!
Остался еще один вариант, когда первый аргумент — объект. Добавим простое условие и переберем объект, вызывая нашу функцию для каждой пары:
whatever = (fn) ->
(possibleKey, possibleValue) ->
if typeof possibleKey == 'object'
for key, value of possibleKey
fn(key, value)
else
fn(possibleKey, possibleValue)
Вот так выглядит TDD процесс. Теперь мы можем приступить к рефакторингу или добавлению новых фич. Но этим мы займемся во второй части, уже по BDD методолгии.
BDD
Что такое BDD
BDD это модификация TDD, как и в TDD, вы пишете тесты до того, как появится код, но делаете это иначе.
BDD ориентирован на поведение, когда как TDD ориентирован на сам код.
Это значит, что вы должны думать не функциями и возращаемыми значениями, а поведением тестируемой сущности.
В контекте задачи, прикрутить нотификации о новых комментариях, при использвовании TDD мы думаем так:
Когда мы вызываем функцию addComment, должна быть вызвана функция notifyAboutComment, если у user notifyAboutComments == true.
В BDD мы смотрим на это под другим углом:
Когда к статье пользователя кто-либо постит комментарий, мы должны послать ему e-mail, если он включил нотификации о новых коментариях.
Вы конечно же проверите, что addComment вызывает notifyAboutComment, но соус уже другой.
Не смотря на тонкую такую грань BDD помогает:
- понять с чего начать;
- понять что тестировать;
- сколько тестов нужно написать за один подход;
- как структурировать тесты.
Практика
Как же будут выглядеть тесты для нашей функции whatever в BDD варианте?
Давайте в первую очередь перепишем наши тесты на BDD лад:
describe 'whatevered function', ->
beforeEach -> array = []
it 'should accept 2 arguments and call original function once', ->
whatevered('awe', 'some')
array[0].should.eql(key: 'awe', value: 'some')
it 'should accept object as argument and call original function for each key', ->
whatevered(one: 1, two: 2)
array[0].should.eql(key: 'one', value: 1)
array[1].should.eql(key: 'two', value: 2)
Вместо обычных assetions мы использовали it и описали желаемое поведение в формате: «должен принимать 2 аргумента и единожды вызывать оригинальную функцию».
Чего мы добились этим изменением?
Тесты удобно читать, каждый example всегда снабжен четкой формулировкой. Тесты можно использовать как документацию.
Кроме того вывод в консоли стал предельно понятным:
А значит проще понять, что не работает.
Давайте вернемся к коду. У нашей функции whatever есть один недостаток: она не сохраняет контекст.
Давайте исправим этот недочет!
Пишем тест «должен использоваться контекст, переданный в качестве второго аргумента»:
it 'should apply passed context to original function', ->
obj =
array: []
add: (key, value) ->
@array.push(key: key, value: value)
whatevered = whatever(obj.add, obj)
whatevered('lol', 'w00t')
obj.array[0].should.eql(key: 'lol', value: 'w00t')
Как и ожидалось, тест завалился:
TypeError: Cannot call method 'push' of undefined.
Что ж, это очевидно. Чтобы это исправить, используем call:
whatever = (fn, context) ->
(possibleKey, possibleValue) ->
if typeof possibleKey == 'object'
for key, value of possibleKey
fn.call(context, key, value)
else
fn.call(context, possibleKey, possibleValue)
Тесты пройдены:
Теперь можно заняться рефакторингом, наша функция не идеальна, у нас дважды вызывается call. Давайте используем рекурсию:
whatever = (fn, context) ->
fn = (possibleKey, possibleValue) ->
if typeof possibleKey == 'object'
fn(key, value) for key, value of possibleKey
else
fn.call(context, possibleKey, possibleValue)
Если первый аргумент — объект, функция вызовет сама себя передавая уже два аргумента, ключ и значение.
Но что случилось, тесты упали!
Упс. Я одинаково назвал и оригинальную и новую функцию. Исправим эту ошибку:
whatever = (fn, context) ->
whatevered = (possibleKey, possibleValue) ->
if typeof possibleKey == 'object'
whatevered(key, value) for key, value of possibleKey
else
fn.call(context, possibleKey, possibleValue)
Ура:
Заключение
Я бы не хотел, чтобы эту статью воспринимали как руководство. Это скорее демонстрация, к настоящей практике мы перейдем в следущей статье. Настроим окружение, ознакомимся с инструментами.
Исходный код статьи доступен на GitHub. Пул-реквесты приветствуются!
Спасибо за внимание!
Автор: kossnocorp
А где в коде объявление функции whatever?
var printKeyValue = function (key, value) {
console.log(‘Key: ‘ + key + ‘, value: ‘ + value);
};
var whateveredFn = whatever(printKeyValue);
whateveredFn(‘awe’, ‘some’);
// => Key: awe, value: some
whateveredFn({ one: 1, two: 2 });
// => Key: one, value: 1
// => Key: two, value: 2