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