nin-jin / HabHub

Peering social blog

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Концепции автоматического тестирования

nin-jin opened this issue · comments

https://page.hyoo.ru/#!=mnd8x2_rawwi6

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

  1. Зачем мы пишем тесты?
  2. Какие бывают тесты?
  3. Как мы пишем тесты?
  4. Как их стоит писать?
  5. Почему модульные тесты - это плохо?

Правильная пирамида тестирования

Задачи автоматического тестирования

От более важного к менее:

  1. Обнаружение дефектов как можно раньше. До того как увидит пользователь, до того как выложить на сервер, до того как отдать на тестирование, до того как закоммитить.
  2. Локализация проблемы. Тест затрагивает лишь часть кода.
  3. Ускорение разработки. Исполнение теста происходит гораздо быстрее ручной проверки.
  4. Актуальная документация. Тест представляет из себя простой и гарантированно актуальный пример использования.

Ортогональные классификации

  1. Классификация по объекту
  2. Классификация по типам тестов
  3. Классификация по видам процесса тестирования

На всякий случай подчеркну, что речь идёт исключительно про автоматическое тестирование.

Объекты тестирования

  1. Модуль или юнит - минимальный кусок кода, который можно протестировать независимо от всего остального кода. Тестирование модулей так же известно как "юнит-тестирование".
  2. Компонент - относительно самостоятельная часть приложения. Может включать в себя другие компоненты и модули.
  3. Приложение или система - вырожденный случай компонента, косвенно включающего в себя все остальные компоненты.

Типы тестов

  1. Функциональные - проверка соответствия требованиям функциональности
  2. Интеграционные - проверка совместимости соседних объектов тестирования
  3. Нагрузочные - проверка соответствия требованиям производительности

Виды процессов тестирования

  1. Приёмочный - проверка новой/изменённой функциональности.
  2. Регрессионный - проверка отсутствия дефектов в не изменявшейся функциональности.
  3. Дымовой - проверка основной функциональности на явные дефекты.
  4. Полный - проверка всей функциональности.
  5. Конфигурационный - проверка всей функциональности на разных конфигурациях.

Количество тестов

  • Тесты - это код.
  • Любой код требует времени на написание.
  • Любой код требует время на поддержку.
  • Любой код может содержать ошибки.

Чем больше тестов, тем медленнее идёт разработка.

Полнота тестирования

  • Тесты должны проверить все пользовательские сценарии.
  • Тесты должны зайти в каждую ветку логики.
  • Тесты должны проверить все классы эквивалентности.
  • Тесты должны проверить все граничные условия.
  • Тесты должны проверить реакцию на нестандартные условия.

Чем полнее тесты, тем быстрее идёт рефакторинг и тестирование, и как следствие поставка новой функциональности.

Бизнес приоритеты

  1. Максимизация скорости разработки. Разработчику надо писать минимум тестов, которые быстро исполняются.
  2. Минимизация дефектов. Надо обеспечивать максимальное покрытие.
  3. Минимизация стоимости разработки. Надо тратить минимум усилий на написание и поддержку кода (в том числе и тестов).

Стратегии тестирования

В зависимости от приоритетов, можно выделить несколько основных стратегий:

  1. Качество. Пишем функциональные тесты на все модули. Проверяем их совместимость интеграционными тестами. Добавляем тесты на все невырожденные компоненты. Не забываем и про интеграционные для компонент. Присыпаем тестами всего приложения. Многоуровневое исчерпывающее тестирование потребует много времени и ресурсов, но позволит с большей вероятностью выявить дефекты.
  2. Скорость. Используем лишь дымовое тестирование приложения. Мы точно знаем, что основные функции работают, а остальное починим, если вдруг. Таким образом мы быстро поставляем функциональность, но тратим много ресурсов на доведение её до ума.
  3. Cтоимость. Пишем тесты лишь на всё приложение. Критичные дефекты таким образом обнаруживаются заблаговременно, что позволяет снизить стоимость поддержки и как следствие относительно высокую скорость поставки новой функциональности.
  4. Качество и скорость. Покрываем тестами все (в том числе вырожденные) компоненты, что даёт максимальное покрытие минимумом тестов, а следовательно минимум дефектов при высокой скорости, в результате давая и относительно низкую стоимость.

Пример приложения

Чтобы моя аналитика не была совсем уж голословной, давайте создадим простейшее приложение из двух компонент. Оно будет содержать поле ввода имени и блок с выводом приветствия, адресованного этому имени.

$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 просто создаёт экземпляр и переопределяет указанные свойства.

Компонентное тестирование

При этом подходе мы используем реальные зависимости всегда, когда это возможно.

Что следует мокать в любом случае:

  1. Взаимодействие со внешним миром (http, localStorage, location и тп)
  2. Недетерминированнось (Math.random, Date.now и тп)
  3. Особо медленные вещи (вычисление криптоскойкого хэша и тп)
  4. Асинхронность (синхронные тесты проще в понимании и отладке)

Итак, сперва пишем тест на вложенный компонент:

// 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. А это приводит нас к ошибкам двух типов:

  1. Правильный код модуля может давать ошибки на моках.
  2. Дефектный код модуля может не давать ошибки на моках.

И, наконец, тесты, получается, проверяют не требования (напомню - должно выводиться приветствие с подставленным именем), а реализацию (внутри вызывается такой-то метод с такими-то параметрами). А это значит, что тесты получаются хрупкими.

Хрупкие тесты - такие тесты, которые ломаются при эквивалентных изменениях реализации.

Эквивалентные изменения - такие изменения реализации, которые не ломают соответствие кода функциональным требованиям.

Test Driven Development

Алгоритм TDD довольно прост и весьма полезен:

  1. Пишем тест, убеждаемся, что он падает, что означает, что тест реально что-то тестирует и изменения в коде реально необходимы.
  2. Пишем код, пока тест не перестанет падать, что означает, что мы выполнили все требования.
  3. Рефакторим код, убеждаясь, что тест не падает, что означает, что наш код по прежнему соответствует требованиям.

Если мы пишем хрупкие тесты, то на шаге рефакторига они будут постоянно падать, требуя исследования и корректировки, что снижает производительность программиста.

Интеграционные тесты

Чтобы побороть оставшиеся после модульных тестов кейсы, придумали дополнительный вид тестов - интеграционные. Тут мы берём несколько модулей и проверяем, что взаимодействуют они правильно:

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

Ссылки по теме