Почему вы должны заботиться о типах и как это влияет на автоматическое тестирование

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

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

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

В качестве эталонных языков мы будем использовать Javascript и Typescript, но эта статья относится к любому динамическому и типизированному языку.

Нет типов

В CRUI у нас есть функция для привязки Stream и определенного свойства элемента. В самом простом виде это выглядит так:

function h$b(tag, bind)

Честно говоря, я бы не знал, что делать с этой функцией.

Глупый я, проблема явно не в документации! Добавим.

/**
 * Element with Bind.
 * Create an DOM Node and bind properties to a StreamBox.
 * 
 * @param tag A string with an HTML Tag like 'div'
 * @param bind An object specifying which property to bind to which stream box.
 * @returns A component
 */
function h$b(tag, bind)

Хорошо, теперь я готов его использовать: я буду использовать 'input' для тега, а для второго мне нужен объект с StreamBox и… подождите, какие это свойства упоминаются в документе?

Немного покопавшись разбираемся (снова) и решаем исправить проблему раз и навсегда:

/**
 * Element with Bind.
 * Create an DOM Node and bind properties to a StreamBox.
 * 
 * @param tag A string with an HTML Tag like 'div'
 * @param bind An object specifying which property to bind to which stream box. We support 2 properties: `value` and `checked`. 
 *            This parameter should look like: {value: StreamBox}
 * @returns A component
 */
function h$b(tag, bind)

Допустимым примером будет:

const stream = new StreamBox('')
const comp = h$b('input', {value: stream})

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

Нет типов — тестирование

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

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

Для такой простой функции юнит-тестов должно быть достаточно.

Итак, мы начинаем тестирование и охватываем все следующие случаи:

  • Передача любых свойств, кроме value или checked, вызовет исключение
  • Передача чего-то другого, кроме input или других допустимых тегов элемента HTML, поддерживающих value и checked, вызовет исключение.
  • Для свойства checked разрешен только тег input.
  • Установка значения для StreamBox, связанного с value, упорядочит его.
  • Установка значения для StreamBox, привязанного к checked, сделает его логическим
  • Изменение значения StreamBox также меняет значение элемента.
  • Изменение значения элемента также меняет значение StreamBox.

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

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

const stream = new StreamBox('')
hc('div', [
   h$b('input', { valeu: stream })
])

Мы тщательно тестировали и hc, и h$b, поэтому совершенно уверены, что этот код будет работать. Мы запускаем его через наш любимый сборщик и открываем браузер, чтобы насладиться нашим чудесным творением… пустой страницей.

Хорошо, что-то пошло не так. Мы открываем консоль браузера и находим исключение вроде:

Error: `valeu` property is not supported.

Ой! Глупая опечатка.

Нет типов — извлеченные уроки

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

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

Знакомство с типами

Типизированная версия функции выше может быть:

function h$b(tag: string, bind: Object): Function

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

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

Давайте вернемся к документации, которую мы написали для Javascript:

/**
 * Element with Bind.
 * Create an DOM Node and bind properties to a StreamBox.
 * 
 * @param tag A string with an HTML Tag like 'div'
 * @param bind An object specifying which property to bind to which stream box. We support 2 properties: `value` and `checked`. 
 *            This parameter should look like: {value: StreamBox}
 * @returns A component
 */

Хорошо, мы можем кодифицировать это:

type Tag = string
type Bind = { value?: StreamBox, checked?: StreamBox }
type Component = () => Node
function h$b(tag: Tag, bind: Bind): Component

Сейчас мы говорим! Ноль документации, и у нас уже больше информации, чем раньше: Component на самом деле является функцией, которая ничего не получает и возвращает Node. (Обратите внимание, что компонент CRUI немного сложнее этого)

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

Типы — тестирование

У нас есть наша функция, но теперь нам нужно снова покрыть ее тестами.

Давайте вернемся к ним.

Передача любого свойства, кроме value или checked, вызовет исключение.

Подождите, на самом деле у нас больше нет этой проблемы, потому что мы определили Bind; система типов будет обеспечивать соблюдение этого правила.

Хорошо, переходим к следующему:

Передача чего-либо, кроме input или других допустимых тегов элемента HTML, вызовет исключение.

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

type Tag = 'input'|'select'|'textarea'
function h$b(tag: Tag, bind: Bind): Component

Хороший! Если мы попытаемся использовать что-то еще, кроме input , select или textarea, компилятор Typescript будет кричать на нас, заставляя нас использовать допустимый тег.

Следующий:

Для ресурса checked разрешен только тег input.

Давайте еще немного подумаем: мы можем использовать value для всех тегов, которые мы только что определили, но checked только с input. Интересно, что Typescript и здесь может помочь благодаря перегрузке функций:

type Tag = 'input'|'select'|'textarea'
type BindValue = { value: StreamBox }
type BindChecked = { checked: StreamBox }
function h$b(tag: Tag, bind: BindValue): Component
function h$b(tag: 'input', bind: BindChecked): Component

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

Еще один тест пропущен, следующий, пожалуйста!

Установка значения для StreamBox, привязанного к value, сделает его строковым
Установка значения для StreamBox, привязанного к checked, сделает его логическим

Другими словами: value работает только со строками, а checked только с логическими значениями.

Мы определенно можем решить эту проблему, улучшив определение StreamBox:

type BindValue = { value: StreamBox<string> }
type BindChecked = { checked: StreamBox<boolean> }

Если вы не знакомы с дженериками, то это просто информирование компилятора о том, что StreamBox содержит значение строкового типа для value и логическое значение для checked.

Между <> можно добавить любой другой тип, поэтому он называется универсальным.

Вот последние два критерия приемлемости:

Изменение значения StreamBox также меняет значение элемента.

Изменение значения элемента также меняет значение StreamBox.

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

В конце концов, мы рассмотрели 5 критериев приемлемости из 7, используя систему типов. Это невероятный результат, особенно потому, что логика и код нашей функции теперь свободны от всего защитного кода и исключений, которые мы должны были использовать в версии Javascript, что делает ее более простой и менее подверженной ошибкам.

Уроки выучены

  • Просто написание некоторыхтипов не поможет. Нам нужно написать правильные типы.
  • В системе типов можно закодировать некоторую логику, что позволит избежать необходимости проверок во время выполнения, упростить логику, повысить производительность и предотвратить ошибки при кодировании благодаря поддержке IDE.
  • Нам нужно писать гораздо меньше тестов, чем раньше, но при этом иметь более сильные гарантии общей корректности.
  • Правильность составляет: типы могут гарантировать, что каждая функция используется по назначению и правильно; поэтому модульные тесты гораздо более актуальны.
  • Интеграционные тесты менее актуальны, но все же важны для покрытия случаев, когда типов недостаточно, например: функция, которая ожидает, что элементы в определенном порядке должным образом выполнят свою работу.
  • 100% покрытие тестами достигается за счет множества модульных тестов и некоторых интеграционных тестов. Лично я не считаю это требованием.

Самое главное здесь то, что теперь компилятор не позволит нам (и любому пользователю нашей функции) совершать какие-либо глупые ошибки — и это повысит производительность.

Реальный кейс, лучше тестов

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