Unit testing for legacy code

Закончился семинар Крейга Лармана. Как и ожидалось, вчера был самый интересный и наиболее полезный день - Ларман показывал, как можно начать применять юнит тесты для нашего кода.



Конечно же, все принесли самые труднотестируемые проекты - работу с GUI, HTTP клиента и даже сканер сетевого трафика на вирусы.
Каждый из этих проектов просто погряз в зависимостях - они подключают десятки разных lib и dll, посылают сообщения в сеть или другим компонентам, отображают что-то в GUI и т.д. На первый взгляд такой код невозможно протестировать юнит тестами.
Однако есть простой набор правил, который позволяет это сделать. Основная задача вначале тестирования любого модуля - это разрыв зависимостей между этим модулем и остальными, которые создают проблемы. Многие библиотеки не создают проблем тестируемому коду - их не надо убирать. Обычно проблемы для теста создают функции и классы, которые работают с чем-то внешним - с сетью, с интерфейсом, с диском и т.п. В юнит тесте у нас нет возможности использвать диск или сеть - мы должны убрать все такие зависимости.
Вся работа по разрыву зависимостей сводится к тому, чтобы создать заглушки для всех классов и функций, которые вам мешают. Самый простой способ - использовать виртуальное наследование для этого и перегружать функции.
Но разорвать зависимость между классами A и B невозможно, если A сам создает ClassB. Например:
class A
{
ClassB mB;
public:
A() {}
void foo()
{

mB.connectToServer();

}
};
Такую зависимость можно разбить минимум 3-мя способами: виртуальным замещением, используя линкер и используя переменные компилятора (#define ClassBTestable ClassB - это самый последний вариант, если уже ничто не помогает).
Для виртуального замещения надо немного изменить класс А, чтобы была возможность в тесте легко использовать ClassBTestable вместо ClassB (предположим, что мы собираемся тестировать класс А):
class A
{
ClassB* mB;
public:
A(ClassB* b) : mB(b) {}
void foo()
{

mB->connectToServer();

}
};
Теперь нам ничто не мешает при создании класса A передать ему ClassBTestable, в котором перегрузить connectToServer(). Мы разорвали зависимость между A и ClassB. Теперь мы можем порождать новые классы от ClassB для тестов разной функциональности класса A и симулировать в них нужное нам поведение connectToServer и других функций без реальной работы с сетью.
Если класс A должен создавать и удалять ClassB, то можно использовать такой конструктор:
A() : {mB = createClassB();}
В этом случае мы можем управлять тем, какой класс создавать внутри функции createClassB(), которую можно легко поменять.
Если используется init() функция, а не создается все прямо в конструкторе, то еще проще - можно применить тот же метод createClassB(), но сделать его виртуальным в классе A, тогда в тесте можно пронаследоваться от A, перегрузить эту одну функцию и создать в ней нужный нам ClassBTestable.
Главное, что нужно понять - внутри класса или функции не должно быть прямого создания объектов, которые мешают (через new, например). Должна быть возможность легко подменять мешающие объекты во время теста. И это несложно сделать.
Второй способ разрыва зависимостей - использование линкера. Он не такой удобный, но тоже применим. Например, если ClassB реализован где-то в другой библиотеке или в другом cpp файле, то мы можем скопировать всю его реализацию в новый cpp файл и оставить её пустой. Грубо говоря, мы создадим lib файл, который будет содержать этот же ClassB, но с пустыми функциями - заглушками. И прилинкуем его, а не оригинальную библиотеку. Всё, зависимость разорвана. Дальше мы в этой новой либе можем использовать илиому pImpl для того, чтобы можно было создавать в тестах реализацию этого класса (потому как от пустого класса толку мало).
После того, как все связи с внешним миром у класса или функции оборваны - мы ее полностью контролируем и можем полностью проверить ее работу. Для этого может понадобиться создать еще несколько наследников от классов A и ClassB, которые будут выдавать нужные данные для конкретных тестов. В итоге получается, что надо написать достаточно много дополнительного кода только для того, чтобы написать первый тест. Для второго уже меньше. Десятый тест пишется уже без всякого напряжения и просто использет то, что уже написано.
Это самый тяжелый момент в написании тестов для новой функции - первый тест. Он может потребовать в десятки раз больше работы, чем остальные 50 тестов и именно на этом этапе многие останавливаются и бросают писать тесты - им кажется, что слишком много "ненужного" кода.
Но надо всегда думать о финальной цели - получить тесты, которым можно доверять. Если функция покрыта тестами, то вы не будете бояться ее изменить и даже полностью переписать - тесты докажут ее работоспособность.
И самое главное - нельзя писать юнит тесты ради юнит тестов. Их надо писать только в том случае, если вы собираетесь изменить функцию. Вы во-первых поймёте полностью, как она работает, и во-вторых, у вас появится уверенность, что от ваших изменений ничего не испортилось.
А если следовать этому правилу всегда и всегда добавлять тест при изменении функций, то уже через несколько месяцев у вас будут сотни и тысячи тестов, лучшее понимание того, как всё работает и большая уверенность в качестве кода.
Про тестирование старого кода можно написать еще очень много, но для начала хватит. Если кому интересно почитать про это - Ларман советовал книгу Working Effectively with Legacy Code (Robert C. Martin Series).
К сожалению не знаю русских книг или сайтов про это - если знаете, поделитесь в комментариях ссылками.

 Понравилась статья? Подпишись на RSS!

Ответить

 

 

 

Вы можете использовать эти HTML тэги

<a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <strike> <strong>