Два способа сделать надежные юнит-тесты

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    Подробнее
    Реклама

    Комментарии 49

      +5

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

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

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

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

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

              0

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

                0

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

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

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

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

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

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

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

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

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

                    Собственно, сейчас мы умудряемся на интеграционных сценариях даже работать в парадигме test-first, и это дает очень хорошие результаты.
                    +1
                    А зачем юнит-тест переписывать? У любого теста есть жизненный цикл. Иногда тест дешевле удалить и написать новый, а не править текущий под новые реалии.
                      +2
                      Из текста статьи (и из ряда комментариев), создается впечатление, что, для многих, интеграционные тесты приравниваются к 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.: Раз уж статья была помечена тэгом ТДД, то хотелось бы обратить внимание, что ТДД — это не методика тестирования, а методика проектирования и разработки.
                        0

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

                          +1

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

                            0

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

                              0

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

                                0

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

                                  0

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

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

                            «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.»
                              0

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


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

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

                                0

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


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

                                  0

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


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

                                    0

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


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


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

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


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


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

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

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

                                      P.S.: Пример с лампочкой был очень удачным, спасибо.
                                        +1
                                        Купить дизель-генератор для проверки лампочки :)

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


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

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

                                          0

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


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

                                            0

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


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

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

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

                                      0

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


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


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


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


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

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


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

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

                          +4

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

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

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

                            +1

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

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

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

                                  0

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

                                    –1

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

                              +2

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


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

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


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

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

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

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

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

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

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

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

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

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

                                        –1

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

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

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

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

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

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

                                          0

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

                                    Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                                    Самое читаемое