Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Moscow Python Meetup №96 Кирилл Сосновских (YAD...

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

November 28, 2024
Tweet

More Decks by Moscow Python Meetup

Other Decks in Programming

Transcript

  1. • Работаю в YADRO автоматизатором тестирования в отделе TATLIN.OBJECT •

    Пишу на Python, знаю что такое AST, вкусный мармелад и как написать свой линтер Кирилл Сосновских
 Automation QA

  2. 4 О какой рутине речь? Review Merge Но у меня

    немного иначе Code Test Report Test Storag e
  3. 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() Принятые в команде конвенции Рассмотрим на примере
  4. 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() Принятые в команде конвенции Заголовок всему голова
  5. 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() Принятые в команде конвенции Был один – стало три
  6. 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() Принятые в команде конвенции Пора программировать
  7. 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() Принятые в команде конвенции По шажку
  8. 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() Представим, что мы разработчик Это я написал!
  9. 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() Отчаянно исправляем Ну сейчас то точно все будет хорошо!
  10. 21 Пусть рутиной занимается робот! Разве может робот написать симфонию…

    Его обязанности: • Анализировать исходный код • Находить проблемные по нашему мнению места в коде • Бить разработчика по рукам • Не просить зарплату
  11. 22 @allure.title( "Create bucket with valid name length " "(s3_client={s3_client},

    length={length})" ) Код как данные Знакомый нам заголовок
  12. 23 @allure.title( "Create bucket with valid name length " "(s3_client={s3_client},

    length={length})" ) Код как данные Обобщим! @allure.title( "Заголовок описывающий тест (p1={p1}, p2={p2}, ...)" )
  13. 26 def length_parametrizer() -> list[int]: # Очень сложный код return

    lengths @pytest.mark.parametrize("length", length_parametrizer()) def test_s3_create_bucket_with_valid_length(...): ... Как еще может выглядеть параметризация? Это стоит учесть
  14. 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, ): ... Как еще может выглядеть параметризация? Вариантов много!
  15. 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, ): ... Как еще может выглядеть параметризация? Совместим оба кейса!
  16. 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() Код как данные Сложновато!
  17. 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() Заголовок наш выбор! И все же заголовок всему голова!
  18. 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() Что за параметр такой? Опять что-то упустили…
  19. 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 Немного о фикстурах Что-то новенькое!
  20. 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 Немного о фикстурах Снова параметризация?
  21. 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 Немного о фикстурах Знакомое явление…
  22. 37 Abstract Syntax Tree root = r (6 + 4)

    // 5 Все как в математике
  23. 38 Abstract Syntax Tree root = // r 5 6

    + 4 Соблюдаем приоритетность операций!
  24. 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)) От слов к действиям Наконец-то!
  25. 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=[] ) Тест как объект И это только один тест…
  26. 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=[] ) Тест как объект Имя теста
  27. 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=[] ) Тест как объект Аргументы теста
  28. 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=[] ) Тест как объект Список декораторов
  29. 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=[] ) ], Декораторы теста Немного углубимся
  30. 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=[] ) Декомпозируем декоратор Имя декоратора
  31. 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=[] ) Декомпозируем декоратор Параметр и его значения
  32. 51 Что делать с хуками? Как же много всего нужно

    учесть! Все просто, но требуются допущения: • Будем анализировать только pytest_generate_tests хуки • Не будем анализировать логику самого хука • Будем учитывать все параметры независимо от их использования в тестах • Перечисленные в хуке параметры будем считать фикстурами
  33. 52 А с фикстурами что? Кажется все не так просто…

    Аналогично тестам, но есть нюансы: • Фикстуры могут быть где угодно • Фикстуры могут переопределять друг друга • Фикстуры могут быть зависимы от других фикстур • Фикстуры могут быть определены во внешних плагинах
  34. 54 Этапы работы линтера Что анализируем? Сбор исходных файлов Сбор

    AST объектов Определени е связей Валидация • Путей к файлам с тестами • Путей к файлам с фикстурами • Путей к файлам плагинов
  35. 55 Этапы работы линтера Формируем данные удобным для нас образом

    Сбор исходных файлов Сбор AST объектов Определени е связей Валидация • Представляющих тесты • Представляющих фикстуры • Представляющих pytest-хуки
  36. 56 Этапы работы линтера Получаем единый контекст Сбор исходных файлов

    Сбор AST объектов Определени е связей Валидация • Для зависимых друг от друга фикстур • Для тестов с их параметрами и фикстурами, в том числе динамическими
  37. 57 Этапы работы линтера Находим проблемы Сбор исходных файлов Сбор

    AST объектов Определени е связей Валидация • Уникальности заголовков • Заполненности заголовков • Наличия или отсутствия в заголовках необходимых параметров
  38. 59 Смотрим что получилось Даже запускаться само будет! allure-validator <путь

    к тестам> [--plugins, --files] - repo: https://git.frostfs.info/TrueCloudLab/allure-validator rev: <ревизия> hooks: - id: allure-validator args: [ "<путь к тестам>", ["--plugins ...", "--files ..."] ] или
  39. 60 Смотрим что получилось Опциональные параметры allure-validator <путь к тестам>

    [--plugins, --files] - repo: https://git.frostfs.info/TrueCloudLab/allure-validator rev: <ревизия> hooks: - id: allure-validator args: [ "<путь к тестам>", ["--plugins ...", "--files ..."] ] или
  40. 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
  41. 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
  42. 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
  43. 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
  44. 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
  45. 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
  46. 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
  47. 68 А если что-то сломается? И такое учли! def test_something(...):

    # noqa: allure-validator ... def some_fixture(): # noqa: allure-validator ... class TestS3GateBucket: # noqa: allure-validator ...
  48. 69 • Найдено и исправлено около 50 проблемных тестов •

    Используется в двух объемных тестовых репозиториях • Интегрирован как хук в pre-commit • Можно запускать руками • Практически нет накладных расходов Интеграция в команду
  49. 70 • Ревью стало проще и быстрее • Соблюдение стандартов

    команды • Улучшение качества тестовой отчетности и опыта работа с TestY Что мы получили?
  50. 71 • Время на поддержку дополнительного инструмента • Периодические поломки

    и частичные блокировки в работе Что мы отдаем взамен?