целью использования метрики покрытия кода является
Code Coverage — хочу верить
Разработчик обязан знать свои инструменты! Знание инструментов увеличивает продуктивность, эффективность, производительность, потенцию разработчика! Не могу программировать без R#!
Подобного рода фразы можно услышать от абсолютно разных людей: фанатиков разработки, продавцов различных утилит, пользователей удобных тулз. Слышит их и мой менеджер, когда мне хочется поэкспериментировать с чем-то новеньким.
Правда, инструкция к инструменту обычно не содержит раздел «Противопоказания», не указываются ситуации когда НЕ стоит применять утилиту. Между тем, подобный раздел мог бы сэкономить тонны времени на неудачные эксперименты.
Сегодня я пошвыряю камни в огород Code Coverage (CC). Достаточно полезная метрика, под которой лежат несколько скудно документированных граблей.
«Есть ложь, есть наглая ложь
есть статистика».
Создатели SonarQube это великолепно понимают, недаром у SonarQube с десяток CC метрик. Я буду перебивать статистику используя CC от DevExpress, там метрика лишь одна.
Проблема 1. Типичный тест не того. Протестируем метод с кучей проверок аргумента:
Метод покрыт тестами на 83%, чего обычно достаточно для авто-билда. Технически спорить не о чем, большая часть кода покрыта тестами, но основной сценарий тестами не затронут. Тестами покрыта наиболее простая часть кода, не наиболее важная.
Проблема 2. Замеряем актуальный код, вместо необходимого.
Тестируемый метод не содержит проверки на null аргумент, однако покрытие — 100%. Иногда люди забывают: Code Coverage — это метрика покрытия кода, не метрика закрытия требований; если в методе не достает логики (метод недостаточно сложен для решения своей задачи) — CC это не покажет.
100% покрытия не гарантируют работоспособности программы. Доводя до абсурда: пустой метод элементарно покрывается на 100%. Непустой метод покрывается на 100% тестами без Assert-ов.
Проблема 3. Оптимизм. Немного иное проявление предыдущей проблемы. Как видно, один тест покрывает 100% кода. Попробуем переписать наш метод, избавившись от LINQ (для улучшения производительности).
Получаем лишь 73% покрытия. Функциональность не изменилась, метрика упала. Мало того, что 100% покрытия не гарантируют работоспособности программы, эти 100% могут быть фейковыми. Вывод: LINQ — г**но результаты CC могут быть завышены, старайтесь проверять покрытие в редакторе.
Следствие: Используемый инструмент может не обладать всей желаемой функциональностью. Тривиальная вещь, не менее от того верная.
Проблема 4. Передача ответственности.
Метод покрывается на 100% одним тестом. При этом покрытие OuterLib библиотеки лежит на совести того, кто её добавил. Или обновил. Года три назад, до введения CC. До увольнения.
Приходится снова констатировать факт: мало того, что 100% покрытия не гарантируют работоспособности программы, эти 100% могут быть фейковыми.
Помимо чисто кодовых моментов есть несколько претензий именно к обработке результатов CC
Претензия 0, всем известная. 100% покрытия. Нет 100% покрытия — нет одобрения билда. Проблема в том, что первые проценты покрытия получить относительно просто, а вот последние… Особенно, когда часть кода генерируется. Или недостижима (поскольку создана для Васи, который будет её юзать через два дня). Или просто теоретически достижима, а пример подбирать\высчитывать пару недель (такое бывает при работе с математикой). Короче, большинство команд (из тех кто вообще интегрирует CC в CI) останавливаются на 60\70\80 процентах необходимого покрытия.
Претензия 1, спорная. Покрытие мертвого кода. На моей памяти схожая проблема особо ярко проявилась в ходе проверки Mirand-ы коллегами из PVS. Комментарии довольно эмоциональны, но часть споров касалась мертвого кода: часть найденных диагностик указывала на (заброшенные) плагины, но не на ядро.
Возникает вопрос: нужен ли CodeCoverage для мертвого кода? С одной стороны, мертвый код это проблема, и привлечение внимания к нему приветствуется. С другой стороны, мертвый код не влияет на продакшн, так стоит ли позволять ему влиять на CC метрику?
Претензия 2. Важность кода. Расширение проблемы 1. В моем проекте есть два примечательных контроллера: «оплата» и «переговорка». «Оплата» критична для клиента, и я вполне согласен с требованием «80% покрытия», «переговоркой» же пользуются 1.5 анонимуса. В год. И она не менялась уже два года. Вопрос: для чего писать тесты к полумертвой функциональности? Лишь для получения 80% бейджа одобрения автосборки?
Претензия 3, невозможная. Метрика как ачивка. Это когда никто не проверяет что именно покрыто. Помните байки про оплату за линии кода? Мне доводилось слышать про людей, которые творили ненужный кода для лучшего покрытия.
Претензия 4. Метрика «за бесплатно». Когда руководство скидывает требование «покрывайте код на 80%», и разработчики безропотно соглашаются. Проект при этом — одноразовый. Или прототип. Или дедлайн на носу. Или имеется здоровенный макаронный легаси монстр без единого теста.
Покрытие кода тестами требует времени! Если покрытие еще и замерять — время на тесты может и возрасти (хотя может и упасть). Так что если команда не успела сдать проект в срок, но зато достигла 80% покрытия — вина может поделиться между руководством и разработчиками. Вопрос линии раздела вины поднимать не стоит, ибо холивар.
Под конец. Еще раз замечу: СС — метрика полезная, хоть и с сюрпризами. Она реально помогает с контролем кода, если нет слепого стремления к цифрам в отчетах.
Полное покрытие кода
Инструмент тестирования nose
Изначальный пример кода
#!/usr/bin/env python
import operator
Код работает только на Python 2.6 и не совместим с Python 3. Код сохранен в файле main.py.
Юнит-тесты
Начнем с простых тестов:
import unittest
from main import factorial
OK
Добавим еще один класс для стопроцентного покрытия:
class FakeStream :
def readline ( self ):
return ‘5’
Выводы
Адаптация под Python 3
#!/usr/bin/env python
import operator
Теперь программу можно запускать:
$ python3 main.py
Enter the positive number: 0
0! = 1
Значит ли это, что программа рабочая? Нет! Она рабочая только до вызова reduce, что нам и показывают тесты:
$ nosetests3
E. E
======================================================================
ERROR: test_calculation (tests.TestFactorial)
———————————————————————-
Traceback (most recent call last):
File «/home/nuald/workspace/factorial/tests.py», line 9, in test_calculation
self.assertEqual(720, factorial(6))
File «/home/nuald/workspace/factorial/main.py», line 12, in factorial
return reduce(operator.mul, range(1, n + 1))
NameError: global name ‘reduce’ is not defined
FAILED (errors=2)
В данном примере все это можно было обнаружить и ручным тестированием. Однако на больших проектах только юнит-тестирование поможет обнаружить такого рода ошибки. И только полное покрытие кода может гарантировать что практически все несоответствия кода и API были устранены.
Ну и собственно, рабочий код, полностью совместимый между Python 2.6 и Python 3:
#!/usr/bin/env python
import operator
from functools import reduce
import sys
import unittest
from main import factorial
class FakeStream :
def readline ( self ):
return ‘5’
Проблемы тестирования: почему 100% покрытие кода это плохо
Недавно в нашем блоге мы рассказывали об использовании предметно-ориентированных языков для решения конкретных задач разработки с помощью Python. Сегодня речь пойдет о тестировании — в частности, о том, почему стопроцентное покрытие тестами кода это на самом деле плохо.
Материал подготовлен на основе выступления разработчика Positive Technologies Ивана Цыганова на конференции Moscow Python Conf (слайды, видео).
Зачем мы пишем тесты
ИБ-эксперты Positive Technologies проводят более 200 аудитов информационной безопасности в год, но мы прежде всего продуктовая компания. Один из наших продуктов — система контроля защищенности и соответствия стандартам MaxPatrol.
Продукт состоит из трех больших подсистем:
Нужно ли 100% покрытие
Здесь есть интересный момент — многие специалисты считают, что проверка покрытия тестами говорит о качестве тестирования. На самом деле это совершенно не так. Да, это хорошая ачивка («у нас 100% coverage!»), но это не означает того, что проект полностью протестирован. Стопроцентное покрытие говорит лишь о стопроцентном покрытии кода тестами, и ни о чем больше.
Для Python де-факто стандартом проверки покрытия является библиотека coverage.py. Она позволяет проверить покрытие кода тестами, у нее есть плагин для pytest. В основном, библиотека работает, но не всегда.
Пример — код ниже покрыт тестами на 100%. И в этом примере претензий к работе coverage.py нет.
Но на более сложной функции один тест дает 100% покрытие, при этом функция остается не протестированной. Мы не проверяем ситуацию, когда единственный ‘if’ функции обернется в False.
У библиотеки есть еще один режим работы, который позволяет отслеживать покрытие ветвей исполнения кода. Если запустить проверку в этом режиме, то будет видно, что не покрыт переход из третей в пятую строку кода. Это означает, что на всех запусках тестов мы никогда не попадали из третьей строки сразу в пятую, а всегда попадали в четвертую, то есть “if” на всех тестовых данных оборачивался в True.
Как считается покрытие
Существует простая формула для расчета покрытия кода тестами:
Coverage.py работает по такой схеме — сначала библиотека берет все исходники и прогоняет через собственный анализатор для получения списка инструкций. Этот анализатор обходит все токены и отмечает «интересные» с его точки зрения факты, затем компилирует код, обходит получившийся code-object и сохраняет номера строк. При обходе токенов он запоминает определения классов, «сворачивает» многострочные выражения и исключает комментарии.
Переходы между строками считаются примерно так же:
Опять берется исходный код и анализируется классом AstArcAnalyzer для получения пары значений — из какой строки в какую возможен переход. AstArcAnalyzer обходит AST-дерево исходников с корневой ноды, при этом каждый тип нод отрабатывается отдельно.
Далее нужно каким-то образом получить информацию о реально выполненных строках — для этого в coverage.py используется функция settrace. Она позволяет нам установить свою функцию трассировки, которая будет вызываться при наступлении некоторых событий.
Например, при наступлении события “call” мы понимаем, что была вызвана функция или мы вошли в генератор… В этом случае библиотека сохраняет данные предыдущего контекста, начинает собирать данные нового контекста, учитывая особенности генераторов. Еще одно интересующее нас событие — событие “line”. В этом случае запоминается выполняемая строка и переход между строками. Событие return отмечает выход из контекста — тут важно помнить, что yield также вызывает наступление события “return”.
После этого строится отчет. К этому моменту у нас есть данные о том, что выполнялось, а также что должно было выполняться — по этим данным можно сделать выводы о покрытии кода тестами.
Все эти сложности с обходом байткода, AST-деревьев позволяют проверить покрытие очень сложного кода и получить корректный отчет. Казалось бы, вот она серебряная пуля, все просто отлично. Но на самом деле все не так хорошо.
Что может пойти не так
Рассмотрим простой пример — вызов некоторой функции с условием при передаче параметров.
Оператор if будет покрыт всегда. И мы никогда не узнаем, что это условие всегда оборачивалось в false.
Проблема возникнет и при использовании lambda — внутрь этой функции coverage.py не заглядывает и не скажет нам о том, что внутри что-то не покрыто. Не сможет библиотека разобраться и с list, dict, set-comprehensions.
Все эти случаи имеют кое-что общее. Как мы выяснили выше, coverage.py использует парсер и получает список инструкций. В итоге результатом работы библиотеки является покрытие инструкций, а не строк кода.
Делаем мир лучше
Возьмем простой пример непокрываемого кода:
Допустим, мы хотим покрыть его и знать, когда не срабатывало условие “or c”. Ни один режим coverage.py не позволит этого сделать. Что можно попробовать сделать в этом случае?
Можно установить собственную функцию трассировки, посмотреть на результат ее работы и сделать выводы. То есть, фактически, повторить то, что делает coverage.py. Этот вариант не подходит, поскольку мы имеем ограниченное количество событий: call, line, return, exception. Маленькие частички оператора if мы никогда не увидим.
Другой вариант — использовать модуль ast.NodeTransformer. С его помощью мы можем обойти дерево, обернуть в «нечто» каждую ноду, запустить и посмотреть, что выполнялось. Проблема здесь в том, что на уровне AST очень сложно обернуть ноду в “нечто”, не изменив при этом логику исполнения. Да и в целом, далеко не все ноды можно обернуть. Этот метод тоже подходит.
Но можно использовать и другой подход. Что если, во время импорта перехватить контроль, обойти байткод импортируемого модуля, добавить внутрь байткода вызов своей функции трассировки, собрать code-object и посмотрим, что получилось. Именно эта идея реализована в прототипе библиотеки OpTrace.
Как работает OpTrace
Прежде всего нужно установить Import.Hook— здесь все довольно просто. В нем есть Finder, который пропускает неинтересные нам модули, создав для нужных Loader. В свою очередь, этот класс получает байт-код модуля, строки его исходного кода, модифицирует байт-код и возвращает измененный байткод в качестве импортируемого модуля.
Работает все это так. Создается wrapper, внутри которого «пробрасываются» две функции — первая нужна для того, чтобы отметить опкод, как уже посещенный (visitor). Задача второй — просто отметить, что такой опкод существует в исходнике (marker).
В Python есть ряд инструментов для работы с байткодом. Прежде всего, это модуль dis и его одноименный метод позволяет увидеть байткод в красивом виде.
Подобное представление удобно просматривать, но не обрабатывать. Существует и другой метод — get_instructions. Он принимает на вход code-object и возвращает список инструкций.
На этом методе и строится работы прототипа библиотеки. С помощью этого метода обходится весь байткод. Чтобы отметить существование опкода вызывается проброшенная ранее функция marker.
С трассировкой дело обстоит несколько сложнее. Нельзя просто так взять и поместить в байткод вызов каких-то нужных нам методов. У CodeObject есть атрибут consts — это доступные внутри него константы. В них можно поместить lambda-функцию и “замкнуть” в нее текущую инструкцию в качестве параметра по-умолчанию. Таким образом, вызвав эту лямбду из констант без параметров, мы сможем трассировать выполнение конкретных опкодов. Далее нужно лишь сгенерировать код для вызова константы.
Важно не забыть про оригинальный опкод — нужно его тоже добавить — и его параметры, при этом необходимо учитывать смещение в последующих опкодах. После оборачивания байткода он будет выглядеть примерно так:
Болдом на скриншоте подсвечен оригинальный трассируемый байткод. После модификации байткода необходимо запустить тесты. Так мы выясним, какая часть кода выполнялась, а какая нет. Возникает вопрос, а что делать с непокрытыми опкодами? В проекте на 50 000 строк их перечисление может занять несколько страниц.
На самом деле способа однозначно перевести любой опкод к строке кода не существует, но можно попытаться его найти. У некоторых опкодов есть информация о строке, в которой они находятся. Значит при обходе мы можем сохранять текущую строку — до момента пока не встретим упоминания другой строки будем считать, что строка не менялась. Именно эта информация будет включаться в отчет. Теперь он выглядит гораздо лучше, уже понятно что и где произошло.
Допустим, что строки у нас всегда вычисляются корректно. Теперь можно попробовать вычислить позицию в строке для каждого пропущенного опкода. Рассмотрим несложный пример с опкодом LOAD_FAST. Его параметры говорят о том, что мы имеем дело с загрузкой некоей переменной. Мы можем попробовать в известной нам строке найти ее имя.
Покрыв примерно 70 типов опкодов удалось получить вменяемый отчет. Но многие опкоды покрыть невозможно. Новый отчет выглядит так:
Удивительно, но это работает. Например, мы четко видим, что не сработал LOAD_FAST для переменной c.
OpTrace: минусы и плюсы
При работе с прототипом имеется ряд проблем.
Заключение
Одной из целей этого исследования и разработки была демонстрация факта того, что не существует идеальных библиотек. Coverage.py хорош, но не идеален — слепо верить его отчетам нельзя. Поэтому необходимо всегда разбираться с тем, как работает библиотека и изучать как она работает “изнутри”.
Еще один ключевой тезис — coverage в 100% расслабляет команду. Раз результатам работы библиотек нельзя полностью доверять, то полное покрытие — это просто ачивка, за которой могут скрываться реальные проблемы.
О метриках тестирования: code coverage для тестировщиков
Как известно из книги «Путеводитель для путешествующих автостопом по галактике», ответ на главный вопрос жизни, вселенной и всего такого — 42. Процент покрытия кода по линиям на одном из моих проектов — 81, дает ли эта цифра ответ на главный вопрос тестирования «cколько тестов достаточно для определения качества продукта»?
В течении своей работы в айти-сфере и тестировании я видела мало команд и проектов, где тестировщики реально используют code coverage в своей работе. Связано это на мой взгляд с двумя вещами:
1. Тем, что тестируем мы прежде всего требования;
2. Далеко не все понимают, как считать и использовать покрытие.
Интересующимся предлагаю свой взгляд на эти 2 пункта.
Требования vs код
Тестировщик тестирует требования. Даже если их формально нет, есть представление о том, как должна вести себя система. Это и только это важно в конечном итоге.
Но.
Не бывает четких исчерпывающих полных требований, проверив каждое из которых, смело можно сказать, что система будет работать как надо и багов нет.
Пример 1
Приложение пытается сохранить данные в БД (располагается на другом сервере). Есть описание того, как оно должно это делать, в том числе звучит требование, что в случае невозможности выполнить операцию (нет доступа к БД, например), мы должы пытаться это сделать до истечения определенного таймаута, потом выдавать клиенту ошибку.
Что значит невозможно выполнить операцию?
Предположим, тестировщик проверяет сценарий с потерей соединения к БД в процессе работы. Все работает хорошо, но значит ли, что багов нет?
В упомянутом приложении мы посмотрели покрытие кода соответствующих классов — оказалось, что разработчик предусмотрел в коде обработку около 5 исключительных ситуаций.
Это значило, как минимум, следующие случаи:
1. Соединение с сервером БД не может быть установлено;
2. Соединение с сервером БД установлено, выполнение запроса вызвало оракловую ошибку;
3. Соединение с сервером БД было установлено, запрос начал выполняться и завис — тут был баг. Приложение ждало ответа примерно минут 5, потом в логи летел эксепшн и больше оно эти данные записать не пыталось.
Пара остальных не стоило внимания по разным причинам.
В примере требования формально проверено было и 1-м кейсом, но баг был найден после анализа покрытия кода. Можно поспорить, что это пример не о пользе code coverage, а о пользе взаимодействия внутри команды (у разработчика детали имплементации можно было бы узнать заранее или дать ему кейсы на ревью), на самом деле я всегда так делаю но не о всем догадаешься спросить, часто внимание к каким-то вещам привлекают непокрытые блоки кода.
Пример 2
В другой системе, которуя я тестировала, при потере консистентности данных приложение должно было выкидывать соответствующий эксепшн, бросать нотификацию мониторингу и ждать, когда придут люди и спасут его. Тесты покрывали разные случаи возникновения таких ситуаций, все обрабатывалось нормально.
Мы посмотрели код, нужный кусок был покрыт хорошо, но я увидела в другом классе непокрытую область кода, в которой бросался тот же самый event о потери консистентности. При каких условиях — неизвестно, т.к. разработчики его быстро выпилили. Оказалось он был скопипасчен из старого проекта, но никто об этом не помнил. Где это могло стрельнуть- неизвестно, но без анализа кода мы бы это не нашли.
Поэтому пусть тестировщик тестирует требования, но если он смотрит еще и код, может поймать то, что в требованиях не описано и хитрые методы тест-дизайна тоже не всегда найдут.
Покрытие = 80. А качество?
Количество не означает качество. Оценка покрытия кода напрямую не связана с качеством продукта, но связана опосредованно.
На одном отчетном совещании я заявила, что покрытие кода у нас увеличилось до 82% по линиям и 51% по условиям, после чего руководством мне был задан вопрос: «А что это значит? Это хорошо или плохо?» Закономерный вопрос, действительно: сколько надо, чтобы было хорошо?
Некоторые разработчики покрывают свой код, добиваясь 100%. Тестировщику 100% добиваться бессмысленно, начиная с какого-то моменты вы столкнетесь с тем, что физически не можете затронуть этот код интеграционными тестами.
Например, разработчики считают хорошим тоном проверять входящие параметры метода на null, хотя в реально работающей системе таких случаев может и не быть (50% по условиям у нас тогда складывалось в том числе из-за этого). Это нормально, передать туда null извне можно было только до первой проверки, которая собственно эту ситуацию и обработает.
К вопросу об «это нормально»: качественная оценка непокрытого кода и ведет в моем понимании к адекватному использованию code coverege. Смотреть важно то, что вы не покрыли, а не сколько. Если это java-код и методы toString(), equals() или ветви с exception, которые сложно воспроизвести интеграционно, ну так и ладно, пусть будет 80% покрытия реальной бизнес-логики. «Лишний» код многие инструменты умеют фильтровать и не считать.
Если сомнения в белых пятнах все-таки остаются, возможно посчитать общее покрытие интеграционными тестами и юнит — разработчики наверняка учли многое что труднодоступно для интеграционных тестов.
Однако есть одно «но». Что, если покрытие кода низкое? 20%, 30%? Где-то я читала забавный факт, что покрытие 50% и меньше (по линиям и условиям, как мне помнится) означает тот уровень тестового покрытия, при котором результат работы приложения будет такой же, как и при отсутствии тестирования вообще. Т.е. там могут быть баги, может не быть багов, с тем же успехом вы могли его и не тестировать. Другое объяснение — много мертвого кода, что маловероятно.
А у нас нет автотестов
А они и не нужны. Даже если вас уверяют в обратном, некоторые разработчики не в курсе, что покрытие можно считать не только для юнит тестов. Есть инструменты, которые пишут покрытие в рантайме, т.е. ставите специально обученный инструментированный билд, проходите на нем тесты, а он пишет покрытие.
А смысл?
Моя знакомая прекрасная тест-лид задала вопрос: «когда тест-кейсы есть не все, и автоматизация в зачаточном состоянии, имеет ли смысл тратить ресурсы на оценку покрытия кода?» Внедрение новых штук в процесс всегда вызывает у менеджмента определенную боль: время, ресурсы и прочие бренности существования, никакого простора для полета тестировщика-мечтателя.
Разберем по порядку, куда конкретно нужно будет потратить ресурсы, если вы решите попробовать считать code coverage:
Пункты 1 и 2 можно отдать разработчикам, могие из них знакомы-слышали-встречались с общеизвестными тулами и тем более смогут построить собственный билд. Построение отчетов, как правило, делается одной командой в командной строке или автоматически, если вы используете CI (у меня это делал jenkins, он же публиковал отчет).
Самое затратное — это четвертый пункт. Основная трудность тут в том, что для адекватной оценки надо уметь читать код, либо садиться рядом с разработчиком, чтобы он объяснял, что значит этот кусок, и как это воспроизвести. Это требует определенной квалификации от тест-инженера и рабочего времени 1 или 2 человек.
Стоит ли оно того — решать команде и ее руководителям. В проектах, где требования слабо формализованы, либо баги возникают необъяснимым для тестеров образом, возможно это может помочь хотя бы понять направление куда копать.
Еще одна категория — проекты, которые предполагают очень hight-level black box тестирование. Это прежде всего тестирование через UI или внешний API систем, внутри которых содержится куча логики, работающей по своим законам, т.е. извне вы не можете ее затронуть или ей управлять, а значит не можете нормально протестировать. Анализ покрытия в таких проектах создаст аргументированную необходимость переходить к более «низким» уровням тестирования: модульным, покомпонентным, тестированию на заглушках и т.п.
Хорошо работает накопленное покрытие кода в цифрах: на графиках можно увидеть моменты, когда вливается новый код, а тесты еще не подоспели; если уровень покрытия был высоким, потом стал снижаться, но предыдущего уровня так и не достиг — где-то может быть хорошее белое пятно недошедших до тестирования требований, и т.д.
Пожалуй, это все, что я хотела сказать на сегодня.