Как избежать хрупких юнит-тестов?

24

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

То, что я ищу, - это окончательный способ написания управляемых и поддерживаемых тестов.

Каркасы

Чак Конвей
источник
Это гораздо лучше подходит для программистов.
StackExchange
BDD
Робби Ди

Ответы:

21

Не думайте о них как о «сломанных модульных тестах», потому что это не так.

Это спецификации, которые ваша программа больше не поддерживает.

Не думайте, что это «исправление тестов», а «определение новых требований».

Тесты должны сначала указать ваше приложение, а не наоборот.

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

Несколько других заметок, которые могут вам помочь:

  1. Тесты и тестируемые классы должны быть короткими и простыми . Каждый тест должен проверять только единый функционал. То есть его не волнуют вещи, которые уже проверяются другими тестами.
  2. Тесты и ваши объекты должны быть слабо связаны друг с другом таким образом, что если вы изменяете объект, вы изменяете только его график зависимостей вниз, и другие объекты, использующие этот объект, не затрагиваются им.
  3. Вы можете создавать и тестировать не те вещи . Ваши объекты созданы для легкого взаимодействия или простой реализации? Если это последний случай, вы обнаружите, что меняете много кода, использующего интерфейс старой реализации.
  4. В лучшем случае строго придерживайтесь принципа единой ответственности. В худшем случае придерживайтесь принципа разделения интерфейса. Смотрите твердые принципы .
Ям Маркович
источник
5
+1 заDon't think of it as "fixing the tests", but as "defining new requirements".
StuperUser
2
+1 Тесты должны сначала указать ваше приложение, а не наоборот
древовидный кодировщик
11

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

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

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

данные были жестко закодированы.

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

assert sum([1,2,3]) == 6
assert sum([1,2,3]) == 1 + 2 + 3
assert sum([1,2,3]) == reduce(operator.add, [1,2,3])

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

очень мало повторного использования кода

Лучшее повторное использование кода в тестах - это imho 'Checks', как и в jUnits assertThat, потому что они делают тесты простыми. Кроме того, если тесты могут быть подвергнуты рефакторингу для совместного использования кода, возможен и реальный тестируемый код , что сводит тесты к тестированию базы с рефакторингом.

keppla
источник
Я хотел бы знать, где downvoter не согласен.
Keppla
keppla - я не downvoter, но в целом, в зависимости от того, где я нахожусь в модели, я предпочитаю тестирование взаимодействия объектов, чем тестирование данных на уровне модулей. Данные тестирования работают лучше на уровне интеграции.
Ритч Мелтон
@keppla У меня есть класс, который направляет заказ на другой канал, если его общие элементы содержат определенные элементы с ограниченным доступом. Я создаю фальшивый заказ, заполняю его 4 предметами, два из которых являются запрещенными. Что касается предметов с ограниченным доступом, этот тест является уникальным. Но шаги по созданию поддельного заказа и добавлению двух обычных элементов - это та же настройка, которую использует другой тест, который проверяет рабочий процесс без ограничений. В этом случае, наряду с элементами, если заказу необходимо настроить данные клиента, настроить адреса и т. Д., Это не хороший случай повторного использования помощников по настройке. Почему только утверждают повторное использование?
Асиф Шираз
6

У меня тоже была эта проблема. Мой улучшенный подход был следующим:

  1. Не пишите модульные тесты, если они не являются единственным хорошим способом проверить что-либо.

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

  2. Используйте утверждения везде, где они эквивалентны модульному тесту для этого компонента. Утверждения обладают хорошим свойством, что они всегда проверяются на протяжении любой отладочной сборки. Таким образом, вместо того чтобы тестировать ограничения класса «Сотрудник» в отдельном блоке тестов, вы эффективно тестируете класс «Сотрудник» в каждом тестовом примере в системе. Утверждения также обладают приятным свойством, заключающимся в том, что они не увеличивают массу кода в большей степени, чем модульные тесты (которые в конечном итоге требуют scaffolding / mocking / что угодно).

    Прежде чем кто-то убьет меня: производственные сборки не должны рушиться на утверждениях. Вместо этого они должны регистрироваться на уровне «Ошибка».

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

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

  3. Предпочитайте системные / интеграционные тесты, внедряя их для всех ваших основных потоков и пользовательского опыта. Угловые шкафы, вероятно, не должны быть здесь. Системный тест проверяет поведение на стороне пользователя путем запуска всех компонентов. Из-за этого системный тест обязательно медленнее, поэтому напишите те, которые имеют значение (не больше, не меньше), и вы поймете самые важные проблемы. Системные тесты имеют очень низкие эксплуатационные расходы.

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

  4. Напишите сильные API, которые можно функционально протестировать. Функциональные тесты неудобны и (давайте посмотрим правде в глаза) бессмысленно, если ваш API слишком усложняет проверку работоспособных компонентов самостоятельно. Хороший дизайн API а) упрощает этапы тестирования и б) порождает четкие и ценные утверждения.

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


По вопросу «не пишите юнит-тесты» я приведу пример:

TEST(exception_thrown_on_null)
{
    InternalDataStructureType sink;
    ASSERT_THROWS(sink.consumeFrom(NULL), std::logic_error);
    try {
        sink.consumeFrom(NULL);
    } catch (const std::logic_error& e) {
        ASSERT(e.what() == "You must not pass NULL as a parameter!");
    }
}

Автор этого теста добавил семь строк, которые никак не влияют на проверку конечного продукта. Пользователь никогда не должен видеть, что это происходит, потому что a) никто никогда не должен передавать NULL там (поэтому напишите утверждение), или b) случай NULL должен вызывать другое поведение. Если случай (b), напишите тест, который фактически проверяет это поведение.

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

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

Тогда мой конкретный совет: начинайте разумно удалять модульные тесты, когда они ломаются, задавая себе вопрос: «Это вывод или я трачу код?» Возможно, вам удастся сократить количество вещей, которые тратят ваше время.

Андрес Яан Тэк
источник
3
Предпочитаю системные / интеграционные тесты - это ужасно плохо. Ваша система доходит до того, что она использует эти (медленные!) Тесты для тестирования вещей, которые могут быть быстро обнаружены на уровне модулей, и для их запуска требуются часы, потому что у вас так много похожих и медленных тестов.
Ритч Мелтон
1
@RitchMelton Совершенно отдельно от обсуждения, похоже, вам нужен новый CI-сервер. CI не должен так себя вести.
Андрес Яан Тэк
1
Сбой программы (что и делают утверждения) не должен убивать вашего тестового бегуна (CI). Вот почему у вас есть тестовый бегун; так что-то может обнаружить и сообщить о таких сбоях.
Андрес Яан Тэк
1
Утверждения в стиле Assert только для отладки, с которыми я знаком (не тестовые утверждения), выдают диалоговое окно, в котором висит CI, потому что он ожидает взаимодействия с разработчиком.
Ритч Мелтон
1
Ах, хорошо, это многое бы объяснило по поводу нашего несогласия. :) Я имею в виду утверждения в стиле C. Я только сейчас заметил, что это вопрос .NET. cplusplus.com/reference/clibrary/cassert/assert
Андрес Яан Тэк,
5

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

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

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

Нил
источник
3

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

  1. Используйте фабрику тестовых объектов для построения структур входных данных, поэтому вам не нужно дублировать эту логику. Возможно, загляните в вспомогательную библиотеку, такую ​​как AutoFixture, чтобы сократить код, необходимый для настройки теста.
  2. Для каждого тестового класса централизовать создание SUT, чтобы было легко изменить, когда вещи будут реорганизованы.
  3. Помните, что тестовый код так же важен, как и рабочий код. Он также должен быть реорганизован, если вы обнаружите, что повторяете себя, если код кажется недостижимым и т. Д. И т. Д.
driis
источник
Чем больше вы повторно используете код в тестах, тем более хрупкими они становятся, потому что теперь изменение одного теста может сломать другой. Это может быть разумной ценой в обмен на ремонтопригодность - я не буду вдаваться в этот аргумент - но утверждать, что пункты 1 и 2 делают тесты менее хрупкими (что и было вопросом), просто неправильно.
PDR
@driis - верно, тестовый код имеет идиомы, отличные от выполняемого кода. Сокрытие вещей путем рефакторинга «обычного» кода и использования таких вещей, как контейнеры IoC, просто маскирует проблемы проектирования, обнаруживаемые вашими тестами.
Ритч Мелтон
Хотя точка зрения @pdr, вероятно, действительна для модульных тестов, я бы сказал, что для интеграционных / системных тестов было бы полезно подумать о «подготовке приложения к задаче X». Это может включать навигацию в нужное место, установку определенных параметров времени выполнения, открытие файла данных и т. Д. Если несколько интеграционных тестов начинаются в одном и том же месте, рефакторинг этого кода для повторного его использования в нескольких тестах может быть плохим, если вы понимаете риски и ограничения такого подхода.
CVn
2

Обрабатывайте тесты так, как вы делаете это с исходным кодом.

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

комар
источник
1

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

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

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

guillaume31
источник