Сила связей в ручном тестировании. Часть 2: Связываем тест-кейсы с wiki-страницами

от автора

Оглавление

Использование проверочных скриптов

Проверочные скрипты, в частности, на SQL, значительно ускоряют выполнение проверок при тестировании методов (функций) бэкенда, которые изменяют данные. Автоматизация в данном случае обычно сводится к сравнению ожидаемого и фактического наборов данных (датасетов). Иногда без использования кода крайне тяжело качественно решить задачу. Например, на проекте, где я работаю, необходимо было сравнить результаты расчета разных версий оптимизатора в двух десятках таблиц. Записей было много. Ограничить используемый набор данных я не мог из-за сложной бизнес-логики с учетом отсутствия документации. Равно как и подготовить свои тестовые данные в разумный срок — по той же причине.

Для тестирования я использовал SQL-запрос, описанный ниже. Под каждую таблицу с отдельным набором данных в CTE expected (реальные наборы данных и названия таблиц с проекта заменены на те, что я подготовил для статьи):

with     expected as (         select *         from (             -- Описываем записи с ожидаемыми данными, включая названия столбцов после t в конце CTE             values                 ('16' , 'iPhone'),                 ('Galaxy S23', 'Samsung'),                 ('14' , 'Xiaomi'),                 ('P60' , 'Huawei'),                 ('X6' , 'POCO')         ) t("name", manufacturer)     ),     -- Ищем записи с фактическими данными по тем же столбцам, что заданы в предыдущем CTE     fact as (         select "name", manufacturer         from smartphones.models     ),     -- Ищем несоответствия между ожидаемым и фактическим наборами записей     mismatches as (         select             *,             -- Добавляем столбец с текстовой строкой к записям для упрощения идентификации             ' (Ожидаемая запись)' as status         from (             select *             from expected             except all             select *             from fact         ) sq1         union all         select             *,             ' (Фактическая запись)' as status         from (             select *             from fact             except all             select *             from expected         ) sq2         order by manufacturer, status -- Добавляем сортировку несоответствий при необходимости     ),     -- Собираем полученную информацию     info as (         select             (select count (*) from mismatches) as count_mismatches,             (select string_agg(                 concat_ws(', ', manufacturer, "name") || status             , E'\n') from mismatches) as mismatches,             (select count (*) from expected) as count_expected,             (select count (*) from fact) as count_fact     ) select * from info

Кстати, логика в этом запросе универсальна: его можно использовать для тестирования данных в любой таблице базы данных в СУБД типа SQL. Конечно, с предварительной заменой проверяемой таблицы, её столбцов и ожидаемого датасета. Если хотите попробовать применить такой запрос у себя на проекте — дайте знать, опишу, что нужно заменить.

Если датасеты идентичны, запрос показывает отсутствие несоответствий (mismatches):

imageAttach_12.10.2024_18.47_XB9e5k.png

Заменим, например, (16, ‘iPhone’) на (15, ‘iPhone’). Теперь запрос показывает наличие несоответствий и дает по ним информацию:

imageAttach_12.10.2024_18.54_Y3dT7e.png

Несоответствия также будут засчитаны при наличии лишних записей и/или при отсутствии ожидаемых:

imageAttach_12.10.2024_19.05_n69s9L.png

Шаг, в котором есть ссылка на wiki-страницу с кодом, обычно содержит меньше текста, чем шаг, в котором нет такой ссылки. Это связано с тем, что действия и их логика переносятся из текстового поля в скрипт. Тест-кейсы с такими шагами получаются менее многословными, что уменьшает количество времени на ознакомление с ними и на их выполнение. Это, в свою очередь, снижает влияние bus factor’а на проекте.

Сравним шаг для проверки соответствия фактических данных ожидаемым в БД (например, после выполнения какого-либо метода или алгоритма):


1-й вариант

Описание: Анализ данных в таблице models

Шаг:

  1. Перейти по пути в DBeaver: _local.smartphones.models

  2. Открыть раздел Data таблицы models

  3. Сравнить записи в таблице с ожидаемыми:

name

manufacturer

16

iPhone

Galaxy S23

Samsung

14

Xiaomi

P60

Huawei

Ожидаемый результат: Набор фактических записей в таблице models соответствует ожидаемому


2-й вариант

Описание: Анализ данных в таблице model

Шаг: Убедиться, что фактический набор записей соответствует ожидаемому, выполнив SQL-запрос:
Ссылка на wiki-страницу с кодом

Ожидаемый результат: Значение в столбце count_mismatches: 0


Сумма слов в разделах Шаг и Ожидаемый результат:

  • В 1-м варианте: ~35 слов с учетом чисел в столбце name;

  • В 2-м варианте: 15 слов без учета словосочетания Ссылка на wiki-страницу с кодом — вместо него, соответственно, предполагается ссылка. БД, в которой необходимо выполнить запрос (smartphones), — указывается на wiki-странице.

Разница — двукратная. И это пример с 1-м шагом и 4-мя проверяемыми записями. На реальном проекте с сотнями тест-кейсов и тысячами проверяемых значений получается крайне существенная экономия ресурсов. Выполнение запроса также занимает меньше времени, чем выполнение 3-х действий, описанных в 1-м варианте.

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

Pasted image 20241111205445.png

Здесь прежде всего важно проверить CRUD: просмотр и изменение данных, хранящихся в таблице models. Опишу, как в данном случае можно применить скрипты.

Для всех тест-кейсов:

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

    delete from smartphones.models where "name" in ('X6', '16', 'Galaxy S23', '14', 'P60') and manufacturer in ('POCO', 'iPhone', 'Samsung', 'Xiaomi', 'Huawei')
  • Скрипт для очистки проверяемых данных, выполняемый в постусловии — чистим за собой, чтобы не захламлять БД (такой же, как в предусловии).

Для каждого отдельного тест-кейса:

  • Создание:

  • Чтение:

    • Скрипт для добавления проверяемых данных, выполняемый в предусловии:

      insert into smartphones.models     (id, "name", manufacturer)  values     (1, '16',           'iPhone'),     (2, 'Galaxy S23',   'Samsung'),     (3, '14',           'Xiaomi'),     (4, 'P60',          'Huawei'),     (5, 'GT',           'realme'),     (6, 'X6',           'POCO');
  • Обновление:

    • Скрипт для добавления проверяемых данных, выполняемый в предусловии (описан выше, в Чтении);

    • Скрипт для проверки изменения записей в таблице после их изменения на UI (запрос, аналогичный тому, что описан в начале раздела Использование проверочных скриптов в шагах, но с корректировкой одного из значения на изменяемое);

  • Удаление:

    • Скрипт для добавления проверяемых данных, выполняемый в предусловии (описан выше, в Чтении);

    • Скрипт для проверки отсутствия записей в таблице после их удаления на UI:

      select exists (select * from smartphones.models)

Кстати, метаданные таблиц мы тоже проверяем автоматически:

imageAttach_12.10.2024_20.30_EGQy8i.png

Этот запрос также можно использовать с любой таблицей в СУБД типа SQL. Ему посвящена отдельная статья: https://habr.com/ru/articles/862562/

Использование артефактов тест-дизайна

Wiki-страницы пространства тестирования полезны не только для частичной автоматизации. Но и для структуризации большого количества тестовых данных. Бывают случаи, когда объект тестирования настолько комплексный, что даже после применения техник тест-дизайна все равно остается слишком много проверок. Я столкнулся с такой ситуацией при работе с алгоритмом, для которого предполагается передача 32-х входных параметров разных типов. Для одного из них валидными значениями считаются те, что относятся к определенному списку. Группа других — коэффициенты, для которых ожидаются значения в диапазоне от 0 до 1. Есть и группа строковых параметров, где каждый из них принимает лишь 1 валидное значение в текущей версии алгоритма. При этом 15 параметров — обязательны для заполнения перед каждым запуском, а остальные — опциональны.

После применения техник тест-дизайна получилось 111 проверок, необходимых для тестирования по поставленной задаче. 51 позитивная проверка и 60 — негативных. Позитивные проверки можно объединять и тестировать совместно в рамках 1-го шага, а вот каждую негативную проверку нужно выполнять отдельно. Это связано с тем, что в алгоритме единовременно возможно срабатывание только 1-го exception’а. После этого его выполнение завершается с ошибкой. С учетом подобных особенностей финальное количество наборов тестовых данных (ТД) составило 73. Мы их раскидали по 4-м тест-кейсам:

  1. Все наборы ТД для позитивных проверок;

  2. Все наборы ТД для негативных проверок;

  3. Сокращенные наборы ТД для позитивных проверок;

  4. Сокращенные наборы ТД для негативных проверок.

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

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

  1. В селекте «Алгоритм» выбрать «Алгоритм 1»

  2. Заполнить входные параметры:

    1. param1:
      1

    2. param2:
      0

    3. param3:
      0

    4. param4:
      Y

    5. param5:
      simple-mode

    6. param6:
      12

    7. param7:
      6

    8. param8:
      calendar

    9. param9:
      history_value

    10. param10:
      new_value

    11. param11:
      history_table

    12. param12:
      new_table

    13. param13:
      value_table

    14. param14:
      resource;area;supplier

    15. param15:
      true

  3. Нажать кнопку «Запустить». Перейти на страницу «Анализ выполнения алгоритмов»

Вам о чем-то говорят названия параметров выше и значения для них? Если нет, то это ожидаемо. Думаю, это хорошая иллюстрация того, как тестировщики видят нетривиальные тест-кейсы, написанные коллегами. При выполнении описанных проверок по-хорошему понимать формулировки необязательно, если шаг остается воспроизводимым. Однако в случае доработок функциональности, за которыми ожидается апдейт тест-кейсов, понимание становится необходимым. И если тестировщик не является автором такого тест-кейса, ему приходится обращаться к документации и осваивать её в большей степени целиком, чтобы понять контекст.

Использование связанной wiki-страницы упрощает и действия в шаге, и понимание контекста за счет возможности размещения артефакта тест-дизайна, где описаны не только значения для тестирования, но и логика их формирования. Шаг с таким оформлением будет выглядеть так:

  1. В селекте «Алгоритм» выбрать «Алгоритм 1»

  2. Заполнить входные параметры, используя ТД из wiki-страницы:
    Ссылка на страницу

  3. Нажать кнопку «Запустить». Перейти на страницу «Анализ выполнения алгоритмов»

Примерно так и выглядит шаг из реального тест-кейса. На связанной wiki-странице у нас содержится та же информация, что во 2-м пункте, в предыдущем варианте. Здесь хранится 1 тестовый набор данных для каждого конкретного шага. С этой wiki-страницей связана другая, где хранятся сведения по логике формирования тестовых данных и таблица с описанием проверок. Покажу, как она выглядит для шага, описанного выше, но, для краткости, с информацией по 2-м параметрам вместо 15-ти:

Параметр

Значение

Тип

ТД

param1

Не заполнен

Негативный

Ссылка на wiki-страницу 1

— 0.1

Негативный

Ссылка на wiki-страницу 2

0

Позитивный

Ссылка на wiki-страницу 3

0.3

Позитивный

Ссылка на wiki-страницу 4

1

Позитивный

Ссылка на wiki-страницу 5

1.1

Негативный

Ссылка на wiki-страницу 6

param4

Не заполнен

Негативный

Ссылка на wiki-страницу 7

D

Позитивный

Ссылка на wiki-страницу 8

W

Позитивный

Ссылка на wiki-страницу 9

M

Позитивный

Ссылка на wiki-страницу 10

Q

Позитивный

Ссылка на wiki-страницу 11

Y

Позитивный

Ссылка на wiki-страницу 12

K

Негативный

Ссылка на wiki-страницу 13

Схема связей артефактов тестирования в этом случае выглядит так:

imageAttach_13.10.2024_19.00_1twm7X.png

Здесь прямоугольники — это wiki-страницы, овалы — разделы wiki-страницы, пятиугольник — тест-кейс. На странице «Общие данные для строковых параметров с 1-м валидным значением» содержатся значения для параметров param8 — param15. Это те, что:

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

В Confluence мы подтягиваем информацию для такой страницы с помощью макросов Excerpt и Excerpt Include. Получается наследование данных, которое также упрощает поддерживаемость.

Выглядит это примерно так (значения параметров param8 — param15 наследуются, остальные — отличаются):


Wiki-страница с тестовыми данными 1

Общие данные для строковых параметров с 1-м валидным значением

  1. param8:
    calendar

  2. param9:
    history_value

  3. param10:
    new_value

  4. param11:
    history_table

  5. param12:
    new_table

  6. param13:
    value_table

  7. param14:
    resource;area;supplier

  8. param15:
    true

  1. param1:
    1

  2. param2:
    0

  3. param3:
    0

  4. param4:
    Y

  5. param5:
    simple-mode

  6. param6:
    12

  7. param7:
    6


Wiki-страница с тестовыми данными 2

Общие данные для строковых параметров с 1-м валидным значением

  1. param8:
    calendar

  2. param9:
    history_value

  3. param10:
    new_value

  4. param11:
    history_table

  5. param12:
    new_table

  6. param13:
    value_table

  7. param14:
    resource;area;supplier

  8. param15:
    true

  1. param1:
    0.5

  2. param2:
    0.5

  3. param3:
    0.5

  4. param4:
    M

  5. param5:
    advanced-mode

  6. param6:
    24

  7. param7:
    12


Кстати, диаграмма иллюстрирует тезис, который я описал в разделе Суть подхода:

Это позволяет структурировать информацию атомарно: каждая страница содержит ровно столько сведений, сколько того требует контекст. Если нужно больше — можно переходить по указанным ссылкам.

Итак, связывание wiki-страниц с шагами тест-кейса позволяет:

  • Сократить количество текста в шагах тест-кейсов;

  • Переиспользовать повторяющиеся данные;

  • Добавить справочную информацию по тестируемой части функциональности, что упрощает понимание документации с точки зрения контекста тестирования. Мы также можем указать, что мы НЕ тестируем и по какой причине;

  • Сделать объекты более атомарными за счет распределения информации между тест-кейсом, wiki-страницей с артефактом тест-дизайна и wiki-страницей с тестовыми данными. Эти объекты связываются линками. Такой подход упрощает работу с информацией;

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

Данные запросов и сообщений для интеграционного тестирования

Несмотря на то, что в json’е по названию ключей и значениям для них можно понять бизнес-смысл запроса, — эта информация все еще содержит мало сведений о контексте тестирования. Почему это может быть важно? Предположим, по шагу тест-кейса необходимо отправить такой http-запрос:

Запрос
POST https://smartphones.ru/catalog  {   "smartphones": [     {       "brand": "TechOne",       "model": "X100 Pro",       "release_date": "2024-03-15",       "specs": {         "display": "6.7 inch OLED",         "processor": "Octa-core 3.2GHz",         "ram": "12GB",         "storage": "256GB",         "camera": {           "rear": "108MP + 12MP + 5MP",           "front": "32MP"         },         "battery": "4500mAh",         "os": "Android 14"       },       "price": 899,       "colors": ["Black", "Silver", "Aurora Blue"]     },     {       "brand": "Photonix",       "model": "PX-Magic",       "release_date": "2024-05-22",       "specs": {         "display": "6.5 inch AMOLED",         "processor": "Hexa-core 2.9GHz",         "ram": "8GB",         "storage": "128GB",         "camera": {           "rear": "64MP + 16MP",           "front": "20MP"         },         "battery": "4200mAh",         "os": "Photonix OS 3.0"       },       "price": 649,       "colors": ["Midnight Black", "Sunrise Gold", "Ocean Green"]     },     {       "brand": "NebulaTech",       "model": "Nebula Z2",       "release_date": "2024-02-10",       "specs": {         "display": "6.8 inch Super Retina",         "processor": "Quad-core 3.5GHz",         "ram": "16GB",         "storage": "512GB",         "camera": {           "rear": "200MP + 50MP + 12MP",           "front": "40MP"         },         "battery": "5000mAh",         "os": "Nebula OS 5.0"       },       "price": 1099,       "colors": ["Space Gray", "Starlight White", "Cosmic Red"]     },     {       "brand": "GalaxyPrime",       "model": "Galaxy Ultra S",       "release_date": "2024-06-30",       "specs": {         "display": "7.1 inch Dynamic AMOLED",         "processor": "Deca-core 3.8GHz",         "ram": "12GB",         "storage": "256GB",         "camera": {           "rear": "150MP + 24MP + 10MP",           "front": "32MP"         },         "battery": "4800mAh",         "os": "GPrime OS 4.0"       },       "price": 999,       "colors": ["Jet Black", "Pearl White", "Emerald Green"]     },     {       "brand": "Solarion",       "model": "Helios Max",       "release_date": "2024-07-15",       "specs": {         "display": "6.9 inch Fluid AMOLED",         "processor": "Octa-core 3.1GHz",         "ram": "8GB",         "storage": "128GB",         "camera": {           "rear": "100MP + 12MP + 8MP",           "front": "24MP"         },         "battery": "4700mAh",         "os": "Solarion UI 6.0"       },       "price": 749,       "colors": ["Solar Black", "Sunburst Orange", "Twilight Blue"]     },     {       "brand": "HyperNova",       "model": "NovaX Edge",       "release_date": "2024-08-25",       "specs": {         "display": "6.4 inch OLED",         "processor": "Hexa-core 2.8GHz",         "ram": "6GB",         "storage": "64GB",         "camera": {           "rear": "48MP + 12MP",           "front": "16MP"         },         "battery": "4100mAh",         "os": "Hyper OS 2.0"       },       "price": 499,       "colors": ["Graphite Gray", "Lunar Silver", "Coral Pink"]     }   ] }

Этот json мне сгенерировала нейросеть. Понятия не имею, что там за данные. Тем не менее он вполне сгодится в качестве иллюстрации. На тест может прийти задача, по которой необходимо отправлять запросы с телом подобного размера. Составить проверки для такого случая будет проще после применения техник тест-дизайна. Однако, как правило, результатом подготовки тестовых артефактов по задаче становится именно набор с тест-кейсами. Артефакты тест-дизайна при таком подходе считаются полезными, но необязательными. Они могут храниться локально или в wiki, но без каких-либо связей.

Тем временем привязанная к тест-кейсам таблица с описанием проверок дает значительно больше контекста по объекту тестирования. Это упрощает работу, что, в свою очередь, приводит к экономии ресурсов. Автору тест-кейса, который составил описанный выше http-запрос, вполне может через некоторое время прийти задача на расширение тестового набора в связи, например, с доработками по связанной функциональности. И если в рамках этой активности необходимо будет править тело запроса, то без артефакта тест-дизайна сделать это будет затруднительно. Особенно по прошествии времени, после переключения на другие задачи. В конце концов с объемными json’ами зачастую неудобно работать в TMS. Например, в Xray, в поле шага, отображается только часть текста и скролл.

Рассмотрим еще пример. В рамках тест-кейса с позитивными проверками необходимо отправить 2 http-запроса:

{     "smartphones": [         {             "manufacturer": "Nothing",             "model": "Nothing Phone 2",             "price": 65000         }     ] }

и

{     "smartphones": [         {             "manufacturer": "Huawei",             "model": "P60",             "price": 55000,             "country": "China"         },         {             "manufacturer": "Xiaomi",             "model": "Xiaomi Redmi Note 11 Pro 4G",             "price": 25000         }     ] }

Выглядит значительно проще. Тем не менее непонятно, почему в теле 1-го запроса — 1 объект в массиве smartphones, а во 2-м — 2 объекта. И почему во 2-м запросе, в 1-м объекте массива — 4 параметра, а во 2-м — 3. И должно ли все быть именно так. И как эти данные соотносятся с негативными проверками.

Несмотря на то, что эти тела запросов — небольшие, даже они могут заставить задуматься автора тест-кейса, оптимально ли составлены проверки. Не говоря уже о коллегах. Дополнительную информацию в этом случае можно извлечь из таблицы с проверками, которая хранится на привязанной к тест-кейсу wiki-странице:

Номер проверки

Значения

Тип

Проверка

Датасет

Количество объектов в массиве

1

0

Негативный

Сообщение об ошибке отсутствия объектов в массиве

Ссылка на страницу с датасетом 1

2

1

Позитивный

1 объект в массиве

Ссылка на страницу с датасетом 2

3

2

Позитивный

Максимальное количество объектов согласно требованию + несколько объектов в массиве

Ссылка на страницу с датасетом 3

4

3

Негативный

Возврат сообщения о превышении количества объектов в массиве

Ссылка на страницу с датасетом 4

Количество строк в объекте массива

5

0

Негативный

Сообщение об ошибке отсутствия обязательных ключей

Связь с проверками 11 и 12

Ссылка на страницу с датасетом 5

6

1

Негативный

Сообщение об ошибке отсутствия обязательных ключей

Связь с проверками 11 и 12

Ссылка на страницу с датасетом 6

7

2

Негативный

Сообщение об ошибке недостаточного количества строк в объекте

Ссылка на страницу с датасетом 7

8

3

Позитивный

Минимальное количество строк в объекте согласно требованию

Связь с проверкой 16

Ссылка на страницу с датасетом 2

9

5

Позитивный

Максимальное количество строк в объекте согласно требованию

Ссылка на страницу с датасетом 3

10

6

Негативный

Переход верхней границы количества строк в объекте

Ссылка на страницу с датасетом 9

Ключи в объекте массива

11

Нет model, но есть manufacturer

Негативный

Сообщение об ошибке отсутствия обязательного ключа model

Ссылка на страницу с датасетом 10

12

Нет manufacturer, но есть model

Негативный

Сообщение об ошибке отсутствия обязательного ключа manufacturer

Ссылка на страницу с датасетом 11

13

Есть и manufacturer, и model, но нет других ключей

Негативный

Сообщение об ошибке недостаточного количества строк в объекте

Связь с проверкой 7

Ссылка на страницу с датасетом 12

14

2 ключа manufacturer и 1 ключ model

Негативный

Сообщение об ошибке наличия 1-й пары дублей

Дублируется обязательный ключ

Ссылка на страницу с датасетом 13

15

2 ключа model и 1 ключ manufacturer

Избыточно. Кейс покрывается проверками 14 (строка-дубль) и 17 (есть 3 строки, включая 2 обязательные)

16

2 ключа model и продублированный необязательный ключ

Негативный

Сообщение об ошибке наличия 2-х пар дублей в случае, когда дубль ключа — один

Дублируется и обязательный, и необязательный ключи

Ссылка на страницу с датасетом 14

17

3 ключа model и необязательный ключ с 2-мя дублями

Негативный

Сообщение об ошибке наличия 2-х пар дублей в случае, когда дублей ключа — несколько

Ссылка на страницу с датасетом 15

18

1 ключ manufacturer, и 1 ключ model, и еще 1 ключ

Позитивный

Минимальное удовлетворение требований

Связь с проверкой 8

Ссылка на страницу с датасетом 2

19

1 ключ manufacturer, и 1 ключ model, и еще 3 ключа

Позитивный

Связь с проверкой 9

Ссылка на страницу с датасетом 3

Значения ключа manufacturer

20

Я описал здесь не все, что можно посмотреть в json’ах. Тем не менее думаю, это подходящая иллюстрация того, что за небольшими тестовыми данными может скрываться объемный контекст тестирования. Артефакты тест-дизайна, связываемые с тест-кейсами, упрощают работу с задачами по тестированию, особенно с теми, где используется большое количество тестовых данных.

Pasted image 20241016234121.png

Wiki-страницы полезны и при работе с сообщениями MQ. Все, что описано для http-запросов, применимо и к таким сообщениям. Также в случае с MQ поддерживаемость можно упростить через наследование страницы с хэдерами на страницах с телом сообщения:

imageAttach_15.10.2024_23.17_hRsk6p.png

Продолжение — в 3-й части:
https://habr.com/ru/companies/axenix/articles/863542/


ссылка на оригинал статьи https://habr.com/ru/articles/863196/