Пишите тесты, которые имеют значение: сначала займитесь самым сложным кодом

Опубликовано: 2022-03-11

На тему качества кода ведется множество дискуссий, статей и блогов. Люди говорят - используйте методы Test Driven! Тесты обязательны для начала любого рефакторинга! Это все круто, но на дворе 2016 год, и до сих пор в производстве находится огромное количество продуктов и кодовых баз, созданных десять, пятнадцать или даже двадцать лет назад. Не секрет, что многие из них имеют устаревший код с низким покрытием тестами.

Хотя хотелось бы всегда быть в авангарде или даже на острие технологического мира - заниматься новыми крутыми проектами и технологиями - к сожалению, это не всегда возможно и часто приходится иметь дело со старыми системами. Я люблю говорить, что когда ты развиваешься с нуля, ты выступаешь в роли творца, осваивая новую материю. Но когда работаешь над легаси-кодом, ты больше похож на хирурга — знаешь, как работает система в целом, но никогда не знаешь наверняка, переживет ли пациент твою «операцию». А поскольку это устаревший код, вам не на что опереться на современные тесты. Это означает, что очень часто одним из самых первых шагов является покрытие его тестами. Точнее, не просто обеспечить покрытие, а разработать стратегию тестового покрытия.

Связывание и цикломатическая сложность: метрики для более разумного покрытия тестами

Забудьте о 100% охвате. Тестируйте умнее, определяя классы, которые с большей вероятностью сломаются.
Твитнуть

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

Основная идея здесь состоит в том, чтобы проанализировать две метрики — сцепление (т. е. афферентное сцепление, или СА) и сложность (т. е. цикломатическая сложность).

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

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

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

Однако есть одно предостережение: допустим, у нас есть два класса — один с СА 100 и сложностью 2, а другой с СА 60 и сложностью 20. Несмотря на то, что сумма метрик выше для первого, мы обязательно должны покрыть второй первый. Это связано с тем, что первый класс используется многими другими классами, но не очень сложен. С другой стороны, второй класс также используется многими другими классами, но он относительно сложнее, чем первый класс.

Подводя итог: нам нужно определить классы с высокой СА и цикломатической сложностью. С математической точки зрения необходима фитнес-функция, которую можно использовать в качестве рейтинга — f(CA,Complexity) — значения которой увеличиваются вместе с CA и сложностью.

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

Поиск инструментов для расчета CA и сложности для всей кодовой базы и предоставления простого способа извлечения этой информации в формате CSV оказался сложной задачей. Во время поиска я наткнулся на два бесплатных инструмента, поэтому было бы несправедливо не упомянуть о них:

  • Показатели сцепления: www.spinellis.gr/sw/ckjm/
  • Сложность: cyvis.sourceforge.net/

Немного математики

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

Нам нужно найти точку на так называемом фронте Парето (красная на картинке выше). Что интересно в множестве Парето, так это то, что каждая точка множества является решением задачи оптимизации. Всякий раз, когда мы движемся вдоль красной линии, нам нужно идти на компромисс между нашими критериями — если один становится лучше, другой становится хуже. Это называется скаляризацией, и конечный результат зависит от того, как мы это делаем.

Есть много методов, которые мы можем использовать здесь. У каждого есть свои плюсы и минусы. Однако наиболее популярными являются линейное масштабирование и основанное на опорной точке. Линейный - самый простой. Наша фитнес-функция будет выглядеть как линейная комбинация CA и Сложности:

f(CA, сложность) = A×CA + B×сложность

где A и B — некоторые коэффициенты.

Точка, представляющая решение нашей задачи оптимизации, будет лежать на линии (синяя на рисунке ниже). Точнее, она будет на пересечении синей линии и красного фронта Парето. Наша исходная проблема не совсем проблема оптимизации. Скорее, нам нужно создать функцию ранжирования. Давайте рассмотрим два значения нашей функции ранжирования, в основном два значения в нашем столбце Rank:

R1 = A∗CA + B∗Сложность и R2 = A∗CA + B∗Сложность

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

К сожалению, есть проблема с этим подходом. Для любой линии (значения ранга) на ней будут лежать точки с очень маленьким КА и очень большой Сложностью (и наоборот). Это сразу помещает точки с большой разницей между значениями метрик в начало списка, чего мы и хотели избежать.

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

(макс.(CA), макс.(сложность))

Фитнес-функцией будет расстояние между контрольной точкой и точками данных:

f(CA,Сложность) = √((CA-CA ) 2 + (Сложность-Сложность) 2 )

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

Этот подход лучше работает с экстремальными значениями, но есть еще две проблемы: во-первых, я хотел бы иметь больше точек рядом с контрольными точками, чтобы лучше преодолеть проблему, с которой мы столкнулись при линейной комбинации. Во-вторых, CA и цикломатическая сложность по своей сути разные и имеют разные значения, поэтому нам нужно их нормализовать (например, чтобы все значения обеих метрик были от 1 до 100).

Вот небольшой трюк, который мы можем применить для решения первой проблемы — вместо того, чтобы смотреть на CA и Cyclomatic Complexity, мы можем посмотреть на их инвертированные значения. Контрольной точкой в ​​этом случае будет (0,0). Чтобы решить вторую проблему, мы можем просто нормализовать метрики, используя минимальное значение. Вот как это выглядит:

Инвертированная и нормализованная сложность — NormComplexity:

(1 + мин(Сложность)) / (1 + Сложность)∗100

Инвертированная и нормализованная КА – NormCA:

(1 + мин(КА)) / (1+КА)∗100

Примечание: я добавил 1, чтобы убедиться, что нет деления на 0.

На следующем рисунке показан график с инвертированными значениями:

Окончательный рейтинг

Теперь мы подходим к последнему шагу — вычислению ранга. Как уже упоминалось, я использую метод опорной точки, поэтому единственное, что нам нужно сделать, это вычислить длину вектора, нормализовать его и сделать его возрастающим с учетом важности создания модульного теста для класса. Вот окончательная формула:

Ранг (НормаСложности , НормаКА) = 100 - √(НормаСложность 2 + НормаСложность 2 ) / √2

Больше статистики

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

Что интересно на этой картинке, так это количество классов с низким CA (0-2). Классы с CA 0 либо вообще не используются, либо являются услугами верхнего уровня. Они представляют конечные точки API, поэтому хорошо, что у нас их много. Но классы с CA 1 — это те, которые напрямую используются конечными точками, и у нас больше таких классов, чем конечных точек. Что это означает с точки зрения архитектуры/дизайна?

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

Дополнительная полезная информация, которую мы можем получить из приведенной выше гистограммы, заключается в том, что мы можем полностью отфильтровать классы с низкой связанностью (CA в {0,1}) из списка классов, подходящих для покрытия модульными тестами. Однако те же классы являются хорошими кандидатами на интеграционные/функциональные тесты.

Вы можете найти все скрипты и ресурсы, которые я использовал, в этом репозитории GitHub: ashalitkin/code-base-stats.

Всегда ли это работает?

Не обязательно. Прежде всего, речь идет о статическом анализе, а не о времени выполнения. Если класс связан со многими другими классами, это может быть признаком его интенсивного использования, но это не всегда так. Например, мы не знаем, действительно ли эта функциональность активно используется конечными пользователями. Во-вторых, если дизайн и качество системы достаточно хороши, то, скорее всего, разные ее части/слои развязаны через интерфейсы, поэтому статический анализ ЦА не даст нам истинной картины. Думаю, это одна из основных причин, по которой CA не так популярен в таких инструментах, как Sonar. К счастью, нас это вполне устраивает, поскольку, если вы помните, мы заинтересованы в применении этого именно к старым уродливым базам кода.

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

По теме: Принцип единой ответственности: рецепт отличного кода