Как вы поддерживаете свои юнит-тесты при рефакторинге?

29

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

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

Как избежать взлома тестов при рефакторинге?

  • Вы пишете тесты «лучше»? Если так, что вы должны искать?
  • Вы избегаете определенных видов рефакторинга?
  • Существуют ли инструменты тестирования-рефакторинга?

Изменить: я написал новый вопрос, который спросил, что я хотел спросить (но сохранил этот как интересный вариант).

Алекс Фейнман
источник
7
Я бы подумал, что с TDD ваш первый шаг в рефакторинге - это написать тест, который не пройден, а затем провести рефакторинг кода, чтобы заставить его работать.
Мэтт Эллен
Разве ваша IDE не может понять, как провести рефакторинг тестов?
@ Thorbjørn Равн Андерсен, да, и я написал новый вопрос, в котором спрашивалось, что я хотел спросить (но оставил этот вопрос в качестве интересного варианта; см. Ответ Ажеглова, по сути, то, что вы говорите)
Алекс Фейнман
Рассматривали добавление thar Info к этому вопросу?

Ответы:

35

То, что вы пытаетесь сделать, это на самом деле не рефакторинг. С помощью рефакторинга, по определению, вы не меняете то, что делает ваше программное обеспечение, вы меняете то, как оно делает это.

Начните со всех зеленых тестов (все проходят), затем внесите изменения «под капот» (например, переместите метод из производного класса в базовый, извлеките метод или инкапсулируйте Composite с помощью Builder и т. Д.). Ваши тесты все еще должны пройти.

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

azheglov
источник
7
Тот же код X скопировал 15 мест. Индивидуальные в каждом месте. Вы делаете его общей библиотекой и параметризуете X или используете шаблон стратегии, чтобы учесть эти различия. Я гарантирую, что модульные тесты для X не удастся. Клиенты X потерпят неудачу, потому что общественный интерфейс изменяется немного. Редизайн или рефакторинг? Я называю это рефактором, но в любом случае это ломает все виды вещей. Суть в том, что вы не можете провести рефакторинг, если не знаете точно, как все это сочетается. Тогда исправление тестов утомительно, но в конечном итоге тривиально.
Кевин
3
Если тесты нуждаются в постоянной корректировке, это, вероятно, намек на слишком подробные тесты. Например, предположим, что фрагмент кода должен запускать события A, B и C при определенных обстоятельствах, а не в определенном порядке. Старый код делает это в порядке ABC, и тесты ожидают события в этом порядке. Если измененный код выплевывает события в порядке ACB, он все еще работает в соответствии со спецификацией, но тест не пройден.
Отто
3
@Kevin: я считаю, что вы описываете редизайн, потому что публичный интерфейс меняется. Определение рефакторинга Фаулером («изменение внутренней структуры [кода] без изменения его внешнего поведения») совершенно ясно об этом.
ажеглов
3
@azheglov: возможно, но по моему опыту, если реализация плохая, то и интерфейс
Кевин
2
Совершенно обоснованный и ясный вопрос заканчивается обсуждением «значения слова». Кому интересно, как вы это называете, давайте поговорим где-нибудь еще. В то же время этот ответ полностью опускает любой реальный ответ, но все еще имеет наибольшее количество голосов. Я понимаю, почему люди называют TDD религией.
Дирк Бур
21

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

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

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

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

Тим Мерфи
источник
7

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

Если я использую фреймворк, который проверяет порядок методов, вызываемых на макетах (когда порядок не имеет значения, поскольку вызовы не имеют побочных эффектов); тогда, если мой код будет чище с этими вызовами методов в другом порядке, и я проведу рефакторинг, мой тест будет прерван. В общем, макеты могут вносить хрупкость в тесты.

Если я проверяю внутреннее состояние моего SUT, выставляя его закрытых или защищенных членов (мы можем использовать «друг» в Visual Basic или повысить уровень доступа «внутренний» и использовать «internalsvisibleto» в c #; во многих языках ОО, включая c # можно использовать « подкласс специфичного для теста »), тогда внезапно будет иметь значение внутреннее состояние класса - возможно, вы реорганизуете класс как черный ящик, но тесты белого ящика не пройдут. Предположим, что одно поле повторно используется для обозначения разных вещей (не очень хорошая практика!), Когда SUT изменяет состояние - если мы разделим его на два поля, нам может потребоваться переписать неработающие тесты.

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

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

Таким образом, чтобы предотвратить привязку тестов к внутренним деталям SUT, это может помочь:

  • Используйте заглушки, а не издевательства, где это возможно. Для получения дополнительной информации см. Блог Фабио Периера о тавтологических тестах и мой блог о тавтологических тестах .
  • При использовании макетов избегайте проверки порядка вызываемых методов, если это не важно.
  • Старайтесь избегать проверки внутреннего состояния вашей SUT - используйте его внешний API, если это возможно.
  • Старайтесь избегать специфичной для теста логики в производственном коде
  • Старайтесь избегать использования тестовых подклассов.

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

Отказ от ответственности: Чтобы обсудить рефакторинг здесь, я использую слово более широко, чтобы включить изменение внутренней реализации без каких-либо видимых внешних эффектов. Некоторые пуристы могут не соглашаться и ссылаться исключительно на книгу Мартина Фаулера и Кента Бека «Рефакторинг», в которой описываются операции атомного рефакторинга.

На практике мы склонны предпринимать несколько большие неразрывные шаги, чем описанные там атомарные операции, и, в частности, изменения, из-за которых производственный код ведет себя идентично извне, могут не оставить прохождение тестов. Но я думаю, что было бы справедливо включить «алгоритм замены для другого алгоритма, который имеет идентичное поведение» в качестве рефакторинга, и я думаю, что Фаулер с этим согласен. Сам Мартин Фаулер говорит, что рефакторинг может нарушить тесты:

Когда вы пишете пробный тест, вы проверяете исходящие вызовы SUT, чтобы убедиться, что он правильно общается со своими поставщиками. Классический тест заботится только о конечном состоянии, а не о том, как это состояние было получено. Тесты Mockist, таким образом, более связаны с реализацией метода. Изменение характера обращений к сотрудникам обычно приводит к срыву теста mockist.

[...]

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

Фаулер - издевательства не окурки

перфекционист
источник
Фаулер буквально написал книгу о рефакторинге; и самая авторитетная книга о модульном тестировании (xUnit Test Patterns by Gerard Meszaros) находится в серии «подписи» Фаулера, поэтому, когда он говорит, что рефакторинг может нарушить тест, он, вероятно, прав.
перфекционист
5

Если ваши тесты прерываются, когда вы выполняете рефакторинг, то вы, по определению, не выполняете рефакторинг, который «меняет структуру вашей программы без изменения поведения вашей программы».

Иногда вам действительно нужно изменить поведение ваших тестов. Возможно, вам нужно объединить два метода (скажем, bind () и listen () в классе прослушивающего сокета TCP), чтобы другие части вашего кода пытались и не смогли использовать теперь измененный API. Но это не рефакторинг!

Фрэнк Шиарар
источник
Что если он просто поменяет название метода, проверенного тестами? Тесты не пройдут, если вы не переименуете их и в тестах. Здесь он не меняет поведение программы.
Оскар Медерос
2
В этом случае его тесты также подвергаются рефакторингу. Вы должны быть осторожны: сначала вы переименовываете метод, затем запускаете тест. Он должен завершиться ошибкой по правильным причинам (он не может скомпилировать (C #), вы получите исключение MessageNotUnderstood (Smalltalk), похоже, ничего не происходит (шаблон Objective-C с нулевым потреблением)). Затем вы меняете свой тест, зная, что вы случайно не ввели какую-либо ошибку. Другими словами, «если ваши тесты прерываются» означает «если ваши тесты прерываются после того, как вы завершили рефакторинг». Старайтесь держать мелкие кусочки!
Фрэнк Шеарар
1
Модульные тесты неразрывно связаны со структурой кода. Например, у Fowler есть много ссылок на refactoring.com/catalog, которые могут повлиять на юнит-тесты (например, метод hide, встроенный метод, замена кода ошибки исключением и т. Д.).
Кристиан Х
ложный. Объединение двух методов, очевидно, является рефакторингом, который имеет официальные имена (например, рефакторинг встроенного метода соответствует определению), и он будет нарушать тесты встроенного метода - некоторые тестовые примеры теперь должны быть переписаны / протестированы другими средствами. Мне не нужно менять поведение программы, чтобы нарушать модульные тесты, все, что мне нужно сделать, - это реструктурировать внутренние компоненты, которые имеют модульные тесты в сочетании с ними. Пока поведение программы не изменилось, это все еще соответствует определению рефакторинга.
КолА
Я написал выше, предполагая, что хорошо написанные тесты: если вы тестируете свою реализацию - если структура теста отражает внутренние части тестируемого кода, конечно. В этом случае тестируйте контракт на единицу, а не на реализацию.
Фрэнк Ширар
4

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

>  Keep the API the same, but change how the API is implemented internally
>  Change the API

Как уже заметил другой человек, если вы сохраняете API-интерфейс одинаковым и все ваши регрессионные тесты работают с открытым API-интерфейсом, у вас не должно возникнуть проблем. Рефакторинг не должен вызывать никаких проблем. Любые неудачные тесты ДАЛИ означают, что в вашем старом коде была ошибка, а ваш тест плохой, или в вашем новом коде есть ошибка.

Но это довольно очевидно. Таким образом, вы, вероятно, подразумеваете под рефакторингом, что вы меняете API.

Итак, позвольте мне ответить, как подойти к этому!

  • Сначала создайте NEW API, который делает то, что вы хотите, чтобы ваше поведение NEW API было. Если случится так, что этот новый API будет иметь то же имя, что и OLDER API, тогда я добавлю имя _NEW к новому имени API.

    int DoSomethingInterestingAPI ();

будет выглядеть так:

int DoSomethingInterestingAPI_NEW( int takes_more_arguments );
int DoSomethingInterestingAPI_OLD();
int DoSomethingInterestingAPI() { DoSomethingInterestingAPI_NEW (whatever_default_mimics_the_old_API);

ОК - на данном этапе - все ваши регрессионные тесты пройдут - используя имя DoSomethingInterestingAPI ().

ДАЛЕЕ, просмотрите код и измените все вызовы DoSomethingInterestingAPI () на соответствующий вариант DoSomethingInterestingAPI_NEW (). Это включает в себя обновление / переписывание любых частей ваших регрессионных тестов, которые необходимо изменить для использования нового API.

СЛЕДУЮЩИЙ, отметьте DoSomethingInterestingAPI_OLD () как [[deprecated ()]]. Держите устаревший API столько, сколько хотите (пока вы не обновите весь код, который может от него зависеть).

При таком подходе любые сбои в ваших регрессионных тестах просто являются ошибками в этом регрессионном тесте или идентифицируют ошибки в вашем коде - именно так, как вы хотели бы. Этот поэтапный процесс пересмотра API путем явного создания версий API _NEW и _OLD позволяет вам иметь биты нового и старого кода, сосуществующие некоторое время.

Льюис Прингл
источник
Мне нравится этот ответ, потому что он делает очевидным, что модульные тесты для SUT такие же, как внешние клиенты для опубликованного Api. То, что вы прописываете, очень похоже на протокол SemVer для управления опубликованной библиотекой / компонентом, чтобы избежать «ада зависимостей». Это, однако, требует затрат времени и гибкости, экстраполяция этого подхода к общедоступному интерфейсу каждого микроустройства также означает экстраполяцию затрат. Более гибкий подход состоит в том, чтобы максимально разделить тесты от реализации, то есть интеграционное тестирование или отдельный DSL для описания входов и выходов
теста
1

Я предполагаю, что ваши модульные тесты имеют гранулярность, которую я бы назвал «глупой» :), т.е. они проверяют абсолютные детали каждого класса и функции. Отойдите от инструментов генератора кода и напишите тесты, которые применимы к большей поверхности, тогда вы можете реорганизовать внутренние компоненты столько, сколько захотите, зная, что интерфейсы к вашим приложениям не изменились, и ваши тесты по-прежнему работают.

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

gbjbaanb
источник
1
Самый полезный ответ, который на самом деле отвечает на этот вопрос - не создавайте тестовое покрытие на шаткой основе внутренних пустяков или не ожидайте, что он постоянно развалится, - но это самый отрицательный ответ, потому что TDD предписывает делать с точностью до наоборот. Это то, что вы получаете за указание на неудобную правду о чрезмерно раскрученном подходе.
КолА
1

синхронизация набора тестов с базой кода во время и после рефакторинга

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

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

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

Кола
источник
0

Ваши тесты слишком тесно связаны с реализацией, а не с требованием.

попробуйте написать свои тесты с комментариями, подобными этим:

//given something
...test code...
//and something else
...test code...
//when something happens
...test code...
//then the state should be...
...test code...

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

mcintyre321
источник