Найти тему
Deep Software Engeneering

Три техники тестирования унаследованного кода, которые мне помогали на этой неделе

Оглавление

На этой неделе пришлось править код машины состояний. Место крайне ответственное и сложное, но, как ни странно, человек, который писал это до меня, не оставил ни одного теста (признаться, я удивлен: он написал довольно много, и судя по коммитам, с первого раза без ошибок — ковбой!). Но все же мелкий баг он таки оставил. Так как эта библиотека используется почти всей компанией, мне было крайне страшно править это место без тестирования. В этой статье я хочу рассказать о трёх подходах, которые мне в этом помогали.

Hotpatch

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

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

Код, который мы хотим протестировать
Код, который мы хотим протестировать
Собственно наш тест
Собственно наш тест
И результат
И результат

Все хорошо, но нам очень надоедает этот "Hi from another thread".

Удалось написать вот такую функцию для решения этой проблемы. Ниже я объясню в чем её суть.

-4

Сразу надо оговориться, решение не самое изящное, но свое дело делает. Основная идея такая — почти любая функция (и 100% любая системная библиотечная функция) имеет адрес в памяти, потому что именно на этот адрес передаётся управление, когда эту функцию вызывают. А если есть адрес, то значит, туда можно и записать что-то. Например команду ассемблера, которая будет делать то, что нам надо. Вот такой вот план.

Для его реализации нужно перво-наперво надо знать, что операционная система выделяет память приложению "страницами", размер страницы как правило 4096 байт, но бывает и до гигабайтов. У страницы памяти есть атрибуты, например "только для чтения", "для записи" и "тут лежит код". Чтобы узнать идентификатор страницы, в которой расположен объект, нужно обнулить последние N бит в адресе этого объекта, причем N такое что 2^(N + 1) = PAGESIZE. Строки 19-20 вычисляют идентификатор страницы. Чтобы сделать это, необходимо узнать размер страницы в текущей операционной системе. Для этого проще всего скомпилировать программу с флагом -DPAGESIZE=$(getconf PAGESIZE) и считать переменную PAGESIZE в коде.

После того, как мы знаем идентификатор страницы, мы помечаем её как записываемую. По умолчанию, компилятор пометит её меткой "тут лежит код" (библиотеки). Делает он это в целях безопасности, чтобы по ошибке программа, выйдя за границы массива, не попыталась модифицировать код какой-то функции (это как раз то, что мы сейчас и делаем). Поэтому в строке 22 мы говорим операционной системе Linux, что записать что-то в эту страницу можно.

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

Разобраться с этим мне помогли две статьи (одна и другая), в общем экспериментальным путем я выяснил, что надо записать. Причем разные команды для 32 и 64 битных процессоров (для ARM будет что-то своё). В строках 24-26 и 28-31 я записываю ассемблерный код по адресу функции target. И затем в строке 35 снова на всякий случай устанавливаю защиту от записи на нашей странице.

В результате наш тест становится вот таким:

-5

И проходится без вывода на экран Hi from another thread!

Как в 32- так и в 64-битном режиме
Как в 32- так и в 64-битном режиме

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

Тестирование экстренного завершения

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

-7

Очевидно, что если у нас есть подобный метод, то вызвать его просто так нельзя. Что же делать? Я поступил вот так:

-8

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

-9

Тестирование static_assert'а

А вот с этим C++ принес неприятный сюрприз. Пусть у нас есть вот такой класс:

-10

И мы хотим проверить что не ошиблись в static_assert.

Мой первый подход был такой: сделать что-то глупое в enable_if, и пусть SFINAE меня защитит:

Неудачный подход с SFINAE
Неудачный подход с SFINAE

Оказалось, что компилятор не пытается "выполнить" код класса, поэтому всё компилируется успешно (а я ожидал ошибки компиляции типа check() is not defined).

Ладно, подумал я, второй подход:

И тут фиаско! На этот раз "слишком перестарался". В общем, ошибка в static_assert не считается ошибкой в SFINAE. Я сдался и полез искать по Интернету. Оказалось, что самый надежный тест на это — создать отдельный .cpp файл и попробовать скомпилировать его, и проверить что компиляция "падает".

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