Пример разработки, управляемой тестами и типами

В статье в примерах кода используется Kotlin и его тестовая библиотека Kotest.

Задание

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

Создайте случайный цвет, который не является зеленым.

Мне нравится задача, так как она открыта для интерпретации и требует много разъяснений — как описать цвет? что такое случайный цвет? когда цвет не зеленый? Это задание позволяет нам увидеть мыслительный процесс кандидатов и умение задавать вопросы (иногда настолько простые, что задавать их почти глупо). При этом задача технически не сложная и может быть реализована на любом языке программирования.

Математика на заднем плане

Одно из возможных решений — рассматривать цвет как вектор из трехмерного куба [0, 255]³ (стандартное RGB-представление цвета). Будем считать, что цвет зеленый, если его евклидово расстояние от чистого зеленого цвета (0, 255, 0) меньше определенного значения, скажем, 20.

Сначала проверьте

Следуя практике разработки через тестирование, мы начинаем с написания небольшой части теста, который, в конце концов, превратится в интеграционный тест. Во время кодирования мы также напишем другие «меньшие» тесты (юнит-тесты). Мы пишем:

"should generate nongreen color" {
  val generatedColor: Color = getNongreenColor()
}

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

fun getNongreenColor(): Color { TODO() }

class Color

Стоит отметить, что этот метод вызывает стандартную функцию Kotlin TODO, а класс Color пока не имеет членов (полей и методов). Метод TODO генерирует исключение после его вызова. Мы максимально упрощаем основной код и ничего не вводим, так как любые изменения в основном коде должны выполняться с проверкой (является ли YAGNI звоночком?).

Класс цвета

Давайте введем зеленый цвет для целей тестирования:

val green = Color(
  red = 0,
  green = 255,
  blue = 0
)

Чтобы код выше скомпилировался, мы должны расширить класс Color следующим образом:

class Color(
  red: Int,
  green: Int,
  blue: Int
)

Красная, зеленая и синяя составляющие допустимого цвета могут находиться только в диапазоне от 0 до 255 (включительно). Следуя практике дизайна на основе типов, мы должны убедиться, что каждый экземпляр класса Color является допустимым представлением реального цвета. Самый простой способ добиться этого — генерировать исключение во время инициализации всякий раз, когда что-то не так.

Давайте начнем еще один тест (еще один!?):

"should throw exception when red component is 300" {
  shouldThrow<InvalidColorComponentException> { }
}

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

class InvalidColorComponentException : Exception()

Мы готовы продолжить написание теста

"should throw exception when red component is 300" {
  shouldThrow<InvalidColorComponentException> {
    Color(
      red = 300,
      green = 0,
      blue = 0
    )
  }
}

Тест компилируется и проваливается — пора реализовывать:

class Color(
  red: Int,
  green: Int,
  blue: Int
) {
  val red = if(red in 0..255) red else throw InvalidColorComponentException() 
}

Теперь тест проходит — метод выдает правильное исключение. Пришло время улучшить тест, но давайте сначала рефакторим его:

"should throw exception when red component is 300" {
  shouldThrow<InvalidColorComponentException> {
    val testComponents = TestColorComponents(300, 0, 0)
    Color(
      testComponents.red,
      testComponents.green,
      testComponents.blue
    )
  }
}

private data class TestColorComponents(
  val red: Int, 
  val green: Int, 
  val blue:Int
)

Внедрение TestColorComponents позволяет нам легко добавлять дополнительные тестовые случаи. Также меняем название теста:

"should throw exception on invalid color component" {

  val invalidColors = listOf(
    TestColorComponents(300, 0, 0),
    TestColorComponents(-1, 0, 0),
    TestColorComponents(4, 300, 0),
    // all test scenarios are here - omitted for simplicity
  )

  invalidColors.forEach { invalid -> {
    shouldThrow<InvalidColorComponentException> {
      Color(
        invalid.red,
        invalid.green,
        invalid.blue
      )
    }
  }
}

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

class Color(
  red: Int,
  green: Int,
  blue: Int
) {
  val red = if(red in 0..255) red else throw InvalidColorComponentException() 
  val green = if(green in 0..255) green else throw InvalidColorComponentException()
  val blue = if(blue in 0..255) blue else throw InvalidColorComponentException()
}

Теперь тест проходит, но код выглядит ужасно. Проведем рефакторинг

class Color(
  red: Int,
  green: Int,
  blue: Int
) {
  
  private val validComponentRange = 0..255
  
  private fun getColorComponent(component: Int) = if(component in validComponentRange) 
    component
  else 
    throw InvalidColorComponentException()

  val red = getColorComponent(red)
  val green = getColorComponent(green)
  val blue = getColorComponent(blue)
}

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

Вернемся к реализации генератора цвета

Реализовав безопасный тип Color, мы готовы расширить первоначальный тест. После небольшого рефакторинга получаем:

val green = Color(
  red = 0,
  green = 255,
  blue = 0
)

fun Color.isGreen() = distance(green) < 20

"should generate nongreen color" {
  getNongreenColor().isGreen().shouldBeFalse()
}

Чтобы исправить проблемы с компиляцией, мы должны ввести метод distance в класс Color.

Метод расстояния

Начнем с другого теста:

"should calculate a distance between colors" {
  Color(0, 0, 0).distance(Color(0, 0, 1))
}

В приведенном выше тесте нет утверждений, поскольку мы прекратили его писать после того, как он перестал компилироваться. После введения метода distance в класс Color мы можем добавить утверждение

Color(0, 0, 0).distance(Color(0, 0, 1)) shouldBe (1.0 plusOrMinus 0.000001)

Теперь в классе Color мы можем реализовать метод distance, используя известную формулу Евклидова расстояния:

class Color(
  red: Int,
  green: Int,
  blue: Int
) {
  
  ...

  fun distance(other: Color): Double = Math.sqrt(
    (red - other.red).toDouble().pow(2) +
    (green - other.green).toDouble().pow(2) +
    (blue - other.blue).toDouble().pow(2) 
  )
}

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

Время реализации? Нет, давайте сначала напишем еще один тест!

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

Давайте сначала сосредоточимся на меньшей единице работы: случайности. Тестирование случайности — сложная задача, обычно требующая некоторого статистического тестирования. Здесь мы не будем усложнять и предположим, что генератор является случайным, если все результаты 5 вызовов различны. Напишем тест:

"should generate a random color" {
  (0..4).map { getNongreenColor() }.toSet() hasLength 5
}

Для этого теста нам нужно реализовать метод equals в классе Color. Я не буду приводить код, так как это стандартная вещь, а метод equals может быть сгенерирован во многих IDE. Для этого мы также можем использовать классы данных в Kotlin.

Мы готовы реализовать метод:

fun getNongreenColor() = Color(
  red = Random.nextInt(256),
  green = Random.nextInt(256),
  blue = Random.nextInt(256)
)

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

Бонусный вопрос

Рассчитайте вероятность неудачи теста «должен генерировать случайный цвет» с учетом приведенной выше реализации метода getNongreenColor.

(подсказка: используйте схему Бернулли)

Реализация

Наконец-то мы готовы выполнить тест «должен генерировать незеленый цвет». На данный момент мы знаем, что метод getNongreenColor каким-то образом возвращает случайные цвета. Давайте продолжим получать случайные цвета, пока не найдем что-то не зеленое. Мы можем написать, например, следующий код:

fun getNongreenColor(): Color {
  var colorCandidate = getRandomColor()
  while(colorCandidate.distance(pureGreenColor) < 20) {
    colorCandidate = getRandomColor()
  }
  return colorCandidate;
}

private fun getRandomColor() = Color(
  red = Random.nextInt(256),
  green = Random.nextInt(256),
  blue = Random.nextInt(256)
)

private val pureGreenColor = Color(
  red = 0,
  green = 255,
  blue = 0
)

Приведенный выше алгоритм прост и не приводит к бесконечным циклам (знаете почему?).

Улучшения

Задача кодирования завершена, но можно внести ряд улучшений:

  1. сделать метод генерации гибким, добавив параметры (цвет, которого следует избегать, и значение расстояния)
  2. исправить ненадежность теста «должен генерировать случайный цвет»
  3. рефакторинг теста «должен генерировать не зеленый цвет», поэтому мы генерируем несколько цветов (это повышает достоверность теста)
  4. использовать расширенную статистику.