Здравствуйте, меня зовут Дмитрий Карловский и это продолжение традиционной рубрики "Почему мы так не любим писать тесты?". Короткий ответ: потому, что получаемые от них бонусы не перевешивают затрачиваемых усилий. Если это так, значит мы делаем что-то не правильно. Давайте разберёмся что же могло пойти не так..


Картинка для привлечения внимания


Данная заметка выросла из главы "Заблуждения" лонгрида "Концепции автоматического тестирования", посредством дополнения новыми заблужениями и аргументами.


Модульные тесты быстрее компонентных


Да, моки как правило исполняются быстрее, чем реальный код. Однако они прячут некоторые виды ошибок, из-за чего приходится писать больше тестов. Если фреймворк не умеет в ленивость и делает много лишней работы для поднятия дерева компонент (как, например, 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", а как небольшое приключение, стартующее из абсолютной пустоты и посредством некоторого количества действий, проходящее через некоторое количество состояний, которые мы и проверяем.

Комментарии (9)


  1. lair
    09.04.2018 12:22

    Эм, аргументы, которые сводятся к "используйте правильный фреймворк для кода и тестов" — это не очень.


  1. babylon
    09.04.2018 13:11
    -1

    В любом случае тестирование должно осуществляться "правильным" движком, которому не важно что за код вы ему скармливаете. lair раздувай свою демагогию в другом месте. Этот топик не для тебя. Не твой уровень.


    1. lair
      09.04.2018 13:20

      В любом случае тестирование должно осуществляться "правильным" движком, которому не важно что за код вы ему скармливаете.

      А теперь — критерии правильного движка в студию. Может, правда, выясниться, что движки для юнит-тестов и поведенческих тестов немножко разные, но что уж тут...


    1. Danik-ik
      09.04.2018 18:33
      +2

      Мне почему-то кажется, что "Неважно, какой код скармливаете" бывает только в случае "код не имеет смысла".


      "Отвесьте мне кода на три тыщи рупий, пжалста. Неважно, какого, мне для тестирования"...


  1. VolCh
    09.04.2018 21:44

    Если под компонентом имеется в виду часть кода, обладающая как своим внутренним состоянием и логикой его изменения, так и своим UI, то есть по сути являющаяся мини-приложением, то и тесты к ней надо писать исходя из принципов тестирования приложения, а не отдельных его модулей, приемочные/функциональные тесты, а не юнит/интеграционные. И инструментарий соответствующий, эмулирующий действия пользователя, а не программных клиентов API.

    Что не исключает, конечно, что можно тестировать модульными тестами поведение изолированных частей компонента и их интеграцию интеграционными.

    Given/when/then — это, прежде всего, про низкоуровневые тесты, в лучшем случае тесты одного действия пользователя, а не про тестирование сценариев.


    1. babylon
      09.04.2018 22:44

      Что такое программные клиенты API?


      1. lxsmkv
        10.04.2018 01:12

        Я выравнял вам минус. Еще не хватало за вполне оправданные вопросы минусовать. Тут все таки не только доктора наук тусуются. Какие все таки токсичные люди… Ладно.

        Имеются ввиду части кода использующие интерфейс. Самый для меня наглядный пример: мы пишем тесты на PageObjects. Каждый такой PageObject является контроллером для страницы. А тестовый сценарий — это процедура, состоящая из вызовов методов одного или нескольких таких PageObjects. Так вот, тестовая функция и будет клиентом предоставлямых этими контроллерами публичных методов. А публичные методы это и есть интерфейс. То что видно и доступно всем для использования. Набор публичных методов класса это его АPI. Надеюсь что-то прояснил?


        1. babylon
          10.04.2018 01:31

          Спасибо за разъяснение.


  1. lxsmkv
    10.04.2018 01:50

    С Given/When/Then как мне кажется все в порядке. Check не нужен. Check это тот же Тhen.
    Вы пытаетесь освободиться от парадигмы, а надо ее принять. Или вы в мире GWT или нет.
    Так вот, если вы в мире GWT, то вместо этого

    console.assert( Math.pow( 2, 3 ) === 8 ) // Then
    должно получиться что-то такое (псевдокод):
    /*Given*/ exponent = 3; base = 2;
    /*When*/ result = Math.pow(base, exponent)
    /Then */ assert_equals(result,8)

    Этот сценарий обьясняет на примере как работает функция pow. И в этом его дополнительная ценность как документации, когда он представлен в виде GWT.

    Да, как вы справедливо заметили, это выглядит немного неуклюже и избыточно. Но парадигма это всегда кандалы которые ты добровольно одеваешь себе на руки. Ты меняешь свое мировосприятие под парадигму, а не наоборот.

    И, чтобы мир рассыпался в пыль окончательно — в тесте сделанном на чистом GWT не может быть условий. Каждая ветвь условия это отдельный тестовый сценарий.

    Вообще надо сказать, что GWT проще ложится на приемочные сценарии, в которых эмулируется поведение пользователя.