Практика TDD/BDD на примере JavaScript: TDD и BDD

в 14:01, , рубрики: bdd, coffeescript, javascript, mocha, tdd, Блог компании «Evil Martians», метки: , , , ,

Практика TDD/BDD на примере JavaScript: TDD и BDD

Введние

Это продолжение цикла «Практика TDD/BDD на примере JavaScript». В первой, вводной статье, я попытался убедить разработчиков в необходимости, если не писать тесты на всех своих проектах, то хотя бы свободно владеть темой и знать зачем это им нужно.

Сегодня я расскажу что такое TDD (test-driven developement) и на простом примере покажу как это работает. Во второй части будет расмотрено BDD (behaviour-drive development) в сравнении с TDD и на практике.

TDD

Что такое TDD

Разработка через тестирование выражается в простом правиле: сначала тесты, а потом код.

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

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

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

Процесс

Как обычно выглядит TDD процесс? Если по шагам и вкратце, то так:

  1. Вы берете описание задачи из головы или бумаги и думаете над тем, с чего вы начнете её решение.
  2. Вы описываете небольшой участок кода (руководствуясь принципом разделяй и властвуй), проверяя результат выполнения еще не существующей функции (метода, класса etc). Если функция может принимать много аргументов или иметь несколько вариантов использования, то ограничиваетесь лишь 2-3 примерами, избегая подробного описания всех вариантов.
  3. Запускаете тесты. Тесты — красные (тут и далее, красные тесты — упавшие, зеленые — прошедшие)
  4. Пишите код, удовлетворяя условия написанных тестов.
  5. Запускаете тесты. Тесты — зеленые.
  6. Переходите к первому или ко второму шагу.

Теория — ничто, опыт — все. Давайте что-нибудь сделаем используя методолгию 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')

Сохраняем файл, тестов и видим результат:

image

У меня настроено автоматическое тестирование, т.е. мне не нужно после каждого изменения запускать тесты в консоли, они делают это сами — при сохранении исходника или тестов.

Это важный атрибут 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)

image

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

Для начала напишим код для варианта, когда к нам приходят два параметра:

whatever = (fn) ->
  (key, value) ->
    fn(key, value)

Сохраняем и ура, 1 тест пройден!

image

Остался еще один вариант, когда первый аргумент — объект. Добавим простое условие и переберем объект, вызывая нашу функцию для каждой пары:

whatever = (fn) ->
  (possibleKey, possibleValue) ->
    if typeof possibleKey == 'object'
      for key, value of possibleKey
        fn(key, value)
    else
      fn(possibleKey, possibleValue)

image

Вот так выглядит 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 всегда снабжен четкой формулировкой. Тесты можно использовать как документацию.

Кроме того вывод в консоли стал предельно понятным:

image

А значит проще понять, что не работает.

Давайте вернемся к коду. У нашей функции 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')

Как и ожидалось, тест завалился:

image

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)

Тесты пройдены:

image

Теперь можно заняться рефакторингом, наша функция не идеальна, у нас дважды вызывается 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)

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

Но что случилось, тесты упали!

image

Упс. Я одинаково назвал и оригинальную и новую функцию. Исправим эту ошибку:

whatever = (fn, context) ->
  whatevered = (possibleKey, possibleValue) ->
    if typeof possibleKey == 'object'
      whatevered(key, value) for key, value of possibleKey
    else
      fn.call(context, possibleKey, possibleValue)

Ура:

image

Заключение

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

Исходный код статьи доступен на GitHub. Пул-реквесты приветствуются!

Примеры из статьи.

Спасибо за внимание!

Автор: kossnocorp


  1. Magneto:

    А где в коде объявление функции 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

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js