Здравствуйте, меня зовут Дмитрий Карловский и это продолжение традиционной рубрики "Почему мы так не любим писать тесты?". Короткий ответ: потому, что получаемые от них бонусы не перевешивают затрачиваемых усилий. Если это так, значит мы делаем что-то не правильно. Давайте разберёмся что же могло пойти не так..
Данная заметка выросла из главы "Заблуждения" лонгрида "Концепции автоматического тестирования", посредством дополнения новыми заблужениями и аргументами.
Модульные тесты быстрее компонентных
Да, моки как правило исполняются быстрее, чем реальный код. Однако они прячут некоторые виды ошибок, из-за чего приходится писать больше тестов. Если фреймворк не умеет в ленивость и делает много лишней работы для поднятия дерева компонент (как, например, 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
не будет ничего менять в форме:
isPassportVisible = false
toggleRegistration() {
this.passport.isVisible = this.isPassportVisible = !this.isPassportVisible
}
В варианте с дополнительной проверкой дефолтного состояния мы бы словили упавший тест в этом случае. Более того, не стоит бояться писать и более длинные сценарии, если следующий шаг базируется на состоянии предыдущего.
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", а как небольшое приключение, стартующее из абсолютной пустоты и посредством некоторого количества действий, проходящее через некоторое количество состояний, которые мы и проверяем.