Что делать, когда есть вполне стандартная задача, но популярные инструменты не могут ее решить? Правильно, время - изобретать свой велосипед.
Задача состоит в следующем: есть некие параметры, часть из которых может быть объединена в каскады, нужно вычислить покрытие параметров, при этом учитывать, что если есть тесты на каскад, в котором присутствует параметр, то параметр считается покрытым.
Видим, что нам необходимо проверять на покрытие именно сущности, а не ветвление, сценарии или код, как это делают, допустим, coverage.py или pytest-cov
Т.к. пишем вручную, стоит подумать о том, в каком виде было бы удобно получать отчет. Я не смогла определиться, поэтому сделала 3: HTML, CSV, JSON, каждый из них удобно использовать под разные цели:
HTML - красивый и наглядный, удобно показывать на каких-нибудь отчетах, ретро и т.п.,
CSV - корпоративный стандарт хранения подобных вещей,
JSON - удобен для отладки и дальнейшего использования в коде, если вдруг захочется накрутить еще приколов.
Исходные данные:
CSV-файл от аналитиков с актуальными параметрами и каскадами,

Пример формата CSV-файла от аналитиков директории parametres и cascades в проекте автотестов.
Ну вот, собственно, наша концепция и готова:
Из тестов на параметры и каскады извлекаем названия и составляем словарь в формате:
{“<param_name>”: [“<test_name>”, …]}В CSV-файле от аналитиков извлекаем необходимые нам столбцы и составляем словарь:
{“<param_name>”: [“<cascadet_name>”, …]}Объединяем словари:
{“<param_name>”:{ "<param_name>": { "cascades": ["<cascadet_name>", ...], "coverage": "-/+", "tests": ["<test_name>", ...] }На основе этих данных формируем отчет.
Отчет можно сформировать 3 видов, запустив скрипт с соответствующими флагами:
--html- флаг установлен по умолчанию, страница отображает колличественные данные, матрицу покрытия и список непокрытых параметров--csv- простая таблица с матрицей покрытия--json- удобный формат для обработки данных и отладки, отображает матрицу покрытия и список непокрытых параметров
Запускать будем так:
#запук генерации html-страницы python matrix_coverage.py #запук генерации всех видов отчетов python matrix_coverage.py --html --csv --json
Первый пункт - извлечений названий из тестов. Задаем пути, где лежат тесты и проходимся по всем файлам в них (в том числе и по поддиректориям) и ищем первую строку, которая похожа на паттерн. Нашли, отрезаем все лишнее (здесь я смело орудую слайсами, т.к. у нас жестко соблюдаются требования к оформлению - местный стандарт, линтер, все дела), и записываем в нужный словарь. Важная проверка - если название уже есть в словаре, то добавляем его к существующему списку, иначе каскад всегда будет один - последний.
def get_dict_params_from_tests(paths): param_pattern = 'param_name = "' cascade_pattern = "name=" params = {} cascades = {} for base_path in paths: path_obj = Path(base_path) for file_path in path_obj.rglob("*.py"): with open(file_path, "r", encoding="utf-8") as f: for line in f: if param_pattern in line: name = line[line.find('"') + 1 : line.find("\n") - 1] if name not in params: params[name] = [file_path.name] else: params[name].append(file_path.name) break if cascade_pattern in line: name = line[line.find('"') + 1 : line.find("\n") - 1] if name not in cascades: cascades[name] = [file_path.name] else: cascades[name].append(file_path.name) break return params, cascades
Второй пункт - извлечений названий из CSV-документа: указываем путь к файлу, открываем его и читаем с помощью DictReader, который каждую строку превращет в словарь, где ключами являются названия столбцов. Здесь нас интересуют только 2 столбца из которых составляем свой словарь: {“Параметр”: “Участвует в каскадах”}
def get_dict_params_from_cvs(input_file): params_dict = {} with open(input_file, "r", encoding="utf-8", newline="") as f: reader = csv.DictReader(f, delimiter=";") for row in reader: cascades_raw = row.get("Участвует в каскадах", "") cascades = [cp.strip() for cp in cascades_raw.split("\n") if cp.strip()] if cascades: params_dict[row.get("Параметр", "")] = cascades else: params_dict[row.get("Параметр", "")] = None return params_dict
Третий пункт - консолидация полученных словарей. Проходимся по все ключам и их значениям и сравниваем есть ли они в наших словарях из первого пункта. Если есть ставим “+”" в покрытие, указываем тесты и каскады, если нет - ставим “-”, в тестах указываем пустой список. Затем, так же проверяем каскады на наличие тестов.
def create_matrix_coverage(test_dirs, input_cvs): params_from_tests, cascades_from_tests = get_dict_params_from_tests(test_dirs) params_from_cvs = get_dict_params_from_cvs(input_cvs) matrix = {} for param, cascades in params_from_cvs.items(): if param in params_from_tests: matrix[param] = { "coverage": "+", "cascades": cascades, "tests": params_from_tests.get(param), } else: matrix[param] = { "coverage": "-", "cascades": cascades, "tests": [], } ###Проверяем наличие каскадов у параметра, если их нет, то переходим к следующему параметру: if cascades is None: continue ###Если каскады есть, то проверяем есть ли на него тесты: for cascadet in cascades: if cascadet not in cascades_from_tests: continue ###Если на каскад тесты есть, а на парметр нет, то меняем покрытие на + и добавляем список тестов: if matrix[param]["coverage"] == "-": matrix[param]["coverage"] = "+" tests_list = cascades_from_tests.get(cascadet) if tests_list: matrix[param]["tests"].extend(tests_list) ###Если и на каскад и на параметр тесты есть, то добавляем список тестов каскада к списку тестов параметра: else: tests_list = cascades_from_tests.get(cascadet) if tests_list: matrix[param]["tests"].extend(tests_list) return matrix
Около тысячи параметров, это вам не шутки, поэтому будет удобно, если список непокрытых параметров будем перед глазами. Для этого, из уже имеющегося финального словаря отберем непокрытые параметры (у которых покрытие: “-”):
def get_not_covered_params(final_dct): not_covered_params = [] for key, value in final_dct.items(): if value["coverage"] == "-": not_covered_params.append( ( key, value["cascades"], ) ) return not_covered_params
Далее самое интересное - составляем отчет. Начнем с полезного - JSON, хорошо подходит для отладки, здесь же сразу видно структуру, красиво/некрасиво выдит. Формируем словарь с данными, которые будем записывать в JSON: у меня будет 2 родительских объекта: мамочка - матрица покрытия, и папочка - список непокрытых параметров
def generate_json(output_file, final_dct, not_covered_params): json_data = { "coverage_matrix": final_dct, "not_covered_params": [ {"param": param, "cascades": cascades} for param, cascades in not_covered_params ], } with open(output_file, "w", encoding="utf-8") as f: json.dump(json_data, f, indent=4, sort_keys=True, ensure_ascii=False)
Формируем CSV-файл построчно. Первая строка - заполняем ячейки заголовками, все последующие берем из матрицы по соответствующему ключу и заполняем ячейки:
def generate_csv(output_file, final_dct): with open(output_file, "w", encoding="utf-8", newline="") as f: writer = csv.writer(f) writer.writerow(["Покрытие", "Параметр", "Каскады", "Тесты"]) for param, params in final_dct.items(): coverage = params.get("coverage", "-") cascades = params.get("cascades", []) tests = params.get("tests", []) writer.writerow([coverage, param, cascades, tests])
Вот, что мы в итоге получим:

Далее - самое сложное (для тех кто с версткой кое-как знаком - это я) и объемное, советую вынести в отдельный файл. Сначала подумаем, что мы хотим видеть. Я представляю себе это так:
Должны быть количественные данные покрытости,
На странице 2 спойлера с таблицей покрытия и списков непокрытых параметров,
В таблице покрытия 2 столбца - первый - признак покрытияб второй - название параметра,
При нажатии на строку таблицы открывается доп. инфа: каскады и тесты,
У обоих столбцов должна быть сортировка,
Для списка непокрытых параметров должны отображаться каскады, маленькими буквами,
Стрелочка вверх, т.к. данных очень много.
Для наглядности покажу, что мы сейчас будем создавать:

Оформим каждую из наших хотелок в отдельную функцию, и потом из них будем собирать страницу.
Генерируем HTML для одного непокрытого параметра с каскадами:
def _generate_param_with_cp_html(param_data): param = param_data[0] cascades = param_data[1] cp_text = "; ".join([c for c in cascades if c]) if cascades else "<i>нет</i>" return f""" <div class="param-item"> <b class="param-name">{param}</b> <div class="param-cascades">Каскады: {cp_text}</div> </div>"""
Генерируем HTML для списка непокрытых параметров со всеми каскадами:
def _generate_not_covered_html(not_covered_params): if not not_covered_params: return "<i>нет данных</i>" items = [] for param_data in not_covered_params: items.append(_generate_param_with_cp_html(param_data)) return "\n".join(items)
Генерируем HTML спойлер с центрированным заголовком:
def _generate_spoiler_html(title, content): return f""" <details class="spoiler"> <summary class="spoiler-summary">{title}</summary> <div class="spoiler-content">{content}</div> </details>"""
Генерируем HTML строку таблицы с аккордеоном:
def _generate_accordion_row_html(param, coverage, cascades, tests): cover_style = ( "color: green; font-weight: bold;" if coverage == "+" else "color: red; font-weight: bold;" ) cascades_text = ( "; ".join([c for c in cascades if c]) if cascades else "<i>нет</i>" ) tests_text = "; ".join([t for t in tests if t]) if tests else "<i>нет</i>" ### Для корректного отображения нужно перекодировать спец.символы: cascades_encoded = cascades_text.replace("`", "``").replace('"', '`"') tests_encoded = tests_text.replace("`", "``").replace('"', '`"') ### Добавляем data-атрибуты для сортировки return f""" <tr class="param-row" data-cascades="{cascades_encoded}" data-tests="{tests_encoded}" data-param="{param}" data-coverage="{coverage}" style="cursor: pointer;"> <td style="{cover_style}">{coverage}</td> <td><b class="param-name">{param}</b></td> </tr> <tr class="detail-row" style="display: none;"> <td colspan="2"> <div class="detail-content"> <div class="detail-section"> <b>Каскады:</b> {cascades_encoded} </div> <div class="detail-section"> <b>Тесты:</b> {tests_encoded} </div> </div> </td> </tr> """
Генерируем CSS стили. Указываем всякие красивости, по типу размеры, шрифт, цвет и т.п.:
Функция достаточно объемная, поэтому я ее прикрыла
def _generate_html_styles(): return """ body { font-family: Arial, sans-serif; max-width: 1200px; margin: 0 auto; padding: 20px; } table { width: 100%; border-collapse: collapse; margin-top: 20px; } th, td { border: 1px solid #ddd; padding: 12px; text-align: left; } th { background-color: #4CAF50; color: white; } tr:nth-child(even) { background-color: #f2f2f2; } .summary { background: #333; color: white; padding: 15px; text-align: center; border-radius: 5px; } .spoiler { margin: 15px 0; } .spoiler-summary { text-align: center; cursor: pointer; padding: 10px; background: #f0f0f0; border: 1px solid #ccc; border-radius: 5px; margin: 0; } .spoiler-content { padding: 0; background: none; } .spoiler-content ul { margin: 10px 0; padding-left: 25px; } .spoiler-content li { margin: 3px 0; } /* === Аккордеон для таблицы === */ .detail-row { background-color: #fafafa; } .detail-content { padding: 10px; } .detail-section { margin: 5px 0; padding: 5px; background: #f9f9f9; border-left: 3px solid #4CAF50; } .param-row:hover { background-color: #e8f5e9; } /* == Стили для списка непокрытых параметров === */ .spoiler-content .param-item { background: #fff; padding: 10px; margin: 5px 0; border: 1px solid #ddd; border-radius: 4px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); } .spoiler-content .param-name { display: block; color: #333; font-size: 14px; } .spoiler-content .param-cascades { display: block; margin-top: 5px; color: #666; font-size: 12px; font-style: italic; } /* === Кнопка "Вверх" === */ #backToTop { display: none; position: fixed; bottom: 20px; right: 30px; z-index: 99; font-size: 18px; border: none; outline: none; background-color: #4CAF50; color: white; cursor: pointer; padding: 15px; border-radius: 50%; width: 60px; height: 60px; box-shadow: 0 4px 10px rgba(0,0,0,0.3); transition: background-color 0.3s, transform 0.3s; } #backToTop:hover { background-color: #45a049; transform: translateY(-3px); } #backToTop:active { transform: translateY(0); } """
Генерируем тело страницы:
Эта тоже крупная получилась
def _generate_html_body( total, covered, not_covered_count, table_spoiler, not_covered_spoiler ): return f""" <h1 style="text-align:center;">📊 Coverage Report</h1> <div class="summary">Всего параметров: {total} | ✅ Покрыто: {covered} | ❌ Не покрыты: {not_covered_count}</div> {table_spoiler} {not_covered_spoiler} <script> let sortDirection = {{ 'coverage': 'asc', 'param': 'asc' }}; // === Кнопка "Наверх" === (function() {{ let backToTopBtn = document.createElement('button'); backToTopBtn.id = 'backToTop'; backToTopBtn.innerHTML = '↑'; backToTopBtn.title = 'Наверх'; document.body.appendChild(backToTopBtn); backToTopBtn.addEventListener('click', () => {{ window.scrollTo({{ top: 0, behavior: 'smooth' }}); }}); window.addEventListener('scroll', () => {{ backToTopBtn.style.display = window.scrollY > 300 ? 'block' : 'none'; }}); }})(); function sortTable(columnIndex, key) {{ const tbody = document.querySelector('table tbody'); const rows = Array.from(tbody.querySelectorAll('.param-row')); sortDirection[key] = sortDirection[key] === 'asc' ? 'desc' : 'asc'; rows.sort((a, b) => {{ const aValue = a.getAttribute('data-' + key) || ''; const bValue = b.getAttribute('data-' + key) || ''; if (sortDirection[key] === 'asc') {{ return aValue.localeCompare(bValue); }} else {{ return bValue.localeCompare(aValue); }} }}); // === Сохраняем связь ДО перемещения === rows.forEach(row => {{ const detailRow = row.nextElementSibling; tbody.appendChild(row); if (detailRow && detailRow.classList.contains('detail-row')) {{ tbody.appendChild(detailRow); }} }}); }}; // === Аккордеон для таблицы === document.querySelector('table tbody').addEventListener('click', function(e) {{ const paramRow = e.target.closest('.param-row'); if (paramRow) {{ const detailRow = paramRow.nextElementSibling; if (detailRow && detailRow.classList.contains('detail-row')) {{ const isHidden = detailRow.style.display === 'none'; detailRow.style.display = isHidden ? 'table-row' : 'none'; }} }} }}); </script> """
Генерирует полный HTML-документ:
def generate_html(dct, not_covered_params): total = len(dct) covered = sum(1 for d in dct.values() if d["coverage"] == "+") not_covered_count = total - covered rows_html = "" for param, data in dct.items(): rows_html += _generate_accordion_row_html( param, data["coverage"], data["cascades"], data["tests"] ) not_covered_html = ( _generate_not_covered_html(not_covered_params) if not_covered_params else "<i>нет данных</i>" ) not_covered_spoiler = _generate_spoiler_html( "❌ Не покрытые параметры", not_covered_html ) table_spoiler = _generate_spoiler_html( "📊 Матрица покрытия", f"""<table> <thead><tr> <th onclick="sortTable(0, 'coverage')">Покрытие ↕</th> <th onclick="sortTable(1, 'feature')">ФИЧА ↕</th> </tr></thead> <tbody>{rows_html}</tbody> </table>""", ) return f"""<!DOCTYPE html> <html lang="ru"> <head> <meta charset="UTF-8"> <title>Отчёт покрытия</title> <style> {_generate_html_styles()} </style> </head> <body> {_generate_html_body(total, covered, not_covered_count, table_spoiler, not_covered_spoiler)} </body> </html>"""
Все готово, осталось собрать мэйн. Я хочу запускать скрипт с разными ключами, поэтому добавим парсинг командных аргументов. Если флаг не указан, то запускаем --html:
if __name__ == "__main__": final_dct = create_matrix_coverage(TEST_DIRS, INPUT_CSV) not_covered_params = get_not_covered_params(final_dct) parser = argparse.ArgumentParser(description="Генерация отчёта покрытия параметров") parser.add_argument("--html", action="store_true", help="Генерировать HTML отчёт") parser.add_argument("--csv", action="store_true", help="Генерировать CSV файл") parser.add_argument("--json", action="store_true", help="Генерировать JSON файл") args = parser.parse_args() if not args.html and not args.csv and not args.json: args.html = True if args.html: html_content = generate_html(final_dct, not_covered_params) output_html = OUTPUT_HTML with open(output_html, "w", encoding="utf-8") as f: f.write(html_content) print(f"✅ Готово! HTML отчёт: {output_html}") if args.csv: output_csv = OUTPUT_CSV generate_csv(output_csv, final_dct) print(f"✅ Готово! CSV файл: {output_csv}") if args.json: output_json = OUTPUT_JSON generate_json(output_json, final_dct, not_covered_params) print(f"✅ Готово! JSON файл: {OUTPUT_JSON}")
Парсер сам добавляет хэлпу, с описаниями из соответствующего ключа аргумента, вот как это будет выглядеть:
(env) PS C:\test> python utils/matrix_coverage/matrix_coverage.py --help usage: matrix_coverage.py [-h] [--html] [--csv] [--json] Генерация отчёта покрытия параметров options: -h, --help show this help message and exit --html Генерировать HTML отчёт --csv Генерировать CSV файл --json Генерировать JSON файл
Все готово, теперь можно отслеживать покрытие в любой момент времени. При желании можно прикрутить расписание, допустим, собирать инфу раз в спринт или в конце месяца в отчетный период. Скрипт собран из стандартных библиотек, ничего лишнего устанавливать не надо, можно запустить в любой момент и продемонстрировать руководству, какие вы молодцы, и все это в любом удобном формате.

