Что такое покрытие путей?
Покрытие путей требует, чтобы каждый уникальный путь выполнения через программу или функцию был выполнен хотя бы раз. Путь — это полная последовательность операторов от входа до выхода.
Рассмотрим функцию с двумя последовательными операторами if:
def process_order(amount, is_member):
discount = 0
if amount > 100: # Решение 1
discount = 10
if is_member: # Решение 2
discount += 5
return amount - discount
Покрытие операторов требует тестов, выполняющих каждую строку — 2 теста могут быть достаточны.
Покрытие решений требует True и False для каждого решения — 2 теста достаточны.
Покрытие путей требует каждой комбинации путей через все решения:
| Путь | Решение 1 | Решение 2 | Тест-кейс |
|---|---|---|---|
| 1 | True | True | amount=200, is_member=True |
| 2 | True | False | amount=200, is_member=False |
| 3 | False | True | amount=50, is_member=True |
| 4 | False | False | amount=50, is_member=False |
С 2 последовательными решениями у нас 4 пути. С N последовательными решениями — 2^N путей. Этот экспоненциальный рост — причина, по которой полное покрытие путей часто непрактично.
Проблема взрыва путей
Добавьте цикл — и пути становятся бесконечными:
def retry_request(url, max_retries=3):
for attempt in range(max_retries):
response = send_request(url)
if response.status == 200:
return response
raise TimeoutError("Max retries exceeded")
Цикл может выполниться 0, 1, 2 или 3 раза. Каждая итерация добавляет точку принятия решения. Для цикла до N итераций с M решениями внутри, общее число путей может достигать M^N.
Поэтому практики используют basis path testing вместо исчерпывающего покрытия путей.
Графы потока управления
Перед подсчётом путей представьте код как граф потока управления (CFG):
- Узлы представляют операторы или блоки последовательных операторов
- Рёбра представляют поток управления между узлами
- Узлы решений имеют два или более исходящих ребра
Цикломатическая сложность
Цикломатическая сложность (предложена Томасом Маккейбом в 1976 году) измеряет число линейно независимых путей через программу. Она даёт минимальное число тест-кейсов для покрытия базисных путей.
Три эквивалентные формулы:
- V(G) = E - N + 2 (E = рёбра, N = узлы)
- V(G) = P + 1 (P = число точек принятия решений/предикатов)
- V(G) = R (R = число регионов в планарном графе, включая внешний)
Практические рекомендации по цикломатической сложности
| Сложность | Уровень риска | Рекомендация |
|---|---|---|
| 1-10 | Низкий | Просто, низкий риск — легко тестировать |
| 11-20 | Умеренный | Сложнее — нужно тщательное тестирование |
| 21-50 | Высокий | Сложно — подумайте о рефакторинге |
| 50+ | Очень высокий | Нетестируемо — необходим рефакторинг |
Большинство стандартов рекомендуют держать цикломатическую сложность ниже 10 на функцию.
Метод Basis Path Testing
Basis path testing Тома Маккейба предоставляет системный способ вывода тест-кейсов:
Шаг 1: Нарисуйте граф потока управления.
Шаг 2: Вычислите цикломатическую сложность V(G).
Шаг 3: Определите базисный набор независимых путей. Начните с простейшего пути, затем меняйте по одному решению за раз.
Шаг 4: Создайте тест-кейсы для каждого базисного пути.
Пример: Валидация логина
def validate_login(username, password, is_locked):
if is_locked: # D1
return "ACCOUNT_LOCKED"
if not username: # D2
return "USERNAME_REQUIRED"
if len(password) < 8: # D3
return "PASSWORD_TOO_SHORT"
return "VALID"
V(G) = 3 + 1 = 4 (три решения плюс один).
Базисные пути:
- D1=True → “ACCOUNT_LOCKED”
- D1=False, D2=True → “USERNAME_REQUIRED”
- D1=False, D2=False, D3=True → “PASSWORD_TOO_SHORT”
- D1=False, D2=False, D3=False → “VALID”
Тест-кейсы:
| # | username | password | is_locked | Ожидаемый |
|---|---|---|---|---|
| 1 | “user” | “pass123” | True | ACCOUNT_LOCKED |
| 2 | "" | “pass123” | False | USERNAME_REQUIRED |
| 3 | “user” | “short” | False | PASSWORD_TOO_SHORT |
| 4 | “user” | “longpassword” | False | VALID |
Четыре тест-кейса обеспечивают покрытие базисных путей.
Инструменты для анализа путей
- SonarQube — отчёты о цикломатической сложности по функциям и файлам
- Lizard — быстрый анализатор сложности, поддерживающий 20+ языков
- radon — анализ сложности для Python
- ESLint (правило complexity) — ограничение цикломатической сложности для JavaScript/TypeScript
- PMD — анализ сложности для Java/Apex
Упражнение: Basis Path Testing
Задача 1
Выведите базисные пути и тест-кейсы для функции:
def calculate_shipping(weight, distance, is_express):
base_cost = 5.0
if weight > 10:
base_cost += weight * 0.5
else:
base_cost += weight * 0.3
if distance > 500:
base_cost *= 1.5
if is_express:
base_cost *= 2
return round(base_cost, 2)
Решение
Цикломатическая сложность: 3 решения → V(G) = 3 + 1 = 4
Базисные пути:
- weight>10=T, distance>500=T, is_express=T — все True
- weight>10=F, distance>500=T, is_express=T — варьируем D1
- weight>10=T, distance>500=F, is_express=T — варьируем D2
- weight>10=T, distance>500=T, is_express=F — варьируем D3
Тест-кейсы:
| # | weight | distance | is_express | Ожидаемый |
|---|---|---|---|---|
| 1 | 15 | 600 | True | (5 + 15*0.5) * 1.5 * 2 = 37.5 |
| 2 | 5 | 600 | True | (5 + 5*0.3) * 1.5 * 2 = 19.5 |
| 3 | 15 | 200 | True | (5 + 15*0.5) * 1 * 2 = 25.0 |
| 4 | 15 | 600 | False | (5 + 15*0.5) * 1.5 * 1 = 18.75 |
Задача 2
Вычислите цикломатическую сложность и определите, сколько тестов базисных путей нужно:
def grade_student(score, attendance, has_extra_credit):
if score >= 90:
grade = "A"
elif score >= 80:
grade = "B"
elif score >= 70:
grade = "C"
else:
grade = "F"
if attendance < 75:
grade = "F"
if has_extra_credit and grade != "F":
grade = chr(ord(grade) - 1)
return grade
Решение
Подсчёт решений:
- Цепочка if/elif/elif/else = 3 точки принятия решений
- Проверка посещаемости = 1 решение
- Проверка доп. баллов = 1 решение
V(G) = 5 + 1 = 6
Нужно 6 тест-кейсов базисных путей минимум:
| # | score | attendance | extra_credit | Путь | Ожидаемый |
|---|---|---|---|---|---|
| 1 | 95 | 80 | False | >=90, посещение ОК, без доп. | A |
| 2 | 85 | 80 | False | >=80, посещение ОК, без доп. | B |
| 3 | 75 | 80 | False | >=70, посещение ОК, без доп. | C |
| 4 | 50 | 80 | False | <70, посещение ОК, без доп. | F |
| 5 | 95 | 60 | False | >=90, посещение НИЗКОЕ, без доп. | F |
| 6 | 85 | 80 | True | >=80, посещение ОК, доп. баллы | A |
Покрытие путей vs. другие критерии
| Критерий | Что покрывает | Минимум тестов | Поглощает |
|---|---|---|---|
| Операторы | Каждый оператор выполнен | Меньше всего | Ничего |
| Решения | Каждая ветвь пройдена | Больше | Операторы |
| Условия | Каждое условие T/F | Больше | Ничего само по себе |
| MC/DC | Каждое условие независимо | N+1 на решение | Решения + Условия |
| Пути | Каждый уникальный путь | Больше всего (экспонент.) | Все выше |
Покрытие путей — сильнейший критерий, но обычно непрактичен для полного покрытия. Basis path testing — практичный компромисс.
Когда использовать покрытие путей
Используйте basis path testing когда:
- Тестируете критические алгоритмы (финансовые расчёты, логика безопасности)
- Код имеет умеренную сложность (V(G) < 15)
- Нужно обосновать достаточность тестов перед аудиторами
- Рефакторите сложные функции — пути помогают понять, что делает код
Пропустите полное покрытие путей когда:
- Функции содержат циклы с большим числом итераций
- Цикломатическая сложность превышает 20 (сначала рефакторинг)
- Код достаточно прост, и покрытия решений хватает
Ключевые выводы
- Покрытие путей тестирует каждый уникальный путь выполнения от входа до выхода
- Полное покрытие путей непрактично из-за экспоненциального роста и бесконечных путей циклов
- Цикломатическая сложность V(G) = P + 1 даёт минимальное число тестов базисных путей
- Basis path testing — практичный подход: тестируйте независимые пути, а не все пути
- Держите цикломатическую сложность ниже 10 на функцию
- Используйте инструменты SonarQube, Lizard или radon для автоматического измерения сложности