Здравствуйте, меня зовут Дмитрий Карловский и у меня, к сожалению, нет времени писать большую статью, но очень хочется поделиться некоторыми идеями. Поэтому позвольте потестировать на вас небольшую заметку о программировании. Речь сегодня пойдёт об автоматическом тестировании:
- Зачем мы пишем тесты?
- Какие бывают тесты?
- Как мы пишем тесты?
- Как их стоит писать?
- Почему модульные тесты — это плохо?
Задачи автоматического тестирования
От более важного к менее:
- Обнаружение дефектов как можно раньше. До того как увидит пользователь, до того как выложить на сервер, до того как отдать на тестирование, до того как закоммитить.
- Локализация проблемы. Тест затрагивает лишь часть кода.
- Ускорение разработки. Исполнение теста происходит гораздо быстрее ручной проверки.
- Актуальная документация. Тест представляет из себя простой и гарантированно актуальный пример использования.
Ортогональные классификации
- Классификация по объекту
- Классификация по типам тестов
- Классификация по видам процесса тестирования
На всякий случай подчеркну, что речь идёт исключительно про автоматическое тестирование.
Объекты тестирования
- Модуль или юнит — минимальный кусок кода, который можно протестировать независимо от всего остального кода. Тестирование модулей так же известно как "юнит-тестирование".
- Компонент — относительно самостоятельная часть приложения. Может включать в себя другие компоненты и модули.
- Приложение или система — вырожденный случай компонента, косвенно включающего в себя все остальные компоненты.
Типы тестов
- Функциональные — проверка соответствия требованиям функциональности
- Интеграционные — проверка совместимости соседних объектов тестирования
- Нагрузочные — проверка соответствия требованиям производительности
Виды процессов тестирования
- Приёмочный — проверка новой/изменённой функциональности.
- Регрессионный — проверка отсутствия дефектов в не изменявшейся функциональности.
- Дымовой — проверка основной функциональности на явные дефекты.
- Полный — проверка всей функциональности.
- Конфигурационный — проверка всей функциональности на разных конфигурациях.
Количество тестов
- Тесты — это код.
- Любой код требует времени на написание.
- Любой код требует время на поддержку.
- Любой код может содержать ошибки.
Чем больше тестов, тем медленнее идёт разработка.
Полнота тестирования
- Тесты должны проверить все пользовательские сценарии.
- Тесты должны зайти в каждую ветку логики.
- Тесты должны проверить все классы эквивалентности.
- Тесты должны проверить все граничные условия.
- Тесты должны проверить реакцию на нестандартные условия.
Чем полнее тесты, тем быстрее идёт рефакторинг и тестирование, и как следствие поставка новой функциональности.
Бизнес приоритеты
- Максимизация скорости разработки. Разработчику надо писать минимум тестов, которые быстро исполняются.
- Минимизация дефектов. Надо обеспечивать максимальное покрытие.
- Минимизация стоимости разработки. Надо тратить минимум усилий на написание и поддержку кода (в том числе и тестов).
Стратегии тестирования
В зависимости от приоритетов, можно выделить несколько основных стратегий:
- Качество. Пишем функциональные тесты на все модули. Проверяем их совместимость интеграционными тестами. Добавляем тесты на все невырожденные компоненты. Не забываем и про интеграционные для компонент. Присыпаем тестами всего приложения. Многоуровневое исчерпывающее тестирование потребует много времени и ресурсов, но позволит с большей вероятностью выявить дефекты.
- Скорость. Используем лишь дымовое тестирование приложения. Мы точно знаем, что основные функции работают, а остальное починим, если вдруг. Таким образом мы быстро поставляем функциональность, но тратим много ресурсов на доведение её до ума.
- Cтоимость. Пишем тесты лишь на всё приложение. Критичные дефекты таким образом обнаруживаются заблаговременно, что позволяет снизить стоимость поддержки и как следствие относительно высокую скорость поставки новой функциональности.
- Качество и скорость. Покрываем тестами все (в том числе вырожденные) компоненты, что даёт максимальное покрытие минимумом тестов, а следовательно минимум дефектов при высокой скорости, в результате давая и относительно низкую стоимость.
Пример приложения
Чтобы моя аналитика не была совсем уж голословной, давайте создадим простейшее приложение из двух компонент. Оно будет содержать поле ввода имени и блок с выводом приветствия, адресованного этому имени.
$my_hello $mol_list
rows /
<= Input $mol_string
value?val <=> name?val
<= Output $my_hello_message
target <= name -
$my_hello_message $mol_view
sub /
Hello,
<= target
Тем, кто не знаком с этой нотацией, предлагаю взглянуть на эквивалентный TypeScript код:
export class $my_hello extends $mol_list {
rows() {
return [ this.Input() , this.Output() ]
}
@mem
Input() {
return this.$.$mol_string.make({
value : next => this.name( next ) ,
})
}
@mem
Output() {
return this.$.$my_hello_message.make({
target : ()=> this.name() ,
})
}
@mem
name( next = '' ) { return next }
}
export class $my_hello_message extends $mol_view {
sub() {
return [ 'Hello, ' , this.target() ]
}
target() {
return ''
}
}
@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 довольно прост и весьма полезен:
- Пишем тест, убеждаемся, что он падает, что означает, что тест реально что-то тестирует и изменения в коде реально необходимы.
- Пишем код, пока тест не перестанет падать, что означает, что мы выполнили все требования.
- Рефакторим код, убеждаясь, что тест не падает, что означает, что наш код по прежнему соответствует требованиям.
Если мы пишем хрупкие тесты, то на шаге рефакторига они будут постоянно падать, требуя исследования и корректировки, что снижает производительность программиста.
Интеграционные тесты
Чтобы побороть оставшиеся после модульных тестов кейсы, придумали дополнительный вид тестов — интеграционные. Тут мы берём несколько модулей и проверяем, что взаимодействуют они правильно:
// Integration tests of $my_hello
$mol_test({
'print greeting with name'() {
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' )
} ,
})
Ага, у нас получился тот самый последний компонентный тест. Иначе говоря, мы так или иначе написали все компонентные тесты, проверяющие требования, но дополнительно зафиксировали в тестах конкретную реализацию логики. Как правило это избыточно.
Статистика
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), компонентные тесты проходят не медленнеее модульных.
- С компонентными тестами сложно локализовать ошибку. Да, если они исполняются в случайном порядке, то ошибка в логике может уронить кучу тестов от чего может быть не понятно откуда начинать копать. Однако, исполнять компонентные тесты имеет смысл от менее зависимых компонент к более зависимым. Тогда первый же упавший тест покажет на источник проблемы. Остальные тесты обычно можно уже и не исполнять.
- Шаблоны тестировать не нужно. Тестировать надо логику. Редкий шаблонизатор запрещает встраивать логику в шаблоны, а значит их тоже надо тестировать. Часто модульные тесты для этого не годятся, так что всё равно приходится прибегать к компонентным.
Ссылки по теме
- Проблема дублирования и устаревания знания в mock-объектах или Интеграционные тесты — это хорошо
- Тавтологические тесты
Автор: Дмитрий Карловский