TDD: что, почему, когда, как

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

Мы даже рассмотрим пример, в котором мы создаем шифр Цезаря на JavaScript с помощью Jest для запуска наших модульных тестов.

Давайте начнем.

Что такое TDD?

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

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

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

Цикл «красный, зеленый, рефакторинг»

TDD легче понять, взглянув на простую диаграмму:

  1. Напишите тест, который не прошел. Вы пишете тесты до кода приложения, поэтому знаете, что этот тест не пройдет. Вы еще не реализовали эту функцию! Это считается «красным» состоянием (некоторые тесты не работают).
  2. Пройдите тест. Здесь вы пишете код своего приложения, поэтому фактически реализуете все функции, которые проверяет тест. После прохождения теста вы переходите в «зеленое» состояние (все тесты пройдены).
  3. Рефакторинг. Теперь, когда ваш тест пройден, улучшите свой код. Как гласит известная цитата Кента Бека: «Заставьте это работать. Сделать это правильно. Сделать его лучше. В этой последовательности." Обратите внимание, что мы не выполняем рефакторинг, пока не перейдем в «зеленое» состояние. Пройдя все наши тесты, мы можем с уверенностью провести рефакторинг, потому что, если мы что-то сломаем, тесты снова начнут давать сбой, давая нам понять, что наш рефакторинг еще не работает идеально.
  4. Повторите. Напишите еще один тест прямо сейчас! Продолжайте повторять шаги 1–3 по мере того, как вы напишете больше тестов и реализуете больше функций в своем приложении.

Зачем использовать TDD?

Итак, вот что такое TDD и как работает процесс разработки. Но зачем вам его использовать? Использование TDD дает несколько преимуществ, но вот три:

  1. Тесты встроены прямо в цикл разработки, а не на заднем плане.
  2. Это гарантирует, что ваши тесты проверяют правильные вещи.
  3. Каждая ветка покрыта (теоретически).

Давайте рассмотрим каждое из этих преимуществ.

Тесты встроены прямо в цикл разработки, а не на заднем плане.

Вы когда-нибудь писали весь код своего приложения, а затем возвращались и думали: «Как ответственный разработчик, вероятно, должен написать несколько тестов для этого кода», а затем боялись следующих нескольких часов или дней написания тестов?

Или написание тестов когда-нибудь казалось бессмысленным? В конце концов, вы уже протестировали свой код вручную и, похоже, он работает нормально, так зачем писать тесты сейчас? Давай просто отправим его.

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

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

Это гарантирует, что ваши тесты проверяют правильные вещи.

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

Написав тесты на основе требований к продукту, вы также в некотором роде документируете критерии приемлемости для своего приложения. Не забывайте по возможности избегать тестирования деталей внутренней реализации!

Каждая ветка покрыта (теоретически).

Если вы пишете тесты после кода приложения, сталкивались ли вы с трудностями при достижении этих последних нескольких процентов покрытия кода?

Ведутся споры о том, насколько достаточно покрытия кода (80%? 90%? 95%? 100%?), Но в целом я считаю, что ваш код должен иметь почти 100% -ное покрытие. Приятно осознавать, что в вашем приложении нет скрытых уголков, в которых отсутствуют тесты, особенно если эти места являются ключевыми частями вашего приложения.

На практике 100% покрытие кода, вероятно, не всегда реально и не стоит вашего времени, но это хороший идеал, к которому стоит стремиться.

Хорошим побочным эффектом использования TDD является то, что это должно привести к 100% покрытию кода. Если весь код приложения, который вы пишете, должен удовлетворять требованиям тестов, то теоретически вам не следовало писать код приложения, который не тестируется.

Если у вас есть оператор if / else в коде вашего приложения, в котором могут произойти два возможных результата, весьма вероятно, что у вас есть какое-то требование к продукту, гласящее, что «если A, то B должно произойти; если C, то должно произойти D ».

Например, «Если пользователь вошел в систему, он должен видеть содержимое этой страницы. Если пользователь не вошел в систему, он не сможет видеть содержимое этой страницы ».

Когда следует использовать TDD?

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

TDD отлично подходит, когда вы:

  • иметь четкие требования к проекту
  • иметь четкие входы и выходы (чистые функции, конечные точки API)
  • исправляем ошибки!

Четкие требования к проекту

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

Очистить входы и выходы

То же самое, когда у вас есть четкие входы и выходы. Если вы пишете функцию форматирования валюты и знаете, что formatCurrency(3.50, 'USD') должно приводить к $3.50, то сначала напишите свои тесты!

Подумайте обо всех странных вводах, которые вы могли бы получить, и о том, как бы вы с ними справились. Что, если ваш метод был вызван с отсутствующими аргументами, например formatCurrency()? Что, если вместо числа была передана строка, например formatCurrency('sorry', 'CAD')? Как бы вы справились с этими случаями? Что бы сделал ваш метод? Вернуть undefined или null? Выкинуть ошибку?

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

Исправление ошибок

Вы исправляете ошибку? Большой! Напишите тест того, что должно происходить, когда приложение работает правильно. А теперь исправьте ошибку. Посмотри на себя! Вы только что сделали TDD!

Огромным преимуществом этого подхода также является то, что вы написали тест, чтобы гарантировать, что эта конкретная ошибка больше не вернется в ваше приложение.

Когда не использовать TDD?

TDD, вероятно, не имеет смысла для:

  • исследовательское программирование
  • UI разработка (спорно)

Исследовательское программирование

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

UI разработка

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

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

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

В частности, мир React, похоже, отходит от использования Enzyme, который позволяет выполнять неглубокий рендеринг и тестировать детали реализации ваших компонентов, и движется к Библиотеке тестирования React Кента Доддса, в которой особое внимание уделяется тестированию вещей, которые пользователь действительно мог видеть и взаимодействовать с.

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

Это кажется гораздо более доступным способом использования TDD при разработке пользовательского интерфейса.

Демо

Демо-время! Давайте посмотрим, как может выглядеть типичный процесс разработки при использовании TDD при создании шифра Цезаря, реализованного на JavaScript.

Весь следующий код можно найти на GitHub здесь.

Если вы не знакомы с шифром Цезаря, это очень простой метод «кодирования» сообщения, которое человек, получающий сообщение, может затем «декодировать». Сообщение кодируется путем сдвига каждой буквы в сообщении на определенную величину, а затем декодируется путем сдвига каждой буквы в противоположном направлении на ту же величину.

Например, если некодированное сообщение равно Hello world!, а величина сдвига равна 5, то закодированное сообщение становится Mjqqt btwqi!. «H» перемещает на 5 букв алфавита вперед до «M», «e» перемещает на 5 букв алфавита вперед до «j» и так далее.

Требования к продукту

Итак, каковы могут быть требования к нашему продукту? Давайте определим их как:

  1. принимает строку и значение сдвига и возвращает новую строку
  2. сдвигает символы A-Z на правильную величину
  3. не влияет на неалфавитные символы
  4. поддерживает регистр и обрабатывает прописные и строчные буквы
  5. ручки, оборачивающиеся за концом алфавита
  6. обрабатывает значения сдвига больше 26
  7. обрабатывает значения сдвига меньше 0
  8. обрабатывает неверный ввод

Исходный код приложения и тестовый код

Допустим, у нас есть два файла: caesar-cipher.js и caesar-cipher.test.js.

Тестовый файл выглядит так:

import { encode } from './caesar-cipher'
describe('caesar cipher', () => {
  // TODO: write tests here
})

А исходный код выглядит так:

export const encode = () => {}

Требование 1: принимает строку и значение сдвига и возвращает новую строку

Давайте сначала напишем наш тест, поместив этот тест в блок describe:

it('takes a string and a shift value and returns a new string', () => {
  expect(typeof(encode('abc', 1))).toBe('string')
})

Тест не пройден! И, конечно, будет, потому что сейчас у нас есть пустая оболочка метода encode, который просто возвращает undefined.

Теперь давайте напишем наш исходный код, чтобы пройти тест:

export const encode = (str, shiftAmount) => {
  return str
}

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

Требование 2: сдвигает символы A-Z на правильную величину

Наш тест будет:

it('shifts the A-Z characters by the correct amount', () => {
  expect(encode('abc', 1)).toBe('bcd')
  expect(encode('test', 2)).toBe('vguv')
})

Тест не пройден! И это ожидаемо, потому что, опять же, мы еще не кодируем строку. Мы просто возвращаем исходную неизмененную строку.

Давайте напишем исходный код, чтобы удовлетворить это требование:

export const encode = (str, shiftAmount) => {
  const encryptedMessage = str.split('').map((character, index) => {
    const code = str.charCodeAt(index)
    const shiftedCode = code + shiftAmount
    return String.fromCharCode(shiftedCode)
  })
  return encryptedMessage.join('')
}

Этот код разбивает строку на массив символов, а затем перебирает их. Для каждого символа он находит код символа, добавляет к нему величину сдвига, а затем получает символ из нового кода символа. Затем он объединяет массив обратно в строку и возвращает зашифрованное сообщение.

Все тесты сейчас проходят, поэтому мы можем перейти к следующему требованию к продукту.

Требование 3: не влияет на неалфавитные символы

Напишем тест:

it('does not affect non-alphabetic characters', () => {
  expect(encode('abc123', 1)).toBe('bcd123')
})

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

export const encode = (str, shiftAmount) => {
  const encryptedMessage = str.split('').map((character, index) => {
    const code = str.charCodeAt(index)
    // 97-122 => a-z
    if (code >= 97 && code <= 122) {
      const shiftedCode = code + shiftAmount
      return String.fromCharCode(shiftedCode)
    }
    return character
  })
  return encryptedMessage.join('')
}

Теперь мы преобразуем только символы, попадающие в диапазон кодов символов 97–122, который соответствует символам a-z.

Перейдем к нашему следующему требованию.

Требование 4: поддерживает регистр и обрабатывает прописные и строчные буквы

Вот наш тест:

it('maintains case', () => {
  expect(encode('aBc', 1)).toBe('bCd')
})

Тест не пройден! Опять же, наш метод преобразует только символы в диапазоне кодов 97–122 символов, что означает, что пока мы обрабатываем строчные буквы a-z, мы игнорируем прописные A-Z. Давайте также добавим правильный диапазон для этого, который составляет 65–90:

export const encode = (str, shiftAmount) => {
  const encryptedMessage = str.split('').map((character, index) => {
    const code = str.charCodeAt(index)
    // 97-122 => a-z; 65-90 => A-Z
    if ((code >= 97 && code <= 122) || (code >= 65 && code <= 90)) {
      const shiftedCode = code + shiftAmount
      return String.fromCharCode(shiftedCode)
    }
    return character
  })
  return encryptedMessage.join('')
}

Вот и все, намного лучше. Переходим к следующему требованию.

Требование 5: обрабатывает перенос за конец алфавита

Напишем наш тест:

it('handles wrapping past the end of the alphabet', () => {
  expect(encode('xyz', 2)).toBe('zab')
})

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

Чтобы исправить это, мы можем использовать оператор по модулю:

export const encode = (str, shiftAmount) => {
  const encryptedMessage = str.split('').map((character, index) => {
    const code = str.charCodeAt(index)
    // 97-122 => a-z; 65-90 => A-Z
    if (code >= 65 && code <= 90) {
      const shiftedCode = ((code + shiftAmount - 65) % 26) + 65
      return String.fromCharCode(shiftedCode)
    } else if (code >= 97 && code <= 122) {
      const shiftedCode = ((code + shiftAmount - 97) % 26) + 97
      return String.fromCharCode(shiftedCode)
    }
    return character
  })
  return encryptedMessage.join('')
}

Теперь наши сообщения будут правильно обтекать концы алфавита. Следующее требование.

Требование 6: обрабатывает значения сдвига больше 26

Подобно тому, как буквы в конце алфавита должны правильно переноситься, использование большого количества сдвига также должно быть правильным. Например, даже буква «а», сдвинутая на 28, перемещается дальше, чем полная длина алфавита, поэтому ее нужно обернуть, чтобы она стала «с».

Вот наш тест:

it('handles shift values greater than 26', () => {
  expect(encode('abc', 26)).toBe('abc')
  expect(encode('abc', 28)).toBe('cde')
})

И… испытание проходит! Хочешь взглянуть на это. Оказывается, наш оператор по модулю, который мы использовали в требовании 5, помог нам здесь с требованием 6.

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

Перейдем к следующему требованию.

Требование 7: обрабатывает значения сдвига меньше 0

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

Вот наш тест:

it('handles shift values less than 0', () => {
  expect(encode('abc', 0)).toBe('abc')
  expect(encode('abc', -2)).toBe('yza')
})

Тест не пройден! Хорошо, давайте исправим это в нашем исходном коде, также применив оператор по модулю к величине сдвига:

export const encode = (str, shiftAmount) => {
  const encryptedMessage = str.split('').map((character, index) => {
    const code = str.charCodeAt(index)
    const moduloShiftAmount = (shiftAmount % 26) + 26
    // 97-122 => a-z; 65-90 => A-Z
    if (code >= 65 && code <= 90) {
      const shiftedCode = ((code + moduloShiftAmount - 65) % 26) + 65
      return String.fromCharCode(shiftedCode)
    } else if (code >= 97 && code <= 122) {
      const shiftedCode = ((code + moduloShiftAmount - 97) % 26) + 97
      return String.fromCharCode(shiftedCode)
    }
    return character
  })
  return encryptedMessage.join('')
}

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

Требование 8: обрабатывает неверный ввод

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

Наш тест будет:

it('handles bad input', () => {
  expect(encode()).toBe('')
  expect(encode(1, 1)).toBe('')
  expect(encode(1, 'abc')).toBe('')
  expect(encode('abc')).toBe('abc')
})

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

Давайте обновим наш исходный код, чтобы справиться с этим:

export const encode = (str = '', shiftAmount = 0) => {
  if (typeof str !== 'string' || typeof shiftAmount !== 'number') {
    return ''
  }
  const encryptedMessage = str.split('').map((character, index) => {
    const code = str.charCodeAt(index)
    const moduloShiftAmount = (shiftAmount % 26) + 26
    // 97-122 => a-z; 65-90 => A-Z
    if (code >= 65 && code <= 90) {
      const shiftedCode = ((code + moduloShiftAmount - 65) % 26) + 65
      return String.fromCharCode(shiftedCode)
    } else if (code >= 97 && code <= 122) {
      const shiftedCode = ((code + moduloShiftAmount - 97) % 26) + 97
      return String.fromCharCode(shiftedCode)
    }
    return character
  })
  return encryptedMessage.join('')
}

Добавление некоторых значений по умолчанию и проверка типов делает тест пройденным. Мы сделали это! Все 8 требований выполнены.

Если бы вы прямо сейчас проверили покрытие кода нашим шифром Цезаря, вы бы увидели, что у нас 100% покрытие кода. Потрясающие! Хотя этот код не является невероятно сложным, у нас были некоторые значения по умолчанию для параметров функции, и у нас была некоторая логика ветвления, учитывающая типы значений и коды символов.

Используя TDD, мы удостоверились, что каждое условие нашего метода адекватно протестировано, поэтому мы можем с уверенностью использовать шифр Цезаря в нашем созданном приложении.

Заключение

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

Если вы хотите попрактиковаться, напишите decode метод!