Разработчик ПО
0,0
рейтинг
19 января в 16:49

Разработка → Проблема дублирования и устаревания знания в mock-объектах или Интеграционные тесты — это хорошо

TDD*
Многие программисты при выборе между интеграционным и юнит-тестом отдают предпочтение юнит-тесту (или, иными словами, модульному тесту). Некоторые считают интеграционные тесты антипаттерном, некоторые просто следуют модным тенденциям. Но давайте посмотрим, к чему это приводит. Для реализации юнит-теста mock-объекты навешиваются не только на внешние сервисы и хранилища данных, но и на классы, реализованные непосредственно внутри программы. При этом, если мокируемый класс используется в нескольких других классах, то и mock-объект будет содержаться в тестах на несколько классов. А поскольку тестируемое поведение принято задавать внутри теста (смотри given-when-then, arrange-act-assert, test builder), то поведение моки каждый раз заново задаётся в каждом тесте, и нарушается принцип DRY (хотя дублирования кода может и не быть). Кроме того, поведение класса декларируется в mock-объекте, но сама эта декларация не проверяется, поэтому со временем задекларированное в моке поведение может устареть и начать отличаться от реального поведения мокируемого класса. Это вызывает целый ряд сложностей:

1)Во-первых, при изменении функционала сложно вообще вспомнить, что помимо класса и тестов на него нужно изменить ещё и моки этого класса. Давайте рассмотрим цикл разработки в рамках TDD: «создание\изменение тестов на функционал -> создание\изменение функционала -> рефакторинг». Mock-объекты являются декларированием поведения класса и не имеют отношения ни к одной из этих трёх категорий (не являются тестами на функционал, несмотря на то, что в тестах используются, и уж тем более не являются самим функционалом). Таким образом, изменение mock-объектов классов, реализованных внутри программы, не укладывается в концепцию TDD.

2)Во-вторых, сложно найти все места мокирования этого класса. Я не встречал ни одного инструмента для этого. Тут можно или написать свой велосипед, или смотреть все места использования этого класса и отбирать те, где создаются моки. Но при неавтоматизированном поиске можно и ошибиться, проглядеть что-нибудь. Тут у вас, наверное возник вопрос: если проблема столь фундаментальна, как описывает автор, неужели никому не пришло в голову реализовать инструменты, упрощающие её решение? У меня есть гипотеза на этот счёт. Несколько лет назад я начал писать библиотеку, которая должна была собирать mock-объект так же, как IOC-контейнер собирает обычный класс, и автоматически создавать и прогонять тесты на поведение, описываемое в моках. Но затем я отказался от этой идеи, потому что нашёл более элегантное решение проблемы моков: просто не создавать эту проблему. Вероятно, по схожей причине специализированный инструмент для поиска моков конкретного класса или не реализован, или малоизвестен.

3)В-третьих, мест мокирования класса может быть много, и изменение их всех — рутинное занятие. Если программист вынужден делать рутину, которую невозможно автоматизировать, то это явный признак того, что с инструментами, архитектурой или рабочими процессами что-то не в порядке.

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




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

Давайте рассмотрим, какие аргументы приводят противники интеграционных тестов.

Высказывание 1. Интеграционные тесты в меньшей степени помогают в поиске ошибок, нежели юнит-тесты

Доказательство:
Давайте представим, что в каком-то классе, использующемся повсеместно, была допущена ошибка. После этого покраснели тесты непосредственно самого класса, а также все интеграционные тесты, в которых использовался этот класс. В итоге половина тестов в проекте красная. Как понять, в чём причина покраснения тестов? С какого теста начать? А вот если бы вместо класса использовался его mock-объект, то покраснели бы только тесты этого класса.
Опровержение:
Давайте вспомним рабочий процесс в рамках TDD: «красные» тесты, сигнализирующие об ошибке -> создание\изменение функционала -> «зелёные» тесты. Соответственно, при изменении функционала, программист сначала изменяет тесты так, чтобы они тестировали изменённый функционал. Поскольку в коде ещё находится устаревший функционал, то тесты не проходят. Затем программист правит код функционала, и тесты проходят. Если программист поработал с классами, но не с их тестами, то он действовал не в рамках TDD.
Но даже если программист изменил код, но не изменил тесты и не проверил их прохождение, то падение тестов может отследить сервер непрерывной интеграции, который автоматически прогоняет тесты при каждом пуше в систему контроля версий. Автор изменений увидит сообщение о падении тестов, по горячим следам вспомнит какие классы он правил, и в первую очередь начнёт разбираться с тестами именно этих классов. Если программист непреднамеренно внёс баг в некоторый класс, а потом исправил его, то позеленеют не только тесты этого класса, но и все тесты, в которых этот класс использовался. Но что, если не позеленеют? Тогда это сигнал о том, что изменения в классе привели к изменению поведения других классов, где этот класс использовался, и теперь или в этих классах появились ошибки, или их тесты отклонились от логики приложения.
Возможен и другой случай. Если по какой-то причине класс, в котором допустили ошибку, не был хорошо покрыт тестами, то юнит-тесты на моках вообще не выявили бы проблему. Интеграционные же тесты хотя бы просигнализируют о проблеме, хотя для выявления проблемного класса и придётся прибегнуть к старой доброй трассировке.
Подводя итог: если вы следуете TDD, то покраснение тестов тех классов, которые вы не изменяли, является преимуществом, потому что сигнализирует о проблемах. Если вы не следуете TDD, но используете непрерывную интеграцию, то покраснение «лишних» тестов для вас не такая уж и проблема. Если вы не следуете TDD и не выполняете регулярную прогонку тестов, то для вас актуальна проблема выявления соответствия «упавший тест — проблемный класс». В таком случае лучше решать проблему дублирования знания в моках и отсутствия тестов на поведение, декларируемое в моках, не при помощи использования интеграционных тестов вместо модульных, а при помощи других средств (о них поговорим чуть позже).

Высказывание 2. Интеграционные тесты в меньшей степени помогают в проектировании, нежели модульные

Доказательство:
Модульное тестирование, в отличие от интеграционного, вынуждает программистов инжектировать зависимости через конструктор или свойства. А если использовать интеграционное тестирование вместо модульного, то джуниор может зависимости прямо в коде класса инстанцировать. А мне архитектурные записки писать и коды-ревью проводить очень некогда. Да и поручить некому. И не хочется.
Опровержение:
На самом деле, не только модульное тестирование способно принудить программиста к инжектированию зависимостей. С этим отлично справляется IOC-container. На самом деле, если вы инжектируете зависимости, то вы наверняка используете IOC-container. Можно конечно и самому написать фабрику создания самого главного класса, в котором находится точка входа. Но IOC-container решает многие типовые проблемы и упрощает жизнь. Например, вы можете одной строчкой кода сделать какой-либо класс синглтоном, не вникая в подводные камни реализации синглтона. Так что, если вы инжектируете зависимости, но не используете IOC-container, то я рекомендую начать это делать.
В общем, если вы используете модульное тестирование, то вы почти наверняка используете IOC-container. Если вы используете IOC-container, то он побуждает программиста инжектировать зависимости. Можно конечно создать объект не используя IOC-container, но точно также можно создать класс, не снабдив его модульным тестом. Так что, я не вижу у модульных тестов весомых преимуществ в плане побуждения к исполнению принципа Inversion of control.
К тому же, можно не принуждать программистов поступать нужным вам образом за счёт ограничений в архитектуре, а просто объяснить преимущества инжектирования зависимостей и использования IOC-контейнера. Принуждение силой, как и любое насилие, может вызвать встречное сопротивление.

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

Доказательство:
Автор статьи с громким названием «Интеграционные тесты — удел жуликов» пишет о том, что он со всей страстью ненавидит интеграционные тесты и считает их вирусом, приносящим бесконечную боль и страдание. Свои мысли он обосновывает так:
Вы пишите интеграционные тесты, потому что не способны написать совершенные модульные тесты. Вам знакома эта проблема: все ваши тесты прошли, но в программе всё равно обнаруживается дефект. Вы решаете написать интеграционный тест, чтобы убедиться, что весь путь исполнения программы работает как надо. И всё вроде бы идёт нормально, пока вы не подумаете: «А давайте использовать интеграционные тесты везде». Плохая идея! Количество возможных путей исполнения программы нелинейно зависит от размера программы. Для покрытия тестами веб-приложения с 20ю страницами вам потребуется как минимум 10 000 тестов. Возможно миллион. При написании 50 тестов в неделю, вы напишите только 2 500 тестов в год, а это 2,5% процента от нужной суммы. И после этого вы удивляетесь, почему тратите 70% вашего времени, отвечая на звонки пользователей?! Интеграционные тесты — пустая трата времени. Они должны остаться в прошлом.

Опровержение:
Автор той статьи даёт следующее определение интеграционного теста:
I use the term integrated test to mean any test whose result (pass or fail) depends on the correctness of the implementation of more than one piece of non-trivial behavior.
Интеграционный тест — такой тест, результат прохождения которого зависит от правильности реализации более чем одного кусочка нетривиальной логики (метода).

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

Высказывание 4. Интеграционные тесты выполняются дольше модульных

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

Способы борьбы с дублированием и устареванием знания в моках


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

Второй способ — автоматизированно проверять актуальность задекларированного в моке поведения. Например, вы можете автоматически создавать и прогонять соответствующий тест. Но тогда нужно учесть, что мокируемый класс может иметь свои зависимости, часть из которых может быть внешними сервисами. Для быстродействия, можно сначала тестировать уникальное поведение (указанное в моках) классов самого нижнего слоя, затем поведение классов, которые используют предыдущие классы, и так далее. Тогда, если какое-то одинаковое поведение декларируется в моках в нескольких местах, то его можно будет проверить только один раз.
Можно для каждого уникального случая мокирования вручную написать тест и каким-то образом задать соответствие между мокой и тестом на неё, и поручить программистам вручную поддерживать это соответствие при изменении функционала.
Можно просто поручить программистам вручную поддерживать актуальность мок-объектов. Но тогда придётся немного изменить рабочий процесс, отойдя от классического TDD, заменить «Изменение тестов на функционал -> Изменение функционала -> ...» на «Изменение тестов на функционал -> Изменение деклараций этого поведения (в моках) -> Изменение функционала -> ...».

Для устранения проблемы дублирования кода при мокировании можно поместить все моки на один класс в отдельное хранилище. Это упростит этап «Изменение деклараций поведения в моках», но может уменьшить читаемость юнит-теста — тут решайте сами, исходя из собственных приоритетов.

Заключение


Мартин Фаулер давно заметил формирование двух разных школ TDD — классической школы и мокистов:
Now I'm at the point where I can explore the second dichotomy: that between classical and mockist TDD. The big issue here is when to use a mock (or other double).

The classical TDD style is to use real objects if possible and a double if it's awkward to use the real thing. So a classical TDDer would use a real warehouse and a double for the mail service. The kind of double doesn't really matter that much.

A mockist TDD practitioner, however, will always use a mock for any object with interesting behavior. In this case for both the warehouse and the mail service.

Обе эти школы имеют свои преимущества и недостатки. Лично я считаю, что недостатки классического TDD более приемлемые и решаемые, нежели недостатки мокисткого TDD. Ну, а кто-то может считать наоборот — он может прекрасно справляться с последствиями применения мокисткого TDD и не считать приемлемыми проблемы, возникающие при классическом TDD. Почему бы и нет? Все люди разные, и каждый имеет право на свой стиль. Я лишь привёл доводы, почему лично мне классика нравится больше, но окончательный выбор остаётся за вами.

P.S. Я не призываю вас полностью отказаться от модульных тестов. При использовании классического TDD тесты на те классы, которые не обращаются к методам и свойствам других классов, будут модульными.
Толмачёв Дмитрий @FiresShadow
карма
8,0
рейтинг 0,0
Разработчик ПО
Реклама помогает поддерживать и развивать наши сервисы

Подробнее
Спецпроект

Самое читаемое Разработка

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

  • +1
    Давайте вспомним рабочий процесс в рамках TDD: «красные» тесты, сигнализирующие об ошибке -> создание\изменение функционала -> «зелёные» тесты. Соответственно, программист сначала изменяет тесты так, чтобы они тестировали новый функционал.

    Воу воу. Где это написано что програмист меняет тест чтобы написать новый функционал?? Программист пишет новый тест, который будет тестировать новый функционал. Я бы не стал ломать работающие тесты если бы захотел реализовать новый функционал. Потом может быть я удалю старые, если посчитаю их избыточными. А может актуализирую под новый функционал. Но менять…
    • +2
      На стадии сопровождения часто требуется изменить действующий функционал. Например, новую колонку добавить в уже существующий отчёт, новый перк добавить уже существующему персонажу, внешнему сервису, с которым уже налажено взаимодействие, какие-то другие передать и так далее. В таких случаях зачастую проще изменить существующий тест и существующий класс, чем стереть тест и класс и написать всё заново. Это не ломание рабочего теста и рабочего класса, а просто приведение их в соответствие с новой бизнес-логикой и новыми требованиями заказчика.
      • –1
        Зачем стирать тесты? Зачем их ломать? Зачем изменять? Новому функционалу — новые тесты, не?
        • 0
          Представьте такую ситуацию. Гейм-дизайнеры решили, что какой-то перк (или способность) слишком сильная, и для соблюдения баланса решили уменьшить её эффективность в 2 раза. Или путём уменьшения в 2 раза какой-то характеристики перка, или путём добавления каких-то новых условий, которые необходимы, чтобы перк сработал. При этом этот перк должен остаться у тех же персонажей, которые им владели изначально. В этой ситуации проще в коде класса существующего перка константу изменить или условие добавить, чем удалять отовсюду старый перк и добавлять новый. А раз меняется бизнес-логика работы класса, то и тест нужно изменить. При этом в рамках TDD тест нужно изменить в первую очередь.
          Ситуации разные бывают. Иногда при изменении функционала нужно существующие тесты изменить, иногда их нужно удалить, иногда нужно новые написать, не трогая существующих, — всё от ситуации зависит. Но если вы меняете логику работы класса, то нужно в любом случае взглянуть и на его тесты, и что-то с этими тестами сделать (добавить\удалить\изменить)
          • 0
            Понятно, просто выше вы писали о новом функционале:

            > новую колонку добавить в уже существующий отчёт
            > новый перк добавить уже существующему персонажу

            Да, вполне вероятно, что где-то надо будет тесты менять, просто у вас это (и в посте) вроде как по-умолчанию.

            UPD: А, вижу вы в посте указали, что при изменении, но получилось совсем странно:

            > Соответственно, при изменении функционала, программист сначала изменяет тесты так, чтобы они тестировали новый функционал.

            Очевидно, не новый функционал, а изменённый.
      • 0
        Это понятно, но это наверное не совсем про TDD.
        • +6
          Почему? Грубо говоря, был тест: «в возвращаемом отчете есть колонки а, б, ц». Изменились требования, теперь в отчете должна еще появиться колонка «д». Меняем тест (он стал «в возвращаемом отчете есть колонки а, б, ц, д»), он становится красным, меняем реализацию, тест зеленый. Все. И тест продолжает соответствовать требованию.
  • +2
    > Во-первых, при изменении функционала сложно вообще вспомнить, что помимо класса и тестов на него нужно изменить ещё и моки этого класса

    Во-первых, надо разделять классы так, что бы это не было проблемой. В случае, если вы тут не будете использовать моки — у вас при изменении поведения упадёт 1000 тестов. Да, заметить это легче, но чинить сложнее.

    > Как видите, в этом определении нет ни слова о том, что интеграционные тесты можно писать только на главный класс
    > Согласно TDD, тесты предназначены для проверки функционала (feature)

    Во-вторых, вы зачем-то смешиваете «classical TDD» и интеграционные тесты. Это совершенно разные вещи. Поэтому там написано именно то, что написано. Интергационный тест — это более чем про одну feature, и к TDD имеет очень отдалённое отношение.
    • 0
      Во-первых, надо разделять классы так, что бы это не было проблемой.
      А можете пояснить, как именно нужно разделить классы? Не представляю, как перераспределение кода по классам способно решить эту идеологическую проблему, если изменение моков не вписывается ни в один из этапов TDD-шного цикла.

      В случае, если вы тут не будете использовать моки — у вас при изменении поведения упадёт 1000 тестов. Да, заметить это легче, но чинить сложнее.
      Этот вопрос подробно рассматривался в статье.

      Во-вторых, вы зачем-то смешиваете «classical TDD» и интеграционные тесты. Это совершенно разные вещи.
      Тест — это фрагмент кода, TDD — это философия, рабочий процесс. Интеграционные тесты используются в классическом TDD. Я не говорил, что тест = TDD.

      Интергационный тест — это более чем про одну feature, и к TDD имеет очень отдалённое отношение.
      А можете пояснить, почему по-вашему интеграционные тесты не имеют отношения к TDD?
      • 0
        > А можете пояснить, как именно нужно разделить классы?

        Например, http://martinfowler.com/bliki/TellDontAsk.html
        Если у вас код не зависит от поведения класса мока, то вам не надо править мок.

        > Интеграционные тесты используются в классическом TDD

        И вы легко приведёте пруфы?

        > А можете пояснить, почему по-вашему интеграционные тесты не имеют отношения к TDD?

        А я такого не писал.
        • 0
          Если у вас код не зависит от поведения класса мока, то вам не надо править мок.
          Речь шла не о том, в каких случаях нужно править мок, а о том, как решить, что вот прямо сейчас пришло время поправить мок. В рамках TDD все этапы чётко расписаны, что и после чего нужно делать. И правка моков не вписывается ни в один этап.

          И вы легко приведёте пруфы?
          Пруфы содержаться в статье. Ссылка на определение интеграционного теста и ссылка на определение классического и мокисткого TDD.
          • 0
            > Речь шла не о том, в каких случаях нужно править мок, а о том, как решить, что вот прямо сейчас пришло время поправить мок. В рамках TDD все этапы чётко расписаны, что и после чего нужно делать. И правка моков не вписывается ни в один этап.

            Ну вы же сами себе отвечаете: не надо править мок, нет такого этапа.

            > Ссылка на определение интеграционного теста

            И как это обосновывает применимость его в философии TDD? Вы или выражайтесь конкретнее, или одно из двух.

            > ссылка на определение классического и мокисткого TDD

            Вы опять всё смешали. TDD, классический TDD и мокисткий TDD — 3 разные вещи. Да и определения как такового там нет.
            • 0
              Ну вы же сами себе отвечаете: не надо править мок, нет такого этапа.
              Так в том то и дело, что этапа нету, а править надо, раз поведение мокированного класса изменилось.

              И как это обосновывает применимость его в философии TDD?
              По Фаулеру, The classical TDD style is to use real objects if possible and a double if it's awkward to use the real thing. Поскольку real objects, то тест интеграционный.
              • 0
                > а править надо, раз поведение мокированного класса изменилось.

                Это не причинно-следственная связь. Это косяк дизайна. TDD эту проблему решать не задуман.

                > Поскольку real objects, то тест интеграционный.

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

                  Если все женщины люди
                  Не если, а безусловно люди. Раз вы о прекрасном поле заговорили, то, я так понимаю, тема применения интеграционных и модульных тестов в классическом TDD исчерпана?
                  • 0
                    > Я об этом в статье и пишу, и предлагаю или TDD-шный цикл разработки изменить, или не мочить всё подряд без проверки задекларированного в моках поведения.

                    Вы в TDD можете хоть кодревью добавить, хоть необходимость завтракать. Только это будет уже не TDD.

                    > Поскольку real objects, то тест интеграционный.

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

                      Вывод неправильный.

                      Ладно, побуду занудой. Докажем, что интеграционные тесты используются в классическом TDD. Раз 1)классическое TDD, согласно Фаулеру, призывает использовать в тестах реальные объекты там, где это возможно и 2)в программировании возможна ситуация, что в методе одного класса используется метод другого класса, и нет веских обстоятельств, мешающих использовать реальный объект (пункт 2 доказывается примером, который я не считаю нужным приводить), то из этих двух пунктов следует, что в классическом TDD возможна ситуация тестирования одного модуля, обращающегося к другому. А по определению интеграционного теста, это интеграционный тест. Следовательно, интеграционные тесты используются в классическом TDD. Теперь согласны? Или снова будете говорит что всё неправильно, не поясняя где вы заподозрили противоречие?
                      • 0
                        из Ваших определений следует, что поделив тестируемый метод пополам, без изменения функционала, мы получим интеграционный тест. Если вспомнить, что определение Фаулера не содекржит упоминаний, что использование «стандартных» классов нещитово — вы своим доказательством фактически отрицаете существование на практике модульных тестов.

                        Да и не стоит приравнивать «класс» и «модуль»
                        • 0
                          из Ваших определений следует, что поделив тестируемый метод пополам, без изменения функционала, мы получим интеграционный тест
                          Если переместить часть кода тестируемого метода в другие классы, в тестируемом методе вызвать методы этих других классов, и не навесить моки на эти классы, то модульный тест станет интеграционным. Это следует из определений модульного и интеграционного теста на википедии.
                          Если вспомнить, что определение Фаулера не содекржит упоминаний, что использование «стандартных» классов нещитово
                          Зато это следует из определений модульного теста и исходного кода программы на википедии. (исходный код != (не равно) язык программирования. исходный код — то, что написано на языке программирования)

                          Извините, мне не интересно с вами общаться. До свидания.
                          • –1
                            То есть использование БД не противоречит модульному тесту, а использование приватного класса внутри класса — противоречит. Замечательно.
  • +1
    Интересная серия статей на эту же тему:
    enterprisecraftsmanship.com/2015/06/29/test-induced-design-damage-or-why-tdd-is-so-painful
  • 0
    Многие юнит-тесты с моками действительно становятся сложными в поддержке.
    Но, по-моему, при полноценном TDD сначала пишется интеграционный тест, а затем, при необходимости, юнит-тесты на уровень ниже.
    Подходы только юнит или только интеграционные — это две крайности.
    • 0
      при полноценном TDD сначала пишется интеграционный тест, а затем, при необходимости, юнит-тесты на уровень ниже.
      А почему не «а затем, при необходимости, интеграционные тесты на уровень ниже»?

      Подходы только юнит или только интеграционные — это две крайности.
      Довольно спорное философское утверждение. А быть негром или европеоидом — это тоже две крайности? Есть две школы (классическая и мокисты), которые по-разному подходят к TDD. В какой-то фиксированный момент времени вы либо ведёте себя как последователь одной школы, либо как последователь другой. В противном случае вы ведёте себя как человек, не следующий учению ни одной из школ, и вы можете основать свою школу, если сумеете систематизировать свои знания и формально описать новый подход (а не просто сказав, что выбираем тип теста в зависимости от дня недели, не объяснив, почему модульные тесты лучше писать по вторникам, а интеграционные — по пятницам). Возможно эта новая школа будет сочетать лучшие традиции предыдущих двух школ, и они исчезнут, слившись в одну. Но пока что этого не произошло. При всём уважении, желание быть всегда «где-то посередине» — тоже крайность, имхо.
      • 0
        Мокисткое правило — «Всегда мокируйте любое поведение объектов». Правило классического TDD — «Всегда используйте настоящие объекты, если это возможно». Если вы изобретёте новое правило, то это будет новым подходом. Не представляю, как можно быть где-то посередине: использовать немного настоящий объект, который является немного мокой. Может, можно как-то извратиться и сделать, но не представляю зачем и как.
        • 0
          Хотя, в чём-то вы правы :) В проекте в любом случае будет содержаться некоторая доля модульных тестов: для тех классов, внутри которых нет обращений к методам других классов. Формально и фактически такие тесты не будут интеграционными. Они будут потенциально интеграционными. Если в них добавится обращение к методам других классов, то по классическому TDD эти тесты будут преобразованы и станут интеграционными фактически. В статье шло скорее не сравнение интеграционных тестов и модульных, а сравнение мокисткого подхода и классического. Раз это не так очевидно, то добавлю в статью, что даже при классическом подходе какая-то часть тестов будет модульными. Хотя, имхо, это и так очевидно, вроде бы.
          • 0
            > Если в них добавится обращение к методам других классов, то по классическому TDD эти тесты будут преобразованы и станут интеграционными фактически

            Если вы тестируете string ReverseString(string) — вы считаете это будет интеграционный или модульный?
            • 0
              Если тестировать методом черного ящика и писать тест до реализации, то непонятно, получится он интеграционным или модульным — зависит от того, обращается ли к методам других классов ReverseString. Тип теста можно будет узнать только после реализации ReverseString. Кот Шрёдингера какой-то получается.
              • 0
                ReverseString — метод. Для простоты, статический и один в классе.
                • 0
                  Если взять за определения, что интеграционный тест работает без искусственных заглушек, а модульный не требует присутствия зависимостей тестируемого класса, то такой тест удовлетворяет обоим определениям.
                  • 0
                    А если взять определения из википедии?
      • 0
        При TDD разработка мне видится так:
        1. при необходимости внесения изменений человек описывает парочку интеграционных тестов, если это возможно, которые показывают как всё должно работать в целом.
        2. начинает вносить изменения, если в итоге изменения небольшие и покрываются тем что он написал, то всё хорошо.
        3. если человек начинает терять контроль над кодом в плане тестов (например, внутри объекта идёт сложная агрегация из ещё чего-то), то человек тестирует и эти модули.

        Конечно же, это «идеальный вариант» на практике часто пишутся сначала «кусочки» все или не все, которые потом объединяются во что-то большее. Но сама идеология TDD, по-моему, подразумевает разработку от общего к частному, а не наоборот. Но, возможно, это уже BDD.

        Вообще, разница между юнит-тестами и интеграционными, на мой взгляд не так велики. Тест, в котором используются какие-то библиотеки языка, которые решили не мокировать (те же объеты даты и времени) можно считать как юнит-тестом, так и интеграционным.
        • 0
          Вообще, разница между юнит-тестами и интеграционными, на мой взгляд не так велики. Тест, в котором используются какие-то библиотеки языка, которые решили не мокировать (те же объеты даты и времени) можно считать как юнит-тестом, так и интеграционным.

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

          В статье приведена ссылка на статью из русскоязычной википедии, в которой дано следующее определение: модульное тестирование, или юнит-тестирование (англ. unit testing) — процесс в программировании, позволяющий проверить на корректность отдельные модули исходного кода программы. (Соответственно в статье я подразумеваю под модульным тестированием именно это.) Также приведена ссылка на определение интеграционного теста: одна из фаз тестирования программного обеспечения, при которой отдельные программные модули объединяются и тестируются в группе.

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

          При TDD разработка мне видится так:
          1. при необходимости внесения изменений человек описывает парочку интеграционных тестов, если это возможно, которые показывают как всё должно работать в целом.
          Нет, в рамках TDD тесты пишутся не на «всё в целом», а на конкретный функционал. При этом в классическом TDD игнорируется тот факт, что этот функционал может использовать другой функционал, а в мокистком TDD эта связь с другим функционалом разрывается. Это важное различие между классическим и мокистким TDD. Классическое TDD проверяет корректность функционала, а мокисткое — корректность поведения класса.
          • 0
            А если я вместо стандартной библиотеки времени использую нестандартную, то уже не модульное?

            Зачем вы цепляетесь к словам «всё в целом»? Конечно же имелось ввиду как должен «весь в целом» работать функционал.
            • 0
              А если я вместо стандартной библиотеки времени использую нестандартную, то уже не модульное?
              Совершенно верно.
              • 0
                Допустим, а как быть, если язык позволяет патчить стандартные библиотеки напрямую и мы используем данную стандартную, но пропатченную библиотеку?
                • 0
                  Тесты тестируют модули исходного кода. Когда вы патчите стандартную библиотеку, появляется исходный код. Раз несколько модулей исходного кода — значит тест интеграционный.
                  • 0
                    Хотя, если есть возможность пропатчить стандартную библиотеку применительно к только какому-то одному модулю (классу), и есть возможность сделать это внутри самого модуля, то тестируется содержимое одного модуля, и тест модульный.
                    • 0
                      Вообще, не вижу смысла париться над разделением интеграционные тесты — модульные. Актуальнее проблема классическое TDD — мокисткое.
                    • –1
                      > Хотя, если есть возможность пропатчить стандартную библиотеку применительно к только какому-то одному модулю (классу), и есть возможность сделать это внутри самого модуля, то тестируется содержимое одного модуля, и тест модульный.

                      Спасибо, вот теперь всё встало на места. Если весь код написать в одном классе — будут идеальные модульные тесты.
              • 0
                Вот ситуация.

                Я тестирую какой-то объект. Внутри него используется ещё 1.
                Он прямо там и создаётся, больше нигде не используется. Я его решил не тестировать, потому что он, к примеру, слишком прост, считает мне 2 + 2. Я не хочу его передавать через IoC или как-то ещё. Я даже имею возможность его замочить его без IoC, но не хочу этого делать. Судя по-Вашему, это интеграционный тест.

                Но стоит мне только перенести весь функционал из этого вспомогательного объекта в основной, так тест становится модульным?

                Ну это же глупость!
                • 0
                  Есть формальные определения. Все подумали и согласись, что вот это будем называть так-то. Если вам определения не нравятся, то это, скорее всего, ваша проблема, а не тех миллионов или миллиардов людей, которые этим определением успешно пользуются.

                  [Ирония on]Недавно прочитал на микроволновке «не помещать внутрь домашних животных», а у меня на балконе карликовый жираф живёт. Балкон — хоть и часть дома, но такая незначительная, прям вообще не часть дома. Я там кроме рассады и жирафа ничего не держу. Какая же это часть дома?! Это часть природы! Да и жираф — милый такой, совсем не животное. Помыл я жирафа, и решил посушить его в микроволновке, а она возьми и сломайся! Но в гарантийном ремонте мне отказали. Не хотят соглашаться, что жираф с балкона — не домашнее животное. Ну просто глупцы какие-то! [Ирония off]

                  Надеюсь вас история повеселила.
                  P.S. История придуманная. Зоозащитники, узбагойтесь.
                  • 0
                    А вам самому не кажется странным, что тест то модульный, то сразу интеграционный и это постоянно меняется, причём при изменении не теста, а реализации?

                    И я уже задавал вопрос — почему при использовании стандартных классов он остаётся модульным, а при нестандартных перестаёт таковым быть? Разве модульность/интеграционность определяется набором библиотек?
                    И какое определение модульного теста говорит, что в внутри него не должно быть зависимостей? Или получается, что могут быть, но только некоторые?
                    Определение модульного теста определяет тестирование изолированного модуля, юнита, некой абстракции с которой мы работаем как с чёрным ящиком — не более.

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

                    P.S. сомневаюсь что существуют миллиарды людей, которые используют определения модульного/интеграционного тестирования.
                    • 0
                      А вам самому не кажется странным, что тест то модульный, то сразу интеграционный и это постоянно меняется, причём при изменении не теста, а реализации?
                      Нет, не кажется. Если тип теста (интеграционный \ модульный) зависит от реализации того, что он тестирует (по определению теста) и от реализации самого теста (ухода от реализации «лишних» классов в тесте за счёт мокирования), то вполне логично, что при изменении одной из реализаций или сразу двух тип теста может измениться.

                      И я уже задавал вопрос — почему при использовании стандартных классов он остаётся модульным, а при нестандартных перестаёт таковым быть?
                      На этот вопрос я уже отвечал. Если результат теста зависит от реализации нескольких модулей исходного кода программы, то тест интеграционный (по определению). Исходный код — текст программы на языке программирования. Язык программирования и исходный код программы — это не одно и тоже. Стандартные классы (int,bool,string,DateTime и пр.) — это часть языка, а не модули исходного кода программы.

                      Вообще, по поводу некоторых терминов можно долго и безрезультатно спорить. Особенно в молодых или неточных науках, где терминология не до конца устоялась и со временем претерпевает изменения, и один и тот же термин может означать немного разные явления в разных кругах. Не исключено, что до появления ООП под интеграционным тестированием понималось нечто другое. Также не исключено, что некоторые люди до сих пор понимают под интеграционным тестированием тоже самое, что понимали до появления ООП. Что под интеграционным тестированием понимаю лично я и те программисты, с которыми я общался, я вам рассказал. И, судя по этой статье, некоторые заокеанские коллеги понимают под интеграционным тестированием тоже самое. Можно конечно в начало статьи поместить длиннющий список определений, что подразумевается под мокой, мокированием, тестом, функционалом, рабочим процессом и прочим. Но что под этим подразумевается и так ясно из контекста. Статьи по программированию — это, как правило, не строгое научное доказательство программистких теорем в виде кванторов, а передача опыта и идей. Я бы не хотел читать статьи, в которых половина текста посвящена определениям исходного кода, рабочего процесса и прочего.

                      В статье было сказано, что понимается под интеграционным тестом (I use the term integrated test to mean any test whose result (pass or fail) depends on the correctness of the implementation of more than one piece of non-trivial behavior.) и шли рассуждения, почему интеграционных тестов не надо бояться. По сути статьи вопросы есть?
          • 0
            > Первый написанный тест может быть красным, пока другие тесты на классы, которые находятся слоем ниже, пишутся и зеленеют.

            Это вы тоже в википедии нашли?
  • +5
    Всё, что мы знаем при тестировании с использованием моков — это то, что у нас есть хорошо работающий мок. О том, что произойдёт с этим всем кодом на боевой системе мы не знаем ничего. Это как протестировать 10 000 кирпичей по отдельности и на этом основании дать гарантию, что построенный из них дом будет абсолютно надёжным. В общем, я за интеграционные тесты + модульные тесты в тех случаях, когда мы тестируем реальный код, а не моки.
  • +2
    Спасибо за статью.
    А вот и картинку в тему
    image
  • 0
    А если серьезно по теме. Я не против интеграционного тестирования, хотя на мой взгляд интеграционное тестирование имеет больше минусов чем плюсов по сравнению с модульным. Недавно обсуждались проблемы black-box testing и преимущества white-box testing.

    Высказывание 1. Интеграционные тесты в меньшей степени помогают в поиске ошибок, нежели юнит-тесты

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

    Из ваших же примеров. Добавление колонки в результат означает изменение теста измененного метода, изменение методов которые используют измененный метод, изменение тестов которые используют измененный метод т.д. В не зависимости от метода тестирования придется менять все. В случае с интеграционными тестами тесты которые нужно менять сразу покраснеют. В случае с юнит-тестами эти тесты придется искать через поиск, но это не проблема (Ctrl+F и название метода/класса), а поскольку тестов в случае юнит-тестирования в разы меньше то и результатов в поиске будет совсем чуть-чуть и тесты будут исправлены быстрее чем в случае интеграционного тестирования.

    Второй приведенный вам пример это изменение стата в игре. Стат это как константа, параметры, условие. Его изменение не должно влиять на работу программы. В случае юнит-тестирования нам нужно будет изменить только тест который тестирует конкретный метод, а в случае интеграционного тестирования придется еще изменять все тесты где используется этот метод что увеличивает объем работы.

    Высказывание 2. Интеграционные тесты в меньшей степени помогают в проектировании, нежели модульные

    Это вообще не относится к проектированию и тестированию. Просто везде используется DI и все. А как вы будите тестировать класс с зависимостями это уже другая история.

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

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

    Честно говоря я не понял как при интеграционном тестировании мы не столкнемся с экспоненциальным увеличением количества тестов. Ведь мы же используем реальные зависимости, а не моки, соответственно мы должны протестировать не только функциональность тестируемого метода, но и функциональность используемых зависимостей (в этом же весь смысл), а наши зависимости могут иметь свои зависимости, которые тоже могут иметь зависимости и т.д. Вот уже и получаем экспоненциальное увеличение. Это известная проблема black-box testing.

    Простая математика:
    • для тестирования метода нужно написать 5 тестов
    • метод имеет 2 зависимости и для тестирования каждой из них нужно еще по 5 тестов
    • первая из зависимостей имеет тоже 2 зависимости и для тестирования каждой из них нужно еще 5

    Итог: 5 * 5 * 5 * 5 * 5 = 3125 тестов для того что бы покрыть 1 единственный метод против 5 тестов в случае юнит-тестирования. 3125 тестов Карл.
    И это еще простой пример с малым количеством зависимостей. Подсчитал тут интереса ради для одного своего реального метода и получил примерно 15504 теста против 57 юнит-теста.

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

    Мой вывод: Единственный плюс который дает интеграционное тестирование это тестирование работы методов в контексте их использования. Это безусловно очень важный плюс, который не получит при использовании модульного тестирования, но я бы не стал зацикливаться на интеграционном тестировании только из-за него ибо для меня минусы перевешивают этот плюс.
    • 0
      Например, если меняется внутренняя реализация метода isEqual возвращающая в некоторых случаях false там где возвращала true, то у нас поломается только 1 юнит-тест и нужно будет дополнить его в связи с изменениями. В случае же интеграционного тестирования у нас может половина тестов покраснеть, хотя реально сделанные изменения ничего не ломают и ни как не влияют на корректность результата.

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

      Это вообще не относится к проектированию и тестированию. Просто везде используется DI и все.
      Если DI и IOC не имеют никакого отношения к проектированию, то я — испанский лётчик.

      Ведь мы же используем реальные зависимости, а не моки, соответственно мы должны протестировать не только функциональность тестируемого метода, но и функциональность используемых зависимостей (в этом же весь смысл).
      Прочитайте определение интеграционного теста… Там не сказано, что целью интеграционного теста является тестирование нескольких модулей. Там сказано, что результат прохождения (зелёный или красный) зависит от корректности реализации нескольких модулей (или что модули тестируются в группе). Это большая разница. Определение диктует то, как интеграционный тест должен быть реализован, но не то, с какой целью вы будете его реализовывать, и как вы будете его использовать. Цель определяет TDD. Интеграционные и модульные тесты в плане цели в TDD не различаются — это тестирование одной функциональности. Допустим есть функционал А, Б, В. Каждый функционал реализован в отдельном классе. Код, реализующий функционал А, использует функционал Б. Код, реализующий функционал Б, использует функционал В. Соответственно вы можете написать 6 модульных тестов на А и Б, по 3 на штуку, и точно также можете вместо них написать 6 интеграционных тестов на А и Б. Если код, реализующий функционал В, не использует никакой другой функционал и никакие другие классы, то на функционал В можно написать только модульные тесты. И никто вас не поколотит, если вы после замены моков на реальные классы не напишите ещё 100-500 тестов. Если Б сломается, то интеграционный тест на А упадёт, а модульный — нет (вспоминаем историю с Петей и негром).
      • 0
        Есть у вас в программе Петя и John, и функция isEqual, которая их различает. И вот в один прекрасный день вы изменяете isEqual и тест на неё, и теперь она их не различает. А ещё есть тест на то, что жена Пети не пускает в три часа ночи незнакомых пьяных мужчин. И вот после изменения isEqual этот тест краснеет. Вы ругаете интеграционное тестирование, и удаляете этот тест, ведь «сделанные изменения ничего не ломают и ни как не влияют на корректность результата». Примерно через год после этого приходит Петя и слёзно спрашивает вас, почему его жена пускает по ночам пьяных незнакомых мужчин, и почему в его семье родился негр, хотя ни у него в роду, ни у его жены негров отродясь не было.

        Об этом я говорил ниже. Интеграционные тесты позволяют тестировать контекст в котором вызывается тестируемая функция, но это не всегда нужно. Например у нас есть функция А которая проверяет какое-то условие и есть функция Б которая использует ее и с ее помощью определяет нужно ли запустить подпрограмму С. И вот мы решили изменить условия в функции А. Мы все также знаем чту функция А может возвращать true|false в разных условиях, это мы проверили через тесты. Это значит что функция Б все также будет запускать подпрограмму С, но уже в других ситуациях и это нормально. Так и должно быть.

        Можно написать интеграционный тест который будет гарантировать мне что в определенных условиях функция А вернет true и функция Б соответственно запустит подпрограмму С. То есть через интеграционные тесты можно задать жесткое поведение программы. Описать все варианты развития сценария. Шаг в право, шаг в лево — расстрел. Любое изменение в коде означает изменение десятка, а то и сотни тестов.
        С модульными тестами у нас больше свободы, писать их быстрее и проще, тестов меньше и выполняются они быстрее, поддерживать их в зеленом состоянии проще и меньше затрат ресурсов.

        Я не спорю, интеграционные тесты это хорошо и они нужны, но они требуют столько ресурсов что выгода может не окупится. Я не говорю что их не надо писать, я говорю что нужно начать с малого, с модульных тестов. И нужно оценивать свои ресурсы. Многие компании не готовы выделить время и деньги на написание тестов вместо того что бы писать новый функционал и приносить в компании больше денег. Есть такие которые готовы потерять N $ например на уязвимости в проекте и заработать на новом функционале N^3 $.

        Если DI и IOC не имеют никакого отношения к проектированию, то я — испанский лётчик.

        к тестированию не имеют отношения, к тестированию

        Код, реализующий функционал А, использует функционал Б. Код, реализующий функционал Б, использует функционал В. Соответственно вы можете написать 6 модульных тестов на А и Б, по 3 на штуку, и точно также можете вместо них написать 6 интеграционных тестов на А и Б.

        да, только здесь умножение, а не сложение:

        • Функция А имеет 3 ветки развития алгоритма.
        • Функция А использует функцию Б.
        • Функция Б имеет 3 ветки развития алгоритма.
        • Соответственно для каждой ветки развития в функции А есть по 3 ветки развития из функции Б.
        • 3 * 3
        • Функция Б использует функцию В.
        • Функция В имеет 3 ветки развития алгоритма.
        • Соответственно для каждой ветки развития в функции Б есть по 3 ветки развития из функции В.
        • 3 * 3
        • Соответственно для каждой ветки развития в функции А есть по 3 ветки развития из функции Б и для каждой из них есть по 3 ветки развития из функции В.
        • 3 * 3 * 3

        Итог: 3 * 3 * 3 = 27 веток развития алгоритма и как результат 27 вариантов результат и как результат 27 тестов функции А.

        Естественно это идеализированный пример ибо не в каждой ветке развития алгоритма будет использоваться вложенная функция, но общая мысль должна быть понятна.
        • 0
          Любое изменение в коде означает изменение десятка, а то и сотни тестов.
          Ответ на этот вопрос есть в статье. Я приводил ссылку на test builder. Нужно будет изменить builder, а не сотни тестов.

          Соответственно для каждой ветки развития в функции А есть по 3 ветки развития из функции Б.
          3 * 3
          Этот вопрос разбирался в статье: «Согласно TDD, тесты предназначены для проверки функционала (feature), а не путей исполнения программы.». Не считайте пути исполнения программы (или ветки развития, как вы их называете), считайте сколько тестов вам нужно для тестирования конкретного функционала.

          к тестированию не имеют отношения, к тестированию
          Изначально было так:
          Я (в статье): Высказывание 2. Интеграционные тесты в меньшей степени помогают в проектировании, нежели модульные…
          Вы: Это вообще не относится к проектированию и тестированию. Просто везде используется DI и все.
          Я: Если DI и IOC не имеют никакого отношения к проектированию, то я — испанский лётчик.
          Вы: к тестированию не имеют отношения, к тестированию

          Что-то я ничего не понял. По-вашему, тесты к тестированию не имеют отношения? Что вы вообще хотите спросить\сказать?
          Если что, речь шла про тесты в рамках TDD.
          • 0
            Этот вопрос разбирался в статье: «Согласно TDD, тесты предназначены для проверки функционала (feature), а не путей исполнения программы.». Не считайте пути исполнения программы (или ветки развития, как вы их называете), считайте сколько тестов вам нужно для тестирования конкретного функционала.

            Хорошо. Тогда объясните что вы имеете в виду под словом функционал (feature)? Тестирование только особенностей тестируемого метода, без привязки к зависимостям? Если да, то тестирование получается не полным как и в случае с модульным. И мы опять возвращаемся к проблеме с Петей и John, ибо неполный тест может не отлавливать ситуации при которых Петя == John.

            Что-то я ничего не понял. По-вашему, тесты к тестированию не имеют отношения? Что вы вообще хотите спросить\сказать?
            Если что, речь шла про тесты в рамках TDD.

            я говорил вот об этом
            Доказательство:
            Модульное тестирование, в отличие от интеграционного, вынуждает программистов инжектировать зависимости через конструктор или свойства. А если использовать интеграционное тестирование вместо модульного, то джуниор может зависимости прямо в коде класса инстанцировать.

            Если повсеместно использовать DI то этой проблемы не будет и не будет разницы какие тесты вы пишете, интеграционные или модульные. Это организация процесса разработки и архитектуры приложения, ну и обучения джунов. Этот вопрос не имеет прямого отношения к тестированию и TDD.
            • 0
              Тестирование только особенностей тестируемого метода, без привязки к зависимостям?
              Я предлагал использовать вместо зависимости моки внешних сервисов и хранилищ данных, и реальные классы во всех остальных случаях. В случае с Джоном и Петей мы имеем дело с пользовательской историей (т.е. требованием, написанным на языке пользователя) «Замужняя дама должна отклонять просьбу зайти в дом в ночное время от мужчины, не являющегося её мужем». Эта пользовательская история закрепляется тестом. В тесте или при помощи билдера создаётся замужняя дама, или просто в свойство «Муж» передаётся Петя, и потом идёт сама проверка. Допустим, логика проверки на мужа была завязана на isEqual с Петей. В какой-то момент было решено, что в программе представляет интерес только, допустим, надёжность или репутация человека, и два человека с одинаковыми уровнями надёжности можно взаимозаменить в любом месте программы, и isEqual переписали. А про логику замужних дам забыли. Если использовать в качестве isEqual реальную реализацию (или функционал сравнения), то тест может отловить ошибку. А если в тесте использовать не Петю и Джона, а Петю и Васю, то по каким-то причинам может и не отловить. Это тоже ещё одна проблема, что тесты не гарантируют 100% надёжности, поскольку тестируются какие-то отдельные случаи, но не все возможные.

              Если повсеместно использовать DI то этой проблемы не будет и не будет разницы какие тесты вы пишете, интеграционные или модульные.
              Я примерно о том же самом в статье писал.
              • 0
                Это тоже ещё одна проблема, что тесты не гарантируют 100% надёжности, поскольку тестируются какие-то отдельные случаи, но не все возможные.

                В таком случае интеграционные тесты имеют чуть больший процент покрытия чем модульные, но все так же далеки от 100%.
            • 0
              считайте сколько тестов вам нужно для тестирования конкретного функционала.
              Другими словами можно сказать — сколько тестов нужно для тестирования контракта метода (выполнения постусловий при соблюдении предусловий).
  • 0
    Это все будет работать только для относительно простых завимостей, инстанциирование которых достаточно легкое и не требует настройки. Например, если у вас есть свой сериализатор, наверное не имеет большого смысла заменять его моком. Из моего опыта: обычно никто для таких объектов моки и не создает.

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

    Еще такой подход не будет работать или будет работать с большим трудом, когда над разными компонентами работают разные люди, так как кроме своих собственных проблем вам еще придется разгребать и проблемы коллег.
  • –2
    Давно хотел написать подобную статью. Славно, что вы меня опередили и мне не придётся париться :-)

    Добавлю пару мыслей:

    1. Нет смысла разделять модульные тесты и интеграционные — это лишь частные и сильно ограниченные случаи приёмочных тестов.

    2. Зависимости между модулями должны выстраиваться в дерево. Сборщик сериализует это дерево и инициализирует модули в порядке от менее зависимых, до более зависимых. В том же порядке должны запускаться и приёмочные тесты. Таким образом первый упавший тест укажет на проблемное место. По мере прохождения тестов тестируются всё большие и большие части приложения.

    3. Модульное тестирование — тестирование для ленивых. В купе с метриками покрытия строк кода, они позволяют легко и просто пускать пыль в глаза менеджменту: вроде и 100% покрытие, но большая часть функционала оказывается между модулями, а не внутри них, так как отдельные модули при модульном тестировании начинают стремятся делать как можно проще и «тестируемее».
    • +1
      Нет смысла разделять модульные тесты и интеграционные — это лишь частные и сильно ограниченные случаи приёмочных тестов.

      А то, что бывают не только приемочные тесты — и при этом «оставшиеся» все равно могут быть как модульными, так и интеграционными, вас не смущает?

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

      … ну то есть начинаем мы от хранилища, файловой системы, внешних веб-служб и так далее?

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

      А как вам удается разместить функциональность между модулями — т.е., в месте, где нет кода?

      Модульное тестирование — тестирование для ленивых.

      Я и не спорю, кстати. Я вот ленивый, поэтому предпочитаю модульное тестирование — оно позволяет достичь нужного мне результата проще, с меньшим количеством усилий и существенно быстрее.
      • 0
        А то, что бывают не только приемочные тесты — и при этом «оставшиеся» все равно могут быть как модульными, так и интеграционными, вас не смущает?
        Ок, давайте поспорим о терминах. Какие ещё бывают тесты, кроме «проверки соответствия функциональным требованиям»? Лично я не вижу смысла делить тесты иначе чем на «тесты корректности» и «тесты производительности».

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

        А как вам удается разместить функциональность между модулями — т.е., в месте, где нет кода?
        Ну, это не мне. Это места инициализации модулей, места их соединений, конфиги.

        Я и не спорю, кстати. Я вот ленивый, поэтому предпочитаю модульное тестирование — оно позволяет достичь нужного мне результата проще, с меньшим количеством усилий и существенно быстрее.
        Быстрее чем что?
        • +1
          Какие ещё бывают тесты, кроме «проверки соответствия функциональным требованиям»?

          Регрессионные — т.е., те, которые фиксируют поведение, ранее обнаруженное в багах. В требованиях оно может быть не описано при этом.

          Именно так

          Вы понимаете, что вы тем самым усложняете тестирование на несколько порядков?

          А вот на CI-сервере уже гонять по полной.

          CI-сервер не сдохнет, случайно?

          Это места инициализации модулей, места их соединений, конфиги.

          Так это тоже же тестировать надо.

          Быстрее чем что?

          Чем интеграционное с использованием БД, файловой системы, сетевого и межпроцессного взаимодействия.
          • 0
            Регрессионные — т.е., те, которые фиксируют поведение, ранее обнаруженное в багах. В требованиях оно может быть не описано при этом.

            Не повторять багов — вполне себе требование. Да и само существование бага — невыполнение функционального требования.

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

            CI-сервер не сдохнет, случайно?
            Нет.

            Так это тоже же тестировать надо.
            Только это уже не модульные тесты.

            Чем интеграционное с использованием БД, файловой системы, сетевого и межпроцессного взаимодействия.
            Не стоит впадать в крайности. Как я уже сказал, модульные тесты просты и быстры, но проверяют далеко не всё, и их прохождение не гарантирует, что ваше приложение вообще запустится.
            • +2
              Не повторять багов — вполне себе требование. Да и само существование бага — невыполнение функционального требования.

              Я уже поправился ниже: разница не в отношении к требованиям, а в, так скажем, временном аспекте. Регрессионные тесты смотрят «назад», приемочные — «вперед».

              Наоборот, упрощаю

              То есть необходимость сетапить (в том числе — поддерживать изоляцию и корректное состояние) несколько разнородных зависимостей — это упрощение?

              А так же значительно увеличиваю качество тестирования и своевременность обнаружения проблем на стыке со внешними сервисами.

              Давайте разберемся. Сначала «проблемы на стыке со внешними сервисами». Вы же не хотите сказать, что вы будете из CI-окружения использовать «боевой» внешний сервис? Наверное, нет. Значит, проблемы на стыке с ним (равно как и проблемы конфигурации боевого окружения и прочие милые вещи) вы получите все равно потом, так что эта «своевременность» — некоторая иллюзия. Вы получаете некоторые проблемы раньше. Вопрос того, какой ценой.

              Теперь «качество тестирования». Что вы понимаете под качеством? Потому что я в данном контексте могу выделить следующие варианты: тестовое покрытие, скорость обратной связи, точность обратной связи (место обнаружения ошибки) и, наконец, надежность покрытия (в значении «количество ложных срабатываний в обе стороны»). Аналогично, пойдем по порядку.

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

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

              С точностью обратной связи и количеством ложных срабатываний мне кажется что будет более-менее одинаково в обоих вариантах.

              Собственно, то, что вы описываете — это типичный «запашок» под названием «зависимые тесты».

              (ну и да, мы еще не затрагивали сложность тестирования исключительных ситуаций в «живых» окружениях)

              Нет

              Завидую вам. Я таких мощностей в своем распоряжении никогда не имел (и вряд ли буду).

              Только это уже не модульные тесты.

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

              Не стоит впадать в крайности

              А я и не впадаю. Я просто говорю, что для меня, как для ленивого программиста, модульные тесты обычно выгоднее, потому что дают больший эффект при меньших затратах. Иначе говоря, я предпочту иметь один функциональный тест, проверяющий, что приложение запускается и проходит happy path, и тысячи модульных тестов, проверяющих всякие побочные ветви, нежели покрывать это все тысячами функциональных тестов.
              • 0
                Я уже поправился ниже: разница не в отношении к требованиям, а в, так скажем, временном аспекте. Регрессионные тесты смотрят «назад», приемочные — «вперед».
                Опять же, нет никакого смысла разделять эти два типа тестов. Просто одни пишутся до деплоя, а другие после, ибо до деплоя по каким-либо причинам не написали.

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

                Давайте разберемся. Сначала «проблемы на стыке со внешними сервисами». Вы же не хотите сказать, что вы будете из CI-окружения использовать «боевой» внешний сервис? Наверное, нет.


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

                Чтобы получить значительный выигрыш в тестовом покрытии, вам надо покрыть как минимум все те сценарии, которые покрываются модульными тестами. А теперь смотрите: вот вы протестировали модуль, который зависит от какого-то внешнего стимула, у него есть столько-то ветвей. Вы все их покрыли. Теперь вы тестируете следующий модуль, который зависит от предыдущего и от внешнего стимула. Теперь вам надо покрывать все стимулы предыдущего модуля и все стимулы этого (чтобы быть уверенным, что они корректно интегрируются). Таким образом, чтобы получить то же самое покрытие, что в модульных тестах, вам надо затратить больше ресурсов (причем во всех смыслах).
                Нет смысла в следующем модуле тестировать предыдущий. То есть тесты тесты для следующего модуля пишутся в предположении, что предыдущий реализован правильно. Таким образом, чтобы получить то же покрытие, что и в модульных тестах, достаточно затратить столько же ресурсов, а то и меньше, ведь не нужно мокать всё подряд. При этом в качестве бонуса мы получаем проверку взаимодействия модулей.

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

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

                С точностью обратной связи и количеством ложных срабатываний мне кажется что будет более-менее одинаково в обоих вариантах.
                А как же упомянутая в топике проблема несоответствия моков?

                Собственно, то, что вы описываете — это типичный «запашок» под названием «зависимые тесты».
                А то, что вы описываете — это типичный «запашок» под названием «тесты сферического модуля в вакууме».

                (ну и да, мы еще не затрагивали сложность тестирования исключительных ситуаций в «живых» окружениях)
                А в чём там сложность?

                Завидую вам. Я таких мощностей в своем распоряжении никогда не имел (и вряд ли буду).
                Какими «такими»?

                Смотря что. Инициализация и места соединений могут быть покрыты модульными тестами. Конфигурация — сложнее, но тоже может.
                Покрывать места соединения модульными тестами — равносильно кратному увеличению мест соединений. То есть, если вы изолированно протестировали модуль А и модуль Б, а потом протестировали изолированную связь АБ, то у вас образовалось ещё 2 непротестированные связи: А-АБ и и АБ-Б.

                А я и не впадаю. Я просто говорю, что для меня, как для ленивого программиста, модульные тесты обычно выгоднее, потому что дают больший эффект при меньших затратах. Иначе говоря, я предпочту иметь один функциональный тест, проверяющий, что приложение запускается и проходит happy path, и тысячи модульных тестов, проверяющих всякие побочные ветви, нежели покрывать это все тысячами функциональных тестов.
                Ну, это ещё ничего, некоторые вообще тесты писать ленятся :-D
                • +1
                  Опять же, нет никакого смысла разделять эти два типа тестов.

                  Есть. У них разный импакт.

                  Да, это упрощение автоматической всесторонней проверки продукта.

                  Упрощение по сравнению с чем?

                  Вообще-то да.

                  Всегда?

                  Простейший пример: авторизация через соцсеточки. Отдельный тестовый сервер соц сети не предоставляют, да и смысла в этом мало,

                  А вот ЕСИА, например, наоборот, обязывает интегрирующиеся системы работать с тестовым экземпляром.

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

                  Вот только как вы обеспечите все нужные входы в тестируемый модуль из нетестируемого?

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

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

                  … который начнётся не с самого начала, а с изменённого модуля.

                  Тогда вы не будете уверены, что предыдущий код работает. Так делать не надо.

                  А как же упомянутая в топике проблема несоответствия моков?

                  Я с ней встречаюсь редко.

                  А то, что вы описываете — это типичный «запашок» под названием «тесты сферического модуля в вакууме».

                  В какой литературе он описан?

                  А в чём там сложность?

                  Ну вот вам надо проверить, что удаленная система (в качестве которой вы предлагаете использовать боевую): (а) не отвечает (б) отдает ошибку (ц) отдает неизвестно что (д) отвечат медленнее определенного (е) отвечает быстрее определенного (ж) делает это все в определенной последовательности. Это… нетривиально. Реализуемо, да. Но нетривиально.

                  Какими «такими»?

                  Ну так, чтобы проверить «типичную» современную систему, нужно иметь два БД-сервера, два сервера приложений, два веб-сервера и две клиентских машины. А теперь помножим на количество одновременно идущих билдов.

                  Покрывать места соединения модульными тестами — равносильно кратному увеличению мест соединений. То есть, если вы изолированно протестировали модуль А и модуль Б, а потом протестировали изолированную связь АБ, то у вас образовалось ещё 2 непротестированные связи: А-АБ и и АБ-Б.

                  Не-а. Место «соединения» модулей А и Б — это конкретная точка внутри А, где тот вызывает Б. Когда мы подменяем в тесте Б, мы знаем, что А вызывает его корректно. И наоборот. Остается протестировать инициализацию — т.е. то, что во время «боевой» сборки системы А получает нужный Б.

                  Ну, это ещё ничего,

                  Это не просто «ничего», это еще и (проверенно) быстрее по времени отклика, чем у соседа, который пишет функциональные тесты на каждый чих.
          • +1
            На самом деле, я не совсем прав по поводу регрессионных тестов. Более верно разделять регрессионные и приемочные тесты следующим образом: приемочные тесты фиксируют новую (или изменяемую) функциональность, регрессионные тесты фиксируют ранее существовавшую функциональность. В принципе, при смене итерации приемочные тесты даже могут стать регрессионными — но могут и нет.

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