Share
Embed
Moscow Python Meetup №96 Кирилл Сосновских (YADRO, Automation QA (Python). Анализируем исходный код с пользой: как облегчить работу ревьюерам и увеличить читабельность тестовой отчетности
Расскажу, как обеспечить соблюдение командных конвенций и порядок в хранилище тестовых артефактов с помощью синтаксического анализа. Также поделюсь опытом написания и внедрения собственного линтера для проекта с пятьюстами тестовыми сценариями, который значительно упростил работу ревьюеров и улучшил читабельность отчетности.
Видео: https://moscowpython.ru/meetup/96/source-code-analysis/
Moscow Python: http://moscowpython.ru
Курсы Learn Python: http://learn.python.ru
Moscow Python Podcast: http://podcast.python.ru
Заявки на доклады: https://bit.ly/mp-speaker
Moscow Python Meetup PRO
November 28, 2024
Transcript
72 slides
-
Анализируем исходный код с пользой или как облегчить работу ревьюерам
Сосновских Кирилл -
• Работаю в YADRO автоматизатором тестирования в отделе TATLIN.OBJECT •
Пишу на Python, знаю что такое AST, вкусный мармелад и как написать свой линтер Кирилл Сосновских Automation QA -
4 О какой рутине речь? Review Merge Но у меня
немного иначе Code Test Report Test Storag e -
5 @allure.title("Create bucket with valid name length") @pytest.mark.parametrize("length", [3, 32,
63]) def test_s3_create_bucket_with_valid_length( self, s3_client: S3ClientWrapper, length: int, ): bucket_name = string_utils.random_string( length, VALID_SYMBOLS_WITHOUT_DOT, ) with allure.step("Create bucket with valid name length"): s3_client.create_bucket(bucket_name) with allure.step("Check bucket name in buckets"): assert bucket_name in s3_client.list_buckets() Принятые в команде конвенции Рассмотрим на примере -
6 @allure.title("Create bucket with valid name length") @pytest.mark.parametrize("length", [3, 32,
63]) def test_s3_create_bucket_with_valid_length( self, s3_client: S3ClientWrapper, length: int, ): bucket_name = string_utils.random_string( length, VALID_SYMBOLS_WITHOUT_DOT, ) with allure.step("Create bucket with valid name length"): s3_client.create_bucket(bucket_name) with allure.step("Check bucket name in buckets"): assert bucket_name in s3_client.list_buckets() Принятые в команде конвенции Заголовок всему голова -
7 @allure.title("Create bucket with valid name length") @pytest.mark.parametrize("length", [3, 32,
63]) def test_s3_create_bucket_with_valid_length( self, s3_client: S3ClientWrapper, length: int, ): bucket_name = string_utils.random_string( length, VALID_SYMBOLS_WITHOUT_DOT, ) with allure.step("Create bucket with valid name length"): s3_client.create_bucket(bucket_name) with allure.step("Check bucket name in buckets"): assert bucket_name in s3_client.list_buckets() Принятые в команде конвенции Был один – стало три -
8 @allure.title("Create bucket with valid name length") @pytest.mark.parametrize("length", [3, 32,
63]) def test_s3_create_bucket_with_valid_length( self, s3_client: S3ClientWrapper, length: int, ): bucket_name = string_utils.random_string( length, VALID_SYMBOLS_WITHOUT_DOT, ) with allure.step("Create bucket with valid name length"): s3_client.create_bucket(bucket_name) with allure.step("Check bucket name in buckets"): assert bucket_name in s3_client.list_buckets() Принятые в команде конвенции Пора программировать -
9 @allure.title("Create bucket with valid name length") @pytest.mark.parametrize("length", [3, 32,
63]) def test_s3_create_bucket_with_valid_length( self, s3_client: S3ClientWrapper, length: int, ): bucket_name = string_utils.random_string( length, VALID_SYMBOLS_WITHOUT_DOT, ) with allure.step("Create bucket with valid name length"): s3_client.create_bucket(bucket_name) with allure.step("Check bucket name in buckets"): assert bucket_name in s3_client.list_buckets() Принятые в команде конвенции По шажку -
10 @allure.title("Create bucket with valid name length") @pytest.mark.parametrize("length", [3, 32,
63]) def test_s3_create_bucket_with_valid_length( self, s3_client: S3ClientWrapper, length: int, ): bucket_name = string_utils.random_string( length, VALID_SYMBOLS_WITHOUT_DOT, ) with allure.step("Create bucket with valid name length"): s3_client.create_bucket(bucket_name) with allure.step("Check bucket name in buckets"): assert bucket_name in s3_client.list_buckets() Представим, что мы разработчик Это я написал! -
14 @allure.title( "Create bucket with valid name length (s3_client={s3_client}, length={length})"
) @pytest.mark.parametrize("length", [3, 32, 62]) def test_s3_create_bucket_with_valid_length( self, s3_client: S3ClientWrapper, length: int, ): bucket_name = string_utils.random_string( length, VALID_SYMBOLS_WITHOUT_DOT, ) with allure.step("Create bucket with valid name length"): s3_client.create_bucket(bucket_name) with allure.step("Check bucket name in buckets"): assert bucket_name in s3_client.list_buckets() Отчаянно исправляем Ну сейчас то точно все будет хорошо! -
20 Люди – не роботы, человеческий фактор неизбежен Проблема в
ревью-процессе? Висяк! -
21 Пусть рутиной занимается робот! Разве может робот написать симфонию…
Его обязанности: • Анализировать исходный код • Находить проблемные по нашему мнению места в коде • Бить разработчика по рукам • Не просить зарплату -
22 @allure.title( "Create bucket with valid name length " "(s3_client={s3_client},
length={length})" ) Код как данные Знакомый нам заголовок -
23 @allure.title( "Create bucket with valid name length " "(s3_client={s3_client},
length={length})" ) Код как данные Обобщим! @allure.title( "Заголовок описывающий тест (p1={p1}, p2={p2}, ...)" ) -
25 @pytest.mark.parametrize("length", [3, 32, 63]) Код как данные Обобщим и
её! @pytest.mark.parametrize("p1, p2, ...", [V1, V2, ...]) -
26 def length_parametrizer() -> list[int]: # Очень сложный код return
lengths @pytest.mark.parametrize("length", length_parametrizer()) def test_s3_create_bucket_with_valid_length(...): ... Как еще может выглядеть параметризация? Это стоит учесть -
27 def pytest_generate_tests(metafunc: pytest.Metafunc): if "length" in metafunc.fixturenames: metafunc.parametrize("length", [3,
32, 63]) def test_s3_create_bucket_with_valid_length( self, s3_client: S3ClientWrapper, length: int, ): ... Как еще может выглядеть параметризация? Вариантов много! -
28 def pytest_generate_tests(metafunc: pytest.Metafunc): # Очень сложный код if "length"
in metafunc.fixturenames: metafunc.parametrize("length", lengths) def test_s3_create_bucket_with_valid_length( self, s3_client: S3ClientWrapper, length: int, ): ... Как еще может выглядеть параметризация? Совместим оба кейса! -
29 @allure.title( "Create bucket with valid name length (s3_client={s3_client}, length={length})"
) @pytest.mark.parametrize("length", [3, 32, 63]) def test_s3_create_bucket_with_valid_length( self, s3_client: S3ClientWrapper, length: int, ): bucket_name = string_utils.random_string( length, VALID_SYMBOLS_WITHOUT_DOT, ) with allure.step("Create bucket with valid name length"): s3_client.create_bucket(bucket_name) with allure.step("Check bucket name in buckets"): assert bucket_name in s3_client.list_buckets() Код как данные Сложновато! -
30 @allure.title( "Create bucket with valid name length (s3_client={s3_client}, length={length})"
) @pytest.mark.parametrize("length", [3, 32, 63]) def test_s3_create_bucket_with_valid_length( self, s3_client: S3ClientWrapper, length: int, ): bucket_name = string_utils.random_string( length, VALID_SYMBOLS_WITHOUT_DOT, ) with allure.step("Create bucket with valid name length"): s3_client.create_bucket(bucket_name) with allure.step("Check bucket name in buckets"): assert bucket_name in s3_client.list_buckets() Заголовок наш выбор! И все же заголовок всему голова! -
31 @allure.title( "Create bucket with valid name length (s3_client={s3_client}, length={length})"
) @pytest.mark.parametrize("length", [3, 32, 63]) def test_s3_create_bucket_with_valid_length( self, s3_client: S3ClientWrapper, length: int, ): bucket_name = string_utils.random_string( length, VALID_SYMBOLS_WITHOUT_DOT, ) with allure.step("Create bucket with valid name length"): s3_client.create_bucket(bucket_name) with allure.step("Check bucket name in buckets"): assert bucket_name in s3_client.list_buckets() Что за параметр такой? Опять что-то упустили… -
32 @allure.title("[Session]: Create S3 client") @pytest.fixture( scope="session", params=[ pytest.param(AwsCliClient, marks=...),
pytest.param(Boto3ClientWrapper, marks=...), ], ) def s3_client(...) -> S3ClientWrapper: # Создание и подготовка S3 клиента return client Немного о фикстурах Что-то новенькое! -
33 @allure.title("[Session]: Create S3 client") @pytest.fixture( scope="session", params=[ pytest.param(AwsCliClient, marks=...),
pytest.param(Boto3ClientWrapper, marks=...), ], ) def s3_client(...) -> S3ClientWrapper: # Создание и подготовка S3 клиента return client Немного о фикстурах Снова параметризация? -
34 def fixture_parametrizer() -> list: # Очень сложный код return
data @allure.title("[Session]: Create S3 client") @pytest.fixture( scope="session", params=fixture_parametrizer(), ) def s3_client(...) -> S3ClientWrapper: # Создание и подготовка S3 клиента return client Немного о фикстурах Знакомое явление… -
35 Abstract Syntax Tree Тут и думать нечего – будет
2! r = (6 + 4) // 5 -
36 Abstract Syntax Tree Какое ж это дерево без корня?
root r = (6 + 4) // 5 -
37 Abstract Syntax Tree root = r (6 + 4)
// 5 Все как в математике -
38 Abstract Syntax Tree root = // r 5 6
+ 4 Соблюдаем приоритетность операций! -
40 import ast source_code = """ @allure.title(...) @pytest.mark.parametrize("length", [3,
32, 63]) def test_s3_create_bucket_with_valid_length( self, s3_client: S3ClientWrapper, length: int ): ... """ module = ast.parse(source_code) print(ast.dump(module, indent=4)) От слов к действиям Наконец-то! -
43 Декомпозируем пример Еще немного теории… FunctionDef name args body
decorator_list ... ... ... ... ... ... ... ... ... -
44 FunctionDef( name='test_s3_create_bucket_with_valid_length', args=arguments( posonlyargs=[], args=[ arg(arg='self'), arg(arg='s3_client', annotation=Name(id='S3ClientWrapper', ctx=...)),
arg(arg='length', annotation=Name(id='int', ctx=...)) ], kwonlyargs=[], kw_defaults=[], defaults=[] ), body=[...], decorator_list=[...], type_params=[] ) Тест как объект И это только один тест… -
45 FunctionDef( name='test_s3_create_bucket_with_valid_length', args=arguments( posonlyargs=[], args=[ arg(arg='self'), arg(arg='s3_client', annotation=Name(id='S3ClientWrapper', ctx=...)),
arg(arg='length', annotation=Name(id='int', ctx=...)) ], kwonlyargs=[], kw_defaults=[], defaults=[] ), body=[...], decorator_list=[...], type_params=[] ) Тест как объект Имя теста -
46 FunctionDef( name='test_s3_create_bucket_with_valid_length', args=arguments( posonlyargs=[], args=[ arg(arg='self'), arg(arg='s3_client', annotation=Name(id='S3ClientWrapper', ctx=...)),
arg(arg='length', annotation=Name(id='int', ctx=...)) ], kwonlyargs=[], kw_defaults=[], defaults=[] ), body=[...], decorator_list=[...], type_params=[] ) Тест как объект Аргументы теста -
47 FunctionDef( name='test_s3_create_bucket_with_valid_length', args=arguments( posonlyargs=[], args=[ arg(arg='self'), arg(arg='s3_client', annotation=Name(id='S3ClientWrapper', ctx=...)),
arg(arg='length', annotation=Name(id='int', ctx=...)) ], kwonlyargs=[], kw_defaults=[], defaults=[] ), body=[...], decorator_list=[...], type_params=[] ) Тест как объект Список декораторов -
48 decorator_list=[ Call(...), # allure.title(...) Call( func=Attribute( value=Attribute( value=Name(id='pytest', ctx=Load()),
attr='mark', ctx=Load() ), attr='parametrize', ctx=Load() ), args=[ Constant(value='length'), List( elts=[Constant(value=3), Constant(value=32), Constant(value=63)], ctx=Load() ) ], keywords=[] ) ], Декораторы теста Немного углубимся -
49 Call( func=Attribute( value=Attribute( value=Name(id='pytest', ctx=Load()), attr='mark', ctx=Load() ), attr='parametrize',
ctx=Load() ), args=[ Constant(value='length'), List( elts=[Constant(value=3), Constant(value=32), Constant(value=63)], ctx=Load() ) ], keywords=[] ) Декомпозируем декоратор Имя декоратора -
50 Call( func=Attribute( value=Attribute( value=Name(id='pytest', ctx=Load()), attr='mark', ctx=Load() ), attr='parametrize',
ctx=Load() ), args=[ Constant(value='length'), List( elts=[Constant(value=3), Constant(value=32), Constant(value=63)], ctx=Load() ) ], keywords=[] ) Декомпозируем декоратор Параметр и его значения -
51 Что делать с хуками? Как же много всего нужно
учесть! Все просто, но требуются допущения: • Будем анализировать только pytest_generate_tests хуки • Не будем анализировать логику самого хука • Будем учитывать все параметры независимо от их использования в тестах • Перечисленные в хуке параметры будем считать фикстурами -
52 А с фикстурами что? Кажется все не так просто…
Аналогично тестам, но есть нюансы: • Фикстуры могут быть где угодно • Фикстуры могут переопределять друг друга • Фикстуры могут быть зависимы от других фикстур • Фикстуры могут быть определены во внешних плагинах -
53 Этапы работы линтера Что анализируем? Сбор исходных файлов Сбор
AST объектов Определени е связей Валидация -
54 Этапы работы линтера Что анализируем? Сбор исходных файлов Сбор
AST объектов Определени е связей Валидация • Путей к файлам с тестами • Путей к файлам с фикстурами • Путей к файлам плагинов -
55 Этапы работы линтера Формируем данные удобным для нас образом
Сбор исходных файлов Сбор AST объектов Определени е связей Валидация • Представляющих тесты • Представляющих фикстуры • Представляющих pytest-хуки -
56 Этапы работы линтера Получаем единый контекст Сбор исходных файлов
Сбор AST объектов Определени е связей Валидация • Для зависимых друг от друга фикстур • Для тестов с их параметрами и фикстурами, в том числе динамическими -
57 Этапы работы линтера Находим проблемы Сбор исходных файлов Сбор
AST объектов Определени е связей Валидация • Уникальности заголовков • Заполненности заголовков • Наличия или отсутствия в заголовках необходимых параметров -
58 Смотрим что получилось Как запустить? allure-validator <путь к тестам>
[--plugins, --files] -
59 Смотрим что получилось Даже запускаться само будет! allure-validator <путь
к тестам> [--plugins, --files] - repo: https://git.frostfs.info/TrueCloudLab/allure-validator rev: <ревизия> hooks: - id: allure-validator args: [ "<путь к тестам>", ["--plugins ...", "--files ..."] ] или -
60 Смотрим что получилось Опциональные параметры allure-validator <путь к тестам>
[--plugins, --files] - repo: https://git.frostfs.info/TrueCloudLab/allure-validator rev: <ревизия> hooks: - id: allure-validator args: [ "<путь к тестам>", ["--plugins ...", "--files ..."] ] или -
61 Смотрим что получилось Уникальные и непустые заголовки NOT UNIQUE
TITLE: Title for two tests In the following tests: tests/test_validator.py:127: title is not unique by test_not_unique_2 tests/test_validator.py:122: title is not unique by test_not_unique_1 tests/test_validator.py:104: EMPTY TITLE: Missing title in test_empty_title_1 tests/test_validator.py:109: EMPTY TITLE: Missing title in test_empty_title_2 -
62 Смотрим что получилось Неуникальные и пустые заголовки NOT UNIQUE
TITLE: Title for two tests In the following tests: tests/test_validator.py:127: title is not unique by test_not_unique_2 tests/test_validator.py:122: title is not unique by test_not_unique_1 tests/test_validator.py:104: EMPTY TITLE: Missing title in test_empty_title_1 tests/test_validator.py:109: EMPTY TITLE: Missing title in test_empty_title_2 -
63 Смотрим что получилось Неуникальные и пустые заголовки NOT UNIQUE
TITLE: Title for two tests In the following tests: tests/test_validator.py:127: title is not unique by test_not_unique_2 tests/test_validator.py:122: title is not unique by test_not_unique_1 tests/test_validator.py:104: EMPTY TITLE: Missing title in test_empty_title_1 tests/test_validator.py:109: EMPTY TITLE: Missing title in test_empty_title_2 -
64 Смотрим что получилось Неуникальные и пустые заголовки NOT UNIQUE
TITLE: Title for two tests In the following tests: tests/test_validator.py:127: title is not unique by test_not_unique_2 tests/test_validator.py:122: title is not unique by test_not_unique_1 tests/test_validator.py:104: EMPTY TITLE: Missing title in test_empty_title_1 tests/test_validator.py:109: EMPTY TITLE: Missing title in test_empty_title_2 -
65 Смотрим что получилось Пропущенные параметры tests/test_validator.py:53: MISSING PARAMS: Parameters
are missing from title: required_runtime_fixture tests/test_validator.py:127: MISSING PARAMS: Parameters are missing from title: object_size tests/test_validator.py:152: MISSING PARAMS: Parameters are missing from title: p1 OR p2 OR p3 -
66 Смотрим что получилось Пропущенные параметры tests/test_validator.py:53: MISSING PARAMS: Parameters
are missing from title: required_runtime_fixture tests/test_validator.py:127: MISSING PARAMS: Parameters are missing from title: object_size tests/test_validator.py:152: MISSING PARAMS: Parameters are missing from title: p1 OR p2 OR p3 -
67 Смотрим что получилось Пропущенные параметры tests/test_validator.py:53: MISSING PARAMS: Parameters
are missing from title: required_runtime_fixture tests/test_validator.py:127: MISSING PARAMS: Parameters are missing from title: object_size tests/test_validator.py:152: MISSING PARAMS: Parameters are missing from title: p1 OR p2 OR p3 -
68 А если что-то сломается? И такое учли! def test_something(...):
# noqa: allure-validator ... def some_fixture(): # noqa: allure-validator ... class TestS3GateBucket: # noqa: allure-validator ... -
69 • Найдено и исправлено около 50 проблемных тестов •
Используется в двух объемных тестовых репозиториях • Интегрирован как хук в pre-commit • Можно запускать руками • Практически нет накладных расходов Интеграция в команду -
70 • Ревью стало проще и быстрее • Соблюдение стандартов
команды • Улучшение качества тестовой отчетности и опыта работа с TestY Что мы получили? -
71 • Время на поддержку дополнительного инструмента • Периодические поломки
и частичные блокировки в работе Что мы отдаем взамен?