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

В этом есть резон, но так ли уж неполны юнит-тесты и можно ли сделать их надежнее? Сколько вообще причин их неполноты?

Предположим, у нас есть два покрытых юнит-тестами компонента, Caller и Callee. Caller вызывает Callee с аргументом и как-то использует возвращаемый объект. У каждого из компонентов есть свой набор зависимостей, которые мы мокаем.

Сколько сценариев, при которых эти компоненты поведут себя неожиданно при интеграции?

Первый сценарий — это внешняя по отношению к обоим компонентам проблема. Например, они оба зависят от состояния базы данных, авторизации, переменных среды, глобальных переменных, куки, файлов и тп. Судить об этом достаточно просто, поскольку даже в очень больших программах обычно ограниченное число таких contention points.

Разрешать проблему можно, очевидно, либо через редизайн с уменьшением зависимостей,
либо прямо моделируем возможную ошибку в сценарии верхнего уровня, то есть вводим компонет CallingStrategy (OffendingCaller,OffendedCallee) {}, и имитируем падение Callee и обработку ошибки в CallingStrategy. Для этого интеграционные тесты не требуются, но требуется понимание, что определенное поведение одного из компонентов представляет риск для другого компонента, и этот сценарий хорошо бы выделить в компонент.

Второй сценарий: проблема в интерфейсе интегрируемых объектов, т.е. ненужное состояние одного из объектов просочилось в другой.

Фактически, это недостаток интерфейса, который это допускает. Решение проблемы тоже довольно очевидно — типизация и сужение интерфейсов, ранняя валидация параметров.

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

Таким образом, если мы проверили наши классы на предмет 1) внутреннего состояния и 2) внешних зависимостей, то причин сомневаться в надежности юнит-тестов у нас нет.

(Где-то в углу тихо плачет функциональный программист со словами «i told you so», но щас не об этом).

Но ведь мы можем просто забыть или пропустить какую-то зависимость!

Можно оценить грубо. Предположим, в каждом компоненте десять сценариев. Мы пропускаем один сценарий из десяти. Например, Callee внезапно возвращает null, а Caller внезапно получает NullPointerException. Нам нужно ошибиться дважды, значит вероятность падения где-нибудь 1/100. Трудно представить, что интеграционный сценарий для двух элементов это отловит. Для множества последовательно вызванных компонентов внутри интеграционного теста вероятность отлова какой-то из ошибок растет, из чего следует, что чем длиннее стек интеграционного теста, и чем больше сценариев, тем он более оправдан.

(Реальная математика накопления ошибок, разумеется, сильно сложнее, но результат не очень варьирует).

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

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

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


  1. SergejSh
    30.10.2019 05:47

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


    1. rudnevr Автор
      30.10.2019 08:29

      непонятно, как договориться о балансе.


    1. endymion
      30.10.2019 09:44

      Полностью согласен. Пример из практики — было два компонента системы со сложной логикой, где общение между ними организовали с помощью websockets, но в процессе тестирования в реальной среде выявились проблемы. Websockets заменили на rest API.

      Интеграционные тесты переписывать не пришлось вообще. Чему я был рад до безумия.

      Я вообще придерживаюсь мнения, что знаменитую пирамидку с тестами надо перевернуть — интеграционные тесты должны покрывать большую часть функционала, а юнить тесты писать только там где они нужны(на сложную логику или алгоритмы).


      1. vintage
        30.10.2019 10:32

        Не перевернуть, а вообще выкинуть эту изначально неверную концепцию.
        https://m.habr.com/ru/post/351430/


      1. ApeCoder
        30.10.2019 11:34

        А общение с компонентами через websockets это какие тесты — unit, интеграционные, rкомпонентные, end-to-end или еще какие?


      1. rudnevr Автор
        30.10.2019 15:38

        Это странно. Компоненты перебрасываются какими то JSON сообщениями. Какая разница, по какому протоколу. Юнит тестам должно быть все равно.


      1. NIKOSV
        31.10.2019 03:05
        +2

        Был бы дизайн и юнит тесты правильными, а именно скрыть транспорт за каким-то интерфейсом (что-то типа ISomethingProvider, ISomethingClient) и мокать его, тоже не пришлось бы ничего переписывать. Если замена транспорта требует переписывание половины компонентов и юнит тестов для них — у вас проблемы с дизайном.

        Интеграционные тесты хороши в теории. На практике их трудно правильно писать, трудно поддерживать и в принципе невозможно покрыть все случаи. Более того, они рефакторятся чаще чем юнит тесты. Примеры? Компонента А зависит от Компоненты Б которая зависит от Компоненты С которая зависит от Компоненты Д. Вы написали сотню однотипных интеграционных тестов которые дергают компоненту А и проверяют ее результат (и да, эти тесты по размерам будут в несколько раз больше юнит тестов). Потом бизнес меняет поведение Компоненты Д, потому что так захотел, что происходит? По цепочки меняется результат аж до компоненты А половина ваших интеграционных тестов летят в трубу, даже те которые вообще никакого отношения не имеют к изменению. И это происходит гораздо чаще чем переписывание внутренних компонентов и их юнит тестов.

        У нас на проекте если посмотреть историю изменения интеграционных тестов — ни одного живого места, некоторые тесты изменялись по 10 раз в год. После стольких изменений часто теряется суть что собственно тест вообще изначально тестировал. Отчасти потому, что написаны эти тесты криво так как я сказал что писать их правильно нужно уметь и далеко не все это умеют. Собственно как и правильно писать юнит тесты, но там все-равно сложнее начудить чем с интеграционными.

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

        Интеграционные тесты хороши, но в меру. Они должны тестировать интеграцию компонентов, а не их логику. Для логики есть юнит тесты.


      1. Jofr
        31.10.2019 10:08
        +1

        Полностью поддерживаю! Думал, я крамолу несу, а оказалось, не я один :)

        Вообще, разумеется, все зависит от конкретного проекта и конкретной реализации, универсальных ответов не бывает. Но в нашем кейсе (а у нас геймдев) запросы на изменение приходят очень часто, и юниты от таких изменений просто трещали по швам. Вроде выделил юнит, вынес зависимости, завязал на узкий интерфейс, все красиво… но первый же запрос на изменение требует рефакторинга смежных сущностей, расширения интерфейса, вынесения базовых классов, внедрения новых зависимостей — и все, тест даже не переписать, проще выбросить и новый сделать.

        С интеграционными (а то и системными) ситуация обратная, система устойчива к рефакторингу, причем очень серьезному, и даже после крупных изменений можно быть в достаточной степени уверенным, что ничего не сломалось.

        В какой-то серии саус-парка Картман предлагал перевернуть пирамиду питания, здесь мы имеем примерно похожую ситуацию :) Но возможно, мы просто неправильно ее читаем. Приемочные, системные, интеграционные тесты — все они отвечают на важный вопрос «Работает ли программа?», «Работает ли подсистема» и т.д. Это база, это то, что позволяет спокойно вносить крупные изменения на уровне архитектуры, и быть уверенным, что враги не пройдут, а косяки всплывут. Это основание пирамиды. А юниты — вишенка на торте, для действительно независимых юнитов, которые будут меняться с малой вероятностью (например, алгоритмы).

        Собственно, сейчас мы умудряемся на интеграционных сценариях даже работать в парадигме test-first, и это дает очень хорошие результаты.


    1. EvilsInterrupt
      30.10.2019 13:24

      А зачем юнит-тест переписывать? У любого теста есть жизненный цикл. Иногда тест дешевле удалить и написать новый, а не править текущий под новые реалии.


    1. emacsway
      30.10.2019 15:45
      +1

      Из текста статьи (и из ряда комментариев), создается впечатление, что, для многих, интеграционные тесты приравниваются к Sociable Unit Tests, а юнит-тестами считаются исключительно полностью изолированные Solitary Unit Tests. Я, конечно, могу в этом ошибаться, но мне так показалось. В таком случае, хотелось бы привести слова основателя TDD Кент Бека: "My personal practice — I mock almost nothing.". Интеграционные и юнит-тесты имеют немного разные цели.

      можно менять интерфейс взаимодействия внутренних компонентов

      Самотестируемость кода является первостепенным условием для осуществления его рефакторинга. А поэтому, действительно, тесты должны облегчать рефакторинг, а не накладывать на код оковы. Тестировать нужно поведение, а не реализацию, и спускаться в глубь реализации следует тогда, когда это необходимо для сокращения количества комбинаций тестовых условий. Наглядный пример: «Many people make bad trade-offs, especially with heavy mocking. Kent thinks it’s about trade-offs: is it worth making intermediate results testable? He used the example of a compiler where an intermediate parse-tree makes a good test point, and is also a better design.» — "Is TDD Dead?"

      P.S.: Раз уж статья была помечена тэгом ТДД, то хотелось бы обратить внимание, что ТДД — это не методика тестирования, а методика проектирования и разработки.


      1. vintage
        30.10.2019 16:34

        "Sociable Unit Tests" — не являются юнит тестами по определению, так как тестируют не один модуль, а сразу группу модулей. Такие тесты называются компонентными.


        1. ApeCoder
          30.10.2019 16:42

          По какому именно определению? Насколько я понял, в прошлый раз мы пришли к выводу что юнит тестов в вашем понимании вообще не бывает, так как никто не мокает класс string.


          1. vintage
            30.10.2019 17:07

            По определению юнита — куска кода.
            Для стандартной библиотеки обычно делается оговорка. Что лишний раз подчёркивает глупость понятия "модульный тест".


            1. ApeCoder
              30.10.2019 17:49

              Тогда что такое "тестировать X". Верно ли что если что-то тестирует X он должен выполнять только X?


              1. vintage
                30.10.2019 18:11

                Ну давайте и e2e тесты модульными называть тогда, что уж там.


                1. ApeCoder
                  30.10.2019 18:18

                  Есть ли еще варианты? Можно ли придумать какой-то другой принцип называть что-то "тестированием X", который одновременно будет позволять использовать не только X при этом не называть E2E тест модульным?


        1. emacsway
          30.10.2019 16:52

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

          «But not all unit testers use solitary unit tests

          «Indeed using sociable unit tests was one of the reasons we were criticized for our use of the term „unit testing“. I think that the term „unit testing“ is appropriate because these tests are tests of the behavior of a single unit. We write the tests assuming everything other than that unit is working correctly.»


          1. vintage
            30.10.2019 17:12

            Вы лучше свой головой подумайте, а не молитесь на священные писания.


            1. Вы пишите тест используя апи одного модуля.
            2. Ошибка во втором модуле может завалить ваш тест.
            3. Следовательно вы тестируете оба модуля.

            Это элементарная логика.


            1. ApeCoder
              30.10.2019 17:54

              А из чего следует, что если заваливается тест Т при при испорченном X то это является тестом X?


              Например, при покупке лампочки в магазине вы говорите "можно я проверю лампочку?", а не "можно я проверю, лампочку, цолоколь, провода, подстанцию, электростанцию и водохранилище"?


              1. vintage
                30.10.2019 18:17

                Потому что результат теста T зависит от X.


                Лампочку вы проверяете используя тестовый стенд, который изолирует лампочку от вашего торшера, вашей электросети и вашей ТЭЦ. Вообще, аналогии из физического мира тут не к месту.


                1. ApeCoder
                  30.10.2019 18:30

                  "Вы лучше свой головой подумайте"


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


                  Вообще, аналогии из физического мира тут не к месту.

                  То, есть при софтверном тестировании действует совершенно другая логика, логика реального мира разрешает назвать тестированием лампочки процесс, который откажет при сбое на ТЭЦ, а софтверная логика не позволяет. Причем только вам, некоторые люди вокруг на которых вам дают ссылки странным образом применяют непротиворечивую логику и там и там. Правильно?


                  Может быть разберемся почему?


                  Например, на основании какого принципа вы называете тестированием именно лампочки процесс который даст сбой при отказе ТЭЦ? Почему вы его не называете тестированием ТЭЦ?


                  1. emacsway
                    30.10.2019 18:39

                    Как он изолирует от моей ТЭЦ?
                    Купить дизель-генератор для проверки лампочки :)

                    Лампочку вы проверяете используя тестовый стенд
                    Объектом тестирования выступает поведение, а не юнит. Поведение однго юнита. И для проверки работоспособности лампочки совершенно неважно, ввернута она в торшер или в стенд.

                    P.S.: Пример с лампочкой был очень удачным, спасибо.


                    1. ApeCoder
                      30.10.2019 18:50
                      +1

                      Купить дизель-генератор для проверки лампочки :)

                      Не поможет — в софтверной вселенной vintage это станет тестом дизель-генератора и лампочки.


                      Объектом тестирования выступает поведение, а не юнит.

                      Без спойлеров, пожалуйта, мне интересна логика vintage а вы ее можете испортить своими "священными писаниями" подобно тому как европейская фауна портит австралийскую. Давайте введем мыслекарантин!


                      1. vintage
                        30.10.2019 18:58

                        Тестовый стенд не является объектом тестирования. Зависимости модуля не являются тестовым стендом (если они не предоставлены самим стендом, разумеется).


                        Завязывайте уже с этой софистикой, она ни к чем хорошему вас не приведёт.


                        1. ApeCoder
                          30.10.2019 19:09

                          То есть важно не то, что "ошибка во втором модуле может завалить ваш тест." а то, что является объектом тестирования?


                          Я могу для тестирования лампочки использовать экземпляр той же модели цоколя, что я использую в своем торшере?


            1. VolCh
              30.10.2019 20:59

              1. Всё не так однозначно. Обычно цель тестирования модуля — проверить, что его поведение не изменилось. Если тест не падает, то оно не изменилось (предполагаем, что тест действительно тестирует зафиксированное поведение. Если падает, то есть варианты:
                • изменилось поведение нашего модуля — тест выполнил не тольео свою основную задачу, но и помог локализовать ошибку
                • изменилось поведение зависимости нашего модуля и тест упал — это может быть отрицательное срабатывание нашего теста, а может быть ложноотрицательное. Если эта зависимость нигде больше никем не используется, если она в приложении нужна только нашему модулю, то изменение её поведения равносильно изменению поведения нашего модуля и тест выполнил свою основную задачу: выявил изменение поведение.


              1. vintage
                30.10.2019 21:55

                Не понял вашу мысль. Вы описали, что компонентные тесты — это хорошо. Ну как бы да, об этом и речь.


                1. ApeCoder
                  30.10.2019 22:16

                  А компонентный тест не подразумевает что он должен обращаться с некоторым компонентом как с целым? Т.е. это должен быть некоторый набор модулей, который именно в таком виде используется в prod и обладает каким-то общим интерфейсом, а то, о чем мы говорим здесь по вашей терминологии скорее специфический интеграционный тест.


                  Допустим у нас есть модули M1, M2 и M3. M2 и M3 реализуют интерфейс I. M1 используют интерфейс I.


                  В проде M1 всегда получает M2. В тесте мы передаем ему M3.
                  Тест сформулирован в терминах требований к M1.


                  Это в вашей системе терминологии:


                  1. Компонентный тест (если да, то для какого компонента чего)?
                  2. Модульный тест?
                  3. Интеграционный тест?


                  1. vintage
                    31.10.2019 12:11

                    В проде M1 всегда получает M2. В тесте мы передаем ему M3.

                    В тесте мы передаём M2. Кроме некоторых исключительных случаев.


                    в вашей системе терминологии

                    Это не моя терминология.


                    1. ApeCoder
                      31.10.2019 13:06

                      В тесте мы передаём M2

                      А если передаем M3? В некотором исключительном случае.


                      в вашей системе терминологии
                      Это не моя терминология.

                      Ок — в истинной системе терминологии, как ее понимают вы и другие люди, которые думают своей головой а не фанатики, читающие священные книги. Это какой тест?


  1. vintage
    30.10.2019 06:25

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


    1. rudnevr Автор
      30.10.2019 06:49

      Мне и не нужно знать всех вариантов. Мне нужно знать ограниченный набор вариантов, которые использует мой код, для этого существуют тесты для зависимостей. «Реальность» реальных систем преувеличена. Я напишу об этом отдельно.


      1. vintage
        30.10.2019 10:37

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


  1. vintage
    30.10.2019 06:31

    Ещё вы исходите из предположения, что тесты обладают полнотой (что как правило не так) и не содержат ошибок (а выявить их сможет только интеграция).


    1. rudnevr Автор
      30.10.2019 06:41

      не могли бы вы привести пример, в котором интеграционный тест полнее покрывает систему, чем юнит тест?


      1. vintage
        30.10.2019 10:42

        По ссылке выше есть примеры.


        1. rudnevr Автор
          30.10.2019 16:05

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


          1. vintage
            30.10.2019 16:35

            Уж не поленитесь и прочитайте его полностью.


  1. VolCh
    30.10.2019 08:19

    Главные преимущества юнит-тестов:


    • они быстрые
    • они требуют мокать/стабить (кто-то вообще эти термины различает на практике?) меньше зависимостей
    • они, как правило, не требуют внешних зависимостей типа баз данных или файловой системы, не нужно поднимать специальное окружение
    • они пишутся обычно тем же программистом, который пишет тестируемой код

    Всё это приводит к тому, что ошибки (неожиданное изменение зафиксированного поведения) выявляются раньше и фиксятся быстрее.


    Конечно, если это хороший код и хорошие тесты


    1. rudnevr Автор
      30.10.2019 08:29

      это так и есть, есть и много других преимуществ. Эти термины различаются и имеют точный смысл, во многих TDD-шопах они используются по назначению.

      martinfowler.com/bliki/TestDouble.html


  1. ggo
    30.10.2019 09:30

    Еще бы кто-нибудь рассказал, зачем противопоставлять модульные и интеграционные тесты.
    И почему в зависимости от решаемых задач нельзя использовать модульные и/или интеграционные тесты.


    1. ApeCoder
      30.10.2019 09:37

      Обычно рекомендуют тестовую пирамиду


      1. ggo
        30.10.2019 10:18

        Так сие не означает, что «используя модульные, нельзя использовать интеграционные; используя интеграционные, нельзя использовать модульные».

        Запросто пользуй то, что приносит пользу.

        И пирамидка не про «вначале пользуй модульные, и только потом приступай к интеграционным». Пирамидка про полноту и сложность — модульные более полно покрывают логику, и при правильном использовании очень просты. Интеграционные — покрывают логику более высокого уровня, то что ближе к потребностям потребителя.


        1. ApeCoder
          30.10.2019 11:39

          Ну да, я про это же. Уточнения:
          Там не про порядок, а про количество — чем выше к вершине пирамиды тем меньше тестов.


          1. ggo
            31.10.2019 11:20

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


            1. VolCh
              31.10.2019 11:56

              Цель тестов в идеале — покрыть всю логику. Но есть ограничения как на ресурсы, которые могут быть начально инвестированы собственно в разработку тестов, так и на те, которые могут быть выделены на прогон и поддержку тестов. Пирамида тестирования — компромисс для среднего проекта. В идеале, наверное, квадрат должен быть со 100% покрытием.


            1. ApeCoder
              31.10.2019 13:08

              Мы обсуждаем что есть тестовая пирамида, а не какова ее цель, например:


              Stick to the pyramid shape to come up with a healthy, fast and maintainable test suite: Write lots of small and fast unit tests. Write some more coarse-grained tests and very few high-level tests that test your application from end to end. Watch out that you don't end up with a test ice-cream cone that will be a nightmare to maintain and takes way too long to run.


        1. rudnevr Автор
          30.10.2019 16:02

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


          1. AWSVladimir
            30.10.2019 18:33
            +1

            • Юнит тесты покрывают всю логику
            • Интеграционные тесты появляются тогда, когда юнит тесты написаны ненадежно

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

            И другой пример. Написано 2 приложения Сервер и Клиент.
            Для них будет 100% интеграционный тест 2-х запущенных приложений и он так же необходим как и юнит тесты, это просто другой уровень.

            PS:
            Хотя возможно я путаюсь в терминологии, тест 2-х и более приложений одновременно я называю «Интеграционный тест приложений», если есть какое то другое название озвучте плиз.


            1. VolCh
              30.10.2019 21:04

              Больше похоже на системное тестирование или вообще e2e. достаточно большая размытость терминов, по-моему, из-за размытости термина "юнит"


            1. rudnevr Автор
              31.10.2019 16:06

              Если юнит использует множество других юнитов, то мы их рефакторим и мокаем.
              Если у нас клиент и сервер, мы берём тестовый payload и посылаем его клиенту или серверу с соответствующими проверками.


        1. VolCh
          30.10.2019 20:45

          Я пришёл к практическому правилу: на проекте не покрытом тестами вообще при принятии решения вложить ресурсы в покрытие лучше начинать сверху пирамиды — быстрее даст практически чувствующуюся отдачу, опускаться вниз только если ~~делать больше нечего ~~ покрыть какой-то кейс ниже гораздо проще чем выше.