Здравствуйте, меня зовут Дмитрий Карловский и у меня, к сожалению, нет времени писать большую статью, но очень хочется поделиться некоторыми идеями. Поэтому позвольте потестировать на вас небольшую заметку о программировании. Речь сегодня пойдёт об автоматическом тестировании:
- Зачем мы пишем тесты?
- Какие бывают тесты?
- Как мы пишем тесты?
- Как их стоит писать?
- Почему модульные тесты — это плохо?
Задачи автоматического тестирования
От более важного к менее:
- Обнаружение дефектов как можно раньше. До того как увидит пользователь, до того как выложить на сервер, до того как отдать на тестирование, до того как закоммитить.
- Локализация проблемы. Тест затрагивает лишь часть кода.
- Ускорение разработки. Исполнение теста происходит гораздо быстрее ручной проверки.
- Актуальная документация. Тест представляет из себя простой и гарантированно актуальный пример использования.
Ортогональные классификации
- Классификация по объекту
- Классификация по типам тестов
- Классификация по видам процесса тестирования
На всякий случай подчеркну, что речь идёт исключительно про автоматическое тестирование.
Объекты тестирования
- Модуль или юнит — минимальный кусок кода, который можно протестировать независимо от всего остального кода. Тестирование модулей так же известно как "юнит-тестирование".
- Компонент — относительно самостоятельная часть приложения. Может включать в себя другие компоненты и модули.
- Приложение или система — вырожденный случай компонента, косвенно включающего в себя все остальные компоненты.
Типы тестов
- Функциональные — проверка соответствия требованиям функциональности
- Интеграционные — проверка совместимости соседних объектов тестирования
- Нагрузочные — проверка соответствия требованиям производительности
Виды процессов тестирования
- Приёмочный — проверка новой/изменённой функциональности.
- Регрессионный — проверка отсутствия дефектов в не изменявшейся функциональности.
- Дымовой — проверка основной функциональности на явные дефекты.
- Полный — проверка всей функциональности.
- Конфигурационный — проверка всей функциональности на разных конфигурациях.
Количество тестов
- Тесты — это код.
- Любой код требует времени на написание.
- Любой код требует время на поддержку.
- Любой код может содержать ошибки.
Чем больше тестов, тем медленнее идёт разработка.
Полнота тестирования
- Тесты должны проверить все пользовательские сценарии.
- Тесты должны зайти в каждую ветку логики.
- Тесты должны проверить все классы эквивалентности.
- Тесты должны проверить все граничные условия.
- Тесты должны проверить реакцию на нестандартные условия.
Чем полнее тесты, тем быстрее идёт рефакторинг и тестирование, и как следствие поставка новой функциональности.
Бизнес приоритеты
- Максимизация скорости разработки. Разработчику надо писать минимум тестов, которые быстро исполняются.
- Минимизация дефектов. Надо обеспечивать максимальное покрытие.
- Минимизация стоимости разработки. Надо тратить минимум усилий на написание и поддержку кода (в том числе и тестов).
Стратегии тестирования
В зависимости от приоритетов, можно выделить несколько основных стратегий:
- Качество. Пишем функциональные тесты на все модули. Проверяем их совместимость интеграционными тестами. Добавляем тесты на все невырожденные компоненты. Не забываем и про интеграционные для компонент. Присыпаем тестами всего приложения. Многоуровневое исчерпывающее тестирование потребует много времени и ресурсов, но позволит с большей вероятностью выявить дефекты.
- Скорость. Используем лишь дымовое тестирование приложения. Мы точно знаем, что основные функции работают, а остальное починим, если вдруг. Таким образом мы быстро поставляем функциональность, но тратим много ресурсов на доведение её до ума.
- 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), компонентные тесты проходят не медленнеее модульных.
- С компонентными тестами сложно локализовать ошибку. Да, если они исполняются в случайном порядке, то ошибка в логике может уронить кучу тестов от чего может быть не понятно откуда начинать копать. Однако, исполнять компонентные тесты имеет смысл от менее зависимых компонент к более зависимым. Тогда первый же упавший тест покажет на источник проблемы. Остальные тесты обычно можно уже и не исполнять.
- Шаблоны тестировать не нужно. Тестировать надо логику. Редкий шаблонизатор запрещает встраивать логику в шаблоны, а значит их тоже надо тестировать. Часто модульные тесты для этого не годятся, так что всё равно приходится прибегать к компонентным.
Ссылки по теме
Комментарии (43)
babylon
18.03.2018 22:43-1Также не понял, для кого статья предназначалась.
Для меня:). Как всегда Дмитрий выражает мысли лаконично, но вместе с тем предельно чётко. Если в голове нет сумбура, то и в статье откуда ему взяться???
lair
18.03.2018 22:54Если же модулю нужны другие модули, то они заменяются заглушками и мы проверяем, что коммуникация с ними происходит как ожидается.
Это только если вы так решили. А можно, наоборот, подставить модуль, который ведет себя так, как ожидает тестируемый, и проверять поведение тестируемого.
Другое дело, что иногда цель модульного тестирования — это и есть проверка комуникации с зависимостями, и тогда без моков вы не обойдетесь.
babylon
19.03.2018 01:11Его проблематично использовать в ФП, но в ООП он соответствует сложенности объекта
lair
19.03.2018 09:14То есть, по вашему мнению, от языка/экосистемы это никак не зависит?
Что же такого сложного в monkey patch в функциональном языке?
babylon
19.03.2018 15:25-1"Это" это что такое? Сформируй свой вопрос более сосредоточенно.
lair
19.03.2018 15:35"Это" — это сложность реализации monkey patching в тесте (и вообще сложность юнит-тестирования с использованием monkey patching).
babylon
19.03.2018 16:07Тестирование это верификация работоспособности модулей на основе контрольных значений. Расстановка и обработка этих значений в ООП удобнее. Ну на мой взгляд. Что касается сложности. Сложность определяется количеством связей. Уменьшите количество связей уменьшите сложность и наоборот.
lair
19.03.2018 16:17Тестирование это верификация работоспособности модулей на основе контрольных значений. Расстановка и обработка этих значений в ООП удобнее.
… вот только с monkey patching это все никак не связано.
babylon
19.03.2018 18:16-1У тебя есть другое название?
lair
19.03.2018 18:28Для какого явления?
babylon
19.03.2018 18:34Расстановка и обработка контрольных значений в ООП
lair
19.03.2018 18:36Собственно, вы же и процитировали "другое название".
babylon
19.03.2018 18:40а манки патчить значения в ООП вам религия не позволяет?
lair
19.03.2018 18:43Ну во-первых, если это можно сделать без monkey patch, то да, религия гласит, что лучше сделать без monkey patch.
Во-вторых, покажите, пожалуйста, как сделать monkey patch в .net для банального
Stream.Length
.babylon
19.03.2018 18:49-1Я не знаю и не хочу знать.
lair
19.03.2018 18:54Ну вот тогда и не утверждайте, что в ООП monkey patch идеален для тестирования объектов. Возможно, в каком-то известном вам языке/экосистеме — да. Но не в ООП в целом.
babylon
19.03.2018 19:00-1Утверждаю — идеален на любом языке. Ибо язык собственно не причём. Почитайте на досуге про верификацию компиляторов.
lair
19.03.2018 19:01Утверждаю — идеален на любом языке.
Утверждать-то вы можете что угодно, но вот только доказать это не можете.
Ибо язык собственно не причём.
И что же делать, если язык (точнее, среда выполнения) не позволяют monkey patching?
Lofer
19.03.2018 19:58Утверждаю — идеален на любом языке. Ибо язык собственно не причём. Почитайте на досуге про верификацию компиляторов.
В зависимости от языка и среды исполнения вам придется или специально проектировать код для такого механизма или извратиться так, что усилия превысят разумые пределы.
У вас есть С++ — в добрый путь :)
в .Net тоже можно извратиться весьма сходно — но «цена» и сложность превысит сложность проверяемого кода.babylon
20.03.2018 00:40Да надо стараться минимизировать количество тестируемого контекста, а количество контента максимизировать, ну эт общее правило которое справедливо для обоих подходов.
zigrus
19.03.2018 09:21маленькая стрелочка в последнем ряду так была и задумана?
на картинке вторая слева
Lofer
19.03.2018 12:17Модуль или юнит — минимальный кусок кода, который можно протестировать независимо от всего остального кода. Тестирование модулей так же известно как «юнит-тестирование».
1. А насколько независимо от остального кода? какой критерий «независимо»? Например код типа «трансформация данных» или «парсер»/сериализатор/десериализатор к какому будет относится типу?
2. К какому типа будет относится проверка «workflow» если вопрос стоит «корректный да-нет»?
Unit -Component — Integration?
jetcar
19.03.2018 13:47-1Локализация проблемы. Тест затрагивает лишь часть кода.
из своего опыта скажу что юнит тесты даже если покрывают 100% кода ещё не значит что отдельные компоненты будут работать вместе правильно, а ещё может быть избыточность действий когда разные модули слишком большие и они делают одно и тоже но немного по разному, либо вовсе не работают вместе, так может вместо юнит тестов писать сразу функциональные которые делают тоже что и конечный продукт в целом, тестов получается меньше проверяют они столько же, бегут правда помедленнее, но без них всё равно никак а 2 раза тестировать сначала юнит потом функциональными помоему глупо, если прилепить базу так ещё и нагрузочные тесты можно замутить, плохо лишь одно что надо будет хелперы писать чтоб подготовить состояние для самого теста, но опять же это заставляет думать о проблеме в целом, а не только независимый кусочек, если проэкт огромный то тогда ничё не поделаешь и надо модульно тестировать, но всё равно моков поминимуму и только для совсем внешних сервисов которые нельзя из тестов использовать
у меня такой подход отлично работает разве что уи не тестируется, да и то только потому что автоматизаторы сами должны были под это тесты написать, получается что не надо кнопки тыкать, а писать тест на требуемую ситуацию и под этот тест уже писать код, думать почти не надо как и чего сделать в коде, а ещё и всякие рантайм ошибки сразу вылезают связаные с байндингом и тому подобным, а значит нет человеческих ошибок типа забыл чегото там доконфить, которая вылезет только у тестеровjetexe
19.03.2018 14:22проверяют они столько же
Главная ошибкаjetcar
19.03.2018 15:47в чём ошибка то?
jetexe
20.03.2018 17:09В том, что функциональные тесты не проверяют работу приложения полностью.
даже если покрывают 100%
Юнит-тестирование, на то и «Юнит». Чтобы отдельно проверить логически завершённый сегмент всевозможными данными, пройти по всем вилкам условий, выкинуть и отловить все Exception-ы.
Только после юнит-тестирования имеет смысл делать функциональные (как работает весь конвейер).
PS. Если юниттесты долгие, советую использовать заглушки модулей которые пытается вызвать тестируемая часть. Если ваш ЯП поддерживает monkey patching из коробки то задача упрощается до безобразия.
PPS. Если код плохо поддаётся тестированию — то код плохо написанjetcar
20.03.2018 18:16так, а где обьяснение? это всё и функциональными делается, валидаторы и места где 100500 вариантов естественно проще юнит тестами, но не обязательно всё зависит от сложности написания и скорости их работы, ведь важно в конце концов как всё вместе работает, и функциональные сложно писать и бегут долго в огромных сложных проэктах, там начальное состояние сложно создать, например софт который считал накопительную пенсию из данных за 30 лет, но даже тут только первый раз было сложно потому что данных дофига надо создать, работало бы быстрее юнит тесты вообще не писались бы
jetexe
21.03.2018 09:40так, а где обьяснение?
Объяснение, прошу прощения, чего?
Того что функциональные не дают 100% покрытия? Так я объяснил, конвейер снижает вариативность поступающих данных (часть мутирует, часть валидируется). Функциональные тесты не выбрасывают половину Exception-ов глуша и обрабатывая их выше (в лучшем случае).
Юнит тесты сразу и точно показывают где что-то сломалось…
Есть у нас 100 модулей, каждый из которых прибавляет ко входящему параметру 1. Модули объединены в один конвейер. Покрываются одним функциональным тестом.
Правим 1 модуль теперь он добавляет 2 к параметру. Тест упал. В каком модуле произошла ошибка?
Ладно, это просто. Нашли в гите, поправили.
Два разработчика поправили по модулю. один теперь добавляет 0, второй добавляет 2. Ошибки компенсировали друг-друга. Тест пройден. Но приложение содержит ошибку. И когда эта мина подорвётся?
Во второй части своего комментария вы описываете необходимость функционального тестирования. С этим я не спорилjetcar
21.03.2018 10:30вот тут как раз таки и есть проблема избыточности, юнит тестом можно передать всякие невалидные данные которые никогда не дойдут то этого метода и из 10 строчек можно превратить всё в 100 строчек, а это вовсе не нужно, всю работу уже сделали другие модули
поиск модуля где всё сломалось вот тут наверно согласен, не надо дебажить, а можно сразу посмотреть в каком методе сломалось, но это если не использовать модульность и тдд, мы же не лезем в 10 методов сразу, а после того как что-то поменяли запустили тесты, и если что-то сломалось то это связано с последними изменениями, так что это больше похоже на тот случай когда тестов много и их все не запустить и поэтому есть найтли билд который сломался и чтоб не дебажить весь процесс нужны юнит тесты
последнее замечание не совсем валидно если код даёт в конце правильный результат и покрыт на 100% то и не важно что они компенсируют друг друга и всегда дадут нужный результат то что один модуль доделывает работу другого не критично(от перемены мест слагаемых сумма не меняется), ну по крайней мере до тех пор пока в этом коде ничего не меняется, а как будет меняться то и ошибка вылезет и будет исправлена
вобщем я понял вашу мысль и я не являюсь ярым противником юнит тестов их нужно писать где по другому сложнее так как они сократят время разработки, но нельзя забывать что чем больше тестов тем больше времени на их поддержку, и можно потерять всё сьэкономленное время на их переписывание и рефакторинг, был я в таких проэктах где основной упор был на юнит тесты (тысячи тестов) и в таких где на функциональные(несколько сотен) и с функциональными проект был менее проблемным, там где больше юнит тестов было получалось что всё покрыто и вроде должно работать, а всё вместе иногда не работало как надоjetexe
21.03.2018 14:40последнее замечание не совсем валидно если код даёт в конце правильный результат
Это мина замедленного действия.
был я в таких проэктах где основной упор был на юнит тесты (тысячи тестов) и в таких где на функциональные(несколько сотен)
Только ситхи все возводят в абсолют
babylon
20.03.2018 23:34Программы должны тестироваться и выполняться одним движком. Также как это делает компилятор. Тогда или тестеры или программисты будут не нужны. А пока одни пишут код с ошибками, а другие их находят, трудоемкость ручного тестирования с увеличением сложенности будет только возрастать.
Писать такие проги непросто. Потому что этому в школе/институте не учат. С другой стороны задач которые не требуют спецподготовки на порядки меньше. Видимо поэтому и спрос на такие комплексные решения тоже меньше. Этот эсеншл не для всех.lair
20.03.2018 23:36Программы должны тестироваться и выполняться одним движком. Также как это делает компилятор. Тогда или тестеры или программисты будут не нужны.
… как из одного вытекает другое, простите? Откуда "движок" возьмет сценарии тестирования?
babylon
20.03.2018 23:46-1Не прощу:) Откуда их берет компилятор? А там сплошное исполнение умноженное на тестирование. Если код не предусмотрел эксепшн, то тест учтёт только потому что сначала проверит то множество контрольных значений и условий которое ты ему предоставишь.
lair
21.03.2018 00:00Откуда их берет компилятор?
Из спецификации на язык. А вот спецификацию на ваш код компилятору никто предоставлять не будет.
nizkopal
Привет, Дмитрий.
На мой взгляд статья слегка сумбурная. Судя по структуре, целью статьи было структурировать информацию об авто-тестировании. Но информация в пунктах зачастую приведена коротко, а некоторые высказывания спорные и требуют развития мысли.
Также не понял, для кого статья предназначалась. Если для новичков в теме, то снова возвращаемся к ее скомканности. Если для людей, которые в целом в теме, то столь подробная структура излишняя — она не несет ничего нового.
В общем и целом: статьи на Хабре по большей части я бы разделил на два виды. Либо статья описывает какую-то проблему и ее решение. Либо рассказывает подробно о чем-то (как что-то сделать, как чем-то пользоваться и т.д.). Данная статья не решает никакой заявленной в начале проблеме, но и на обучающую тоже не тянет.
Думаю, если найти все же время, в дальнейшем можно развить статью во что-то клевое. Успехов! :)