Здравствуйте, меня зовут Дмитрий Карловский и у меня, к сожалению, нет времени писать большую статью, но очень хочется поделиться некоторыми идеями. Поэтому позвольте потестировать на вас небольшую заметку о программировании. Речь сегодня пойдёт об автоматическом тестировании:
Зачем мы пишем тесты?
Какие бывают тесты?
Как мы пишем тесты?
Как их стоит писать?
Почему модульные тесты - это плохо?
Задачи автоматического тестирования
От более важного к менее:
Обнаружение дефектов как можно раньше. До того как увидит пользователь, до того как выложить на сервер, до того как отдать на тестирование, до того как закоммитить.
Локализация проблемы. Тест затрагивает лишь часть кода.
Ускорение разработки. Исполнение теста происходит гораздо быстрее ручной проверки.
Актуальная документация. Тест представляет из себя простой и гарантированно актуальный пример использования.
Ортогональные классификации
Классификация по объекту
Классификация по типам тестов
Классификация по видам процесса тестирования
На всякий случай подчеркну, что речь идёт исключительно про автоматическое тестирование.
Объекты тестирования
Модуль или юнит - минимальный кусок кода, который можно протестировать независимо от всего остального кода. Тестирование модулей так же известно как "юнит-тестирование".
Компонент - относительно самостоятельная часть приложения. Может включать в себя другие компоненты и модули.
Приложение или система - вырожденный случай компонента, косвенно включающего в себя все остальные компоненты.
Регрессионный - проверка отсутствия дефектов в не изменявшейся функциональности.
Дымовой - проверка основной функциональности на явные дефекты.
Полный - проверка всей функциональности.
Конфигурационный - проверка всей функциональности на разных конфигурациях.
Количество тестов
Тесты - это код.
Любой код требует времени на написание.
Любой код требует время на поддержку.
Любой код может содержать ошибки.
Чем больше тестов, тем медленнее идёт разработка.
Полнота тестирования
Тесты должны проверить все пользовательские сценарии.
Тесты должны зайти в каждую ветку логики.
Тесты должны проверить все классы эквивалентности.
Тесты должны проверить все граничные условия.
Тесты должны проверить реакцию на нестандартные условия.
Чем полнее тесты, тем быстрее идёт рефакторинг и тестирование, и как следствие поставка новой функциональности.
Бизнес приоритеты
Максимизация скорости разработки. Разработчику надо писать минимум тестов, которые быстро исполняются.
Минимизация дефектов. Надо обеспечивать максимальное покрытие.
Минимизация стоимости разработки. Надо тратить минимум усилий на написание и поддержку кода (в том числе и тестов).
Стратегии тестирования
В зависимости от приоритетов, можно выделить несколько основных стратегий:
Качество. Пишем функциональные тесты на все модули. Проверяем их совместимость интеграционными тестами. Добавляем тесты на все невырожденные компоненты. Не забываем и про интеграционные для компонент. Присыпаем тестами всего приложения. Многоуровневое исчерпывающее тестирование потребует много времени и ресурсов, но позволит с большей вероятностью выявить дефекты.
Скорость. Используем лишь дымовое тестирование приложения. Мы точно знаем, что основные функции работают, а остальное починим, если вдруг. Таким образом мы быстро поставляем функциональность, но тратим много ресурсов на доведение её до ума.
Cтоимость. Пишем тесты лишь на всё приложение. Критичные дефекты таким образом обнаруживаются заблаговременно, что позволяет снизить стоимость поддержки и как следствие относительно высокую скорость поставки новой функциональности.
Качество и скорость. Покрываем тестами все (в том числе вырожденные) компоненты, что даёт максимальное покрытие минимумом тестов, а следовательно минимум дефектов при высокой скорости, в результате давая и относительно низкую стоимость.
Пример приложения
Чтобы моя аналитика не была совсем уж голословной, давайте создадим простейшее приложение из двух компонент. Оно будет содержать поле ввода имени и блок с выводом приветствия, адресованного этому имени.
@mem - реактивный кэширующий декоратор. this.$ - di-контекст. Связывание происходит через переопределение свойств. .make просто создаёт экземпляр и переопределяет указанные свойства.
Компонентное тестирование
При этом подходе мы используем реальные зависимости всегда, когда это возможно.
Что следует мокать в любом случае:
Взаимодействие со внешним миром (http, localStorage, location и тп)
Недетерминированнось (Math.random, Date.now и тп)
Особо медленные вещи (вычисление криптоскойкого хэша и тп)
Асинхронность (синхронные тесты проще в понимании и отладке)
Итак, сперва пишем тест на вложенный компонент:
// Components tests of $my_hello_message
$mol_test({
'print greeting to defined target'() {
const app = new $my_hello_message
app.target = ()=> 'Jin'
$mol_assert_equal( app.sub().join( '' ) , 'Hello, Jin' )
} ,
})
А теперь добавляем тесты на внешний компонент:
// Components tests of $my_hello
$mol_test({
'contains Input and Output'() {
const app = new $my_hello
$mol_assert_like( app.sub() , [
app.Input() ,
app.Output() ,
] )
} ,
'print greeting with name from input'() {
const app = new $my_hello
$mol_assert_equal( app.Output().sub().join( '' ) , 'Hello, ' )
app.Input().value( 'Jin' )
$mol_assert_equal( app.Output().sub().join( '' ), 'Hello, Jin' )
} ,
})
Как можно заметить, всё, что нам потребовалось - это публичный интерфейс компонент. Обратите внимание, нам всё равно через какое свойство и как передаётся значение в Output. Мы проверяем именно требования: чтобы выводимое приветствие соответствовало введённому пользователем имени.
Модульное тестирование
Для модульных тестов необходимо изолировать модуль от остального кода. Когда модуль никак не взаимодействует с другими модулями, тесты получаются такими же, как и компонентные:
// Unit tests of $my_hello_message
$mol_test({
'print greeting to defined target'() {
const app = new $my_hello_message
app.target = ()=> 'Jin'
$mol_assert_equal( app.sub().join( '' ), 'Hello, Jin' )
} ,
})
Если же модулю нужны другие модули, то они заменяются заглушками и мы проверяем, что коммуникация с ними происходит как ожидается.
// Unit tests of $my_hello
$mol_test({
'contains Input and Output'() {
const app = new $my_hello
const Input = {} as $mol_string
app.Input = ()=> Input
const Output = {} as $mol_hello_message
app.Output = ()=> Output
$mol_assert_like( app.sub() , [
Input ,
Output ,
] )
} ,
'Input value binds to name'() {
const app = new $my_hello
app.$ = Object.create( $ )
const Input = {} as $mol_string
app.$.$mol_string = function(){ return Input } as any
$mol_assert_equal( app.name() , '' )
Input.value( 'Jin' )
$mol_assert_equal( app.name() , 'Jin' )
} ,
'Output target binds to name'() {
const app = new $my_hello
app.$ = Object.create( $ )
const Output = {} as $my_hello_message
app.$.$mol_hello_message = function(){ return Output } as any
$mol_assert_equal( Output.title() , '' )
app.name( 'Jin' )
$mol_assert_equal( Output.title() , 'Jin' )
} ,
})
Мокирование не бесплатно - оно ведёт к усложнению тестов. Но самое печальное - это то, что проверив работу с моками, вы не можете быть уверенными, что с реальными модулями всё это заработает правильно. Если вы были внимательными, то уже заметили, что в последнем коде мы ожидаем, что имя нужно передавать, через свойство title. А это приводит нас к ошибкам двух типов:
Правильный код модуля может давать ошибки на моках.
Дефектный код модуля может не давать ошибки на моках.
И, наконец, тесты, получается, проверяют не требования (напомню - должно выводиться приветствие с подставленным именем), а реализацию (внутри вызывается такой-то метод с такими-то параметрами). А это значит, что тесты получаются хрупкими.
Хрупкие тесты - такие тесты, которые ломаются при эквивалентных изменениях реализации.
Эквивалентные изменения - такие изменения реализации, которые не ломают соответствие кода функциональным требованиям.
Test Driven Development
Алгоритм TDD довольно прост и весьма полезен:
Пишем тест, убеждаемся, что он падает, что означает, что тест реально что-то тестирует и изменения в коде реально необходимы.
Пишем код, пока тест не перестанет падать, что означает, что мы выполнили все требования.
Рефакторим код, убеждаясь, что тест не падает, что означает, что наш код по прежнему соответствует требованиям.
Если мы пишем хрупкие тесты, то на шаге рефакторига они будут постоянно падать, требуя исследования и корректировки, что снижает производительность программиста.
Интеграционные тесты
Чтобы побороть оставшиеся после модульных тестов кейсы, придумали дополнительный вид тестов - интеграционные. Тут мы берём несколько модулей и проверяем, что взаимодействуют они правильно:
Ага, у нас получился тот самый последний компонентный тест. Иначе говоря, мы так или иначе написали все компонентные тесты, проверяющие требования, но дополнительно зафиксировали в тестах конкретную реализацию логики. Как правило это избыточно.
Статистика
Criteria
Cascaded component
Modular + Integrational
CLOS
17
34 + 8
Complexity
Simple
Complex
Incapsulation
Black box
White box
Fragility
Low
High
Coverage
Full
Extra
Velocity
High
Low
Duration
Low
High
Заблуждения
Модульные тесты быстрее компонентных
Да, моки как правило исполняются быстрее, чем реальный код. Однако они прячут некоторые виды ошибок, из-за чего приходится писать больше тестов. Если фреймворк не умеет в ленивость и делает много лишней работы для поднятия дерева компонент (как, например, web-components гвоздями прибитые к DOM или TestBed в Angular создающий всё на свете при инициализации), то тесты существенно замедляются, но не так чтобы фатально. Если же фреймворк не рендерит, пока его об этом не попросят и не создаёт компоненты, пока они не потребуются (как, например, $mol_view), компонентные тесты проходят не медленнеее модульных.
С компонентными тестами сложно локализовать ошибку
Да, если они исполняются в случайном порядке, то ошибка в логике может уронить кучу тестов от чего может быть не понятно откуда начинать копать. Это, к сожалению, распространённый анти-паттерн - найти все файлы с заданным расширением и выполнить их в случайном порядке, мол тесты же не зависят друг от друга. И это справидливо для модульных тестов.
Однако, исполнять компонентные тесты имеет смысл в порядке от менее зависимых компонент к более зависимым. Тогда первый же упавший тест покажет на источник проблемы. Остальные тесты обычно можно уже и не исполнять, что здорово экономит время прохождения тестов. Опять же, в MAM архитектуре весь код (что продакшен, что тестовый) сериализуется в едином подярке. Это гарантирует, что тесты зависимости будут исполнены до тестов зависимого, а значит тот может смело полагаться на то, что зависимость работает корректно. Если вы используете иные инструменты - подумайте, как с их помощью можно выстраивать тесты в правильном порядке.
Шаблоны тестировать не надо
Тестировать надо логику. Редкий шаблонизатор (mustache, view.tree) запрещает встраивать логику в шаблоны, а значит их тоже надо тестировать. Часто модульные тесты для этого не годятся (enzyme в качестве редкого исключения), так что всё равно приходится прибегать к компонентным.
Тесты должны соответствовать шаблону Given/When/Then
Да, иногда в тестовом сценари можно выделить эти шаги, но не стоит высасывать их из пальца, когда их нет. Зачастую сценарий имеет более простую (например, только Then блок) или сложную (Given/Check/When/Then) структуру. Несколько примеров:
Чистые функции часто имеют только блок Then:
console.assert( Math.pow( 2 , 3 ) === 8 ) // Then
Не менее часто действие (When) заключается именно в подготовке состояния (Given):
component.setState({ name : 'Jin' }) // Given/When
console.assert( component.greeting === 'Hello, Jin!' ) // Then
А бывает, что и проверка не нужна, ибо сам факт успешного выполнения кода достаточен:
ensurePerson({ name : 'Jin' , age : 33 })
Подобный же код совершенно бессмысленный:
const component = new MyComponent // Given
expect( component ).toBeTruthy() // Then
Так же как тест, который никогда не падал - ничего не тестирует. Так и ассерт, который никогда не кидал исключение - ничего не проверяет.
В правильном тесте должен быть только один assert
Не редко необходимо проверять правильно ли мы выполнили подготовку состояния поверкой в середине:
wizard.nextStep().nextStep() // Given
console.assert( wizard.passport.isVisible === false ) // Check
wizard.toggleRegistration() // When
console.assert( wizard.passport.isVisible === true ) // Then
Разбивать этот тест на два следующих нельзя, так как второй неявно полагается на состояние создаваемое первым:
wizard.nextStep().nextStep() // When
console.assert( wizard.passport.isVisible === false ) // Then
wizard.nextStep().nextStep() // Given
wizard.toggleRegistration() // When
console.assert( wizard.passport.isVisible === true ) // Then
Представье, что требования изменились и теперь форму регистрации мы по умолчанию показываем:
wizard.nextStep().nextStep() // When
console.assert( wizard.passport.isVisible === true ) // Then
Теперь, если toggleRegistration реализован так, что, например, использует своё состояние для ускорения работы, то он будет проходить второй тест, по прежнему возвращая true и получится, что первое применение toggleRegistration не будет ничего менять в форме:
В варианте с дополнительной проверкой дефолтного состояния мы бы словили упавший тест в этом случае. Более того, не стоит бояться писать и более длинные сценарии, если следующий шаг базируется на состоянии предыдущего.
wizard.nextStep().nextStep() // When
console.assert( wizard.passport.isVisible === false ) // Then
wizard.toggleRegistration() // When
console.assert( wizard.passport.isVisible === true ) // Then
wizard.toggleRegistration() // When
console.assert( wizard.passport.isVisible === false ) // Then
Обычно аргументом против такого подхода выступает сложность понимания какой из ассертов упал. Но постойте, никто же не заставляет вас использовать такой инструмент тестирования, который не даёт исчерпывающей информации о месте падения теста. Хороший же инструмент (например, $mol_test) даже услужливо остановит отладчик в этом месте, позволяя вам сразу же приступить к исследованию проблемы.
Подводя итог, можно порекомендовать писать тесты не по шаблону "Given/When/Then", а как небольшое приключение, стартующее из абсолютной пустоты и посредством некоторого количества действий, проходящее через некоторое количество состояний, которые мы и проверяем.
https://page.hyoo.ru/#!=mnd8x2_rawwi6
Здравствуйте, меня зовут Дмитрий Карловский и у меня, к сожалению, нет времени писать большую статью, но очень хочется поделиться некоторыми идеями. Поэтому позвольте потестировать на вас небольшую заметку о программировании. Речь сегодня пойдёт об автоматическом тестировании:
Задачи автоматического тестирования
От более важного к менее:
Ортогональные классификации
На всякий случай подчеркну, что речь идёт исключительно про автоматическое тестирование.
Объекты тестирования
Типы тестов
Виды процессов тестирования
Количество тестов
Чем больше тестов, тем медленнее идёт разработка.
Полнота тестирования
Чем полнее тесты, тем быстрее идёт рефакторинг и тестирование, и как следствие поставка новой функциональности.
Бизнес приоритеты
Стратегии тестирования
В зависимости от приоритетов, можно выделить несколько основных стратегий:
Пример приложения
Чтобы моя аналитика не была совсем уж голословной, давайте создадим простейшее приложение из двух компонент. Оно будет содержать поле ввода имени и блок с выводом приветствия, адресованного этому имени.
Тем, кто не знаком с этой нотацией, предлагаю взглянуть на эквивалентный TypeScript код:
@mem
- реактивный кэширующий декоратор.this.$
- di-контекст. Связывание происходит через переопределение свойств..make
просто создаёт экземпляр и переопределяет указанные свойства.Компонентное тестирование
При этом подходе мы используем реальные зависимости всегда, когда это возможно.
Что следует мокать в любом случае:
Итак, сперва пишем тест на вложенный компонент:
А теперь добавляем тесты на внешний компонент:
Как можно заметить, всё, что нам потребовалось - это публичный интерфейс компонент. Обратите внимание, нам всё равно через какое свойство и как передаётся значение в Output. Мы проверяем именно требования: чтобы выводимое приветствие соответствовало введённому пользователем имени.
Модульное тестирование
Для модульных тестов необходимо изолировать модуль от остального кода. Когда модуль никак не взаимодействует с другими модулями, тесты получаются такими же, как и компонентные:
Если же модулю нужны другие модули, то они заменяются заглушками и мы проверяем, что коммуникация с ними происходит как ожидается.
Мокирование не бесплатно - оно ведёт к усложнению тестов. Но самое печальное - это то, что проверив работу с моками, вы не можете быть уверенными, что с реальными модулями всё это заработает правильно. Если вы были внимательными, то уже заметили, что в последнем коде мы ожидаем, что имя нужно передавать, через свойство
title
. А это приводит нас к ошибкам двух типов:И, наконец, тесты, получается, проверяют не требования (напомню - должно выводиться приветствие с подставленным именем), а реализацию (внутри вызывается такой-то метод с такими-то параметрами). А это значит, что тесты получаются хрупкими.
Test Driven Development
Алгоритм TDD довольно прост и весьма полезен:
Если мы пишем хрупкие тесты, то на шаге рефакторига они будут постоянно падать, требуя исследования и корректировки, что снижает производительность программиста.
Интеграционные тесты
Чтобы побороть оставшиеся после модульных тестов кейсы, придумали дополнительный вид тестов - интеграционные. Тут мы берём несколько модулей и проверяем, что взаимодействуют они правильно:
Ага, у нас получился тот самый последний компонентный тест. Иначе говоря, мы так или иначе написали все компонентные тесты, проверяющие требования, но дополнительно зафиксировали в тестах конкретную реализацию логики. Как правило это избыточно.
Статистика
Заблуждения
Модульные тесты быстрее компонентных
Да, моки как правило исполняются быстрее, чем реальный код. Однако они прячут некоторые виды ошибок, из-за чего приходится писать больше тестов. Если фреймворк не умеет в ленивость и делает много лишней работы для поднятия дерева компонент (как, например, web-components гвоздями прибитые к DOM или TestBed в Angular создающий всё на свете при инициализации), то тесты существенно замедляются, но не так чтобы фатально. Если же фреймворк не рендерит, пока его об этом не попросят и не создаёт компоненты, пока они не потребуются (как, например, $mol_view), компонентные тесты проходят не медленнеее модульных.
С компонентными тестами сложно локализовать ошибку
Да, если они исполняются в случайном порядке, то ошибка в логике может уронить кучу тестов от чего может быть не понятно откуда начинать копать. Это, к сожалению, распространённый анти-паттерн - найти все файлы с заданным расширением и выполнить их в случайном порядке, мол тесты же не зависят друг от друга. И это справидливо для модульных тестов.
Однако, исполнять компонентные тесты имеет смысл в порядке от менее зависимых компонент к более зависимым. Тогда первый же упавший тест покажет на источник проблемы. Остальные тесты обычно можно уже и не исполнять, что здорово экономит время прохождения тестов. Опять же, в MAM архитектуре весь код (что продакшен, что тестовый) сериализуется в едином подярке. Это гарантирует, что тесты зависимости будут исполнены до тестов зависимого, а значит тот может смело полагаться на то, что зависимость работает корректно. Если вы используете иные инструменты - подумайте, как с их помощью можно выстраивать тесты в правильном порядке.
Шаблоны тестировать не надо
Тестировать надо логику. Редкий шаблонизатор (mustache, view.tree) запрещает встраивать логику в шаблоны, а значит их тоже надо тестировать. Часто модульные тесты для этого не годятся (enzyme в качестве редкого исключения), так что всё равно приходится прибегать к компонентным.
Тесты должны соответствовать шаблону Given/When/Then
Да, иногда в тестовом сценари можно выделить эти шаги, но не стоит высасывать их из пальца, когда их нет. Зачастую сценарий имеет более простую (например, только Then блок) или сложную (Given/Check/When/Then) структуру. Несколько примеров:
Чистые функции часто имеют только блок Then:
Не менее часто действие (When) заключается именно в подготовке состояния (Given):
А бывает, что и проверка не нужна, ибо сам факт успешного выполнения кода достаточен:
Подобный же код совершенно бессмысленный:
Так же как тест, который никогда не падал - ничего не тестирует. Так и ассерт, который никогда не кидал исключение - ничего не проверяет.
В правильном тесте должен быть только один assert
Не редко необходимо проверять правильно ли мы выполнили подготовку состояния поверкой в середине:
Разбивать этот тест на два следующих нельзя, так как второй неявно полагается на состояние создаваемое первым:
Представье, что требования изменились и теперь форму регистрации мы по умолчанию показываем:
Теперь, если
toggleRegistration
реализован так, что, например, использует своё состояние для ускорения работы, то он будет проходить второй тест, по прежнему возвращая true и получится, что первое применениеtoggleRegistration
не будет ничего менять в форме:В варианте с дополнительной проверкой дефолтного состояния мы бы словили упавший тест в этом случае. Более того, не стоит бояться писать и более длинные сценарии, если следующий шаг базируется на состоянии предыдущего.
Обычно аргументом против такого подхода выступает сложность понимания какой из ассертов упал. Но постойте, никто же не заставляет вас использовать такой инструмент тестирования, который не даёт исчерпывающей информации о месте падения теста. Хороший же инструмент (например, $mol_test) даже услужливо остановит отладчик в этом месте, позволяя вам сразу же приступить к исследованию проблемы.
Подводя итог, можно порекомендовать писать тесты не по шаблону "Given/When/Then", а как небольшое приключение, стартующее из абсолютной пустоты и посредством некоторого количества действий, проходящее через некоторое количество состояний, которые мы и проверяем.
Ссылки по теме