Оглавление
-
Часть 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):

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

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

Шаг, в котором есть ссылка на wiki-страницу с кодом, обычно содержит меньше текста, чем шаг, в котором нет такой ссылки. Это связано с тем, что действия и их логика переносятся из текстового поля в скрипт. Тест-кейсы с такими шагами получаются менее многословными, что уменьшает количество времени на ознакомление с ними и на их выполнение. Это, в свою очередь, снижает влияние bus factor’а на проекте.
Сравним шаг для проверки соответствия фактических данных ожидаемым в БД (например, после выполнения какого-либо метода или алгоритма):
1-й вариант
Описание: Анализ данных в таблице models
Шаг:
-
Перейти по пути в DBeaver: _local.smartphones.models
-
Открыть раздел Data таблицы models
-
Сравнить записи в таблице с ожидаемыми:
|
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, который я показывал выше, и добавим туда, на страницу, несколько записей:

Здесь прежде всего важно проверить CRUD: просмотр и изменение данных, хранящихся в таблице models. Опишу, как в данном случае можно применить скрипты.
Для всех тест-кейсов:
-
Скрипт для очистки проверяемых данных, выполняемый в предусловии, — на случай, если данные остались с предыдущих прогонов. Это также важно для предотвращения потенциально возникающей ошибки, не связанной с кодом приложения — срабатывание ограничения уникальности ключа:
delete from smartphones.models where "name" in ('X6', '16', 'Galaxy S23', '14', 'P60') and manufacturer in ('POCO', 'iPhone', 'Samsung', 'Xiaomi', 'Huawei') -
Скрипт для очистки проверяемых данных, выполняемый в постусловии — чистим за собой, чтобы не захламлять БД (такой же, как в предусловии).
Для каждого отдельного тест-кейса:
-
Создание:
-
Скрипт для проверки создания записей в таблице после их добавления на UI (описан выше, в начале раздела Использование проверочных скриптов в шагах);
-
-
Чтение:
-
Скрипт для добавления проверяемых данных, выполняемый в предусловии:
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)
-
Кстати, метаданные таблиц мы тоже проверяем автоматически:

Этот запрос также можно использовать с любой таблицей в СУБД типа SQL. Ему посвящена отдельная статья: https://habr.com/ru/articles/862562/
Использование артефактов тест-дизайна
Wiki-страницы пространства тестирования полезны не только для частичной автоматизации. Но и для структуризации большого количества тестовых данных. Бывают случаи, когда объект тестирования настолько комплексный, что даже после применения техник тест-дизайна все равно остается слишком много проверок. Я столкнулся с такой ситуацией при работе с алгоритмом, для которого предполагается передача 32-х входных параметров разных типов. Для одного из них валидными значениями считаются те, что относятся к определенному списку. Группа других — коэффициенты, для которых ожидаются значения в диапазоне от 0 до 1. Есть и группа строковых параметров, где каждый из них принимает лишь 1 валидное значение в текущей версии алгоритма. При этом 15 параметров — обязательны для заполнения перед каждым запуском, а остальные — опциональны.
После применения техник тест-дизайна получилось 111 проверок, необходимых для тестирования по поставленной задаче. 51 позитивная проверка и 60 — негативных. Позитивные проверки можно объединять и тестировать совместно в рамках 1-го шага, а вот каждую негативную проверку нужно выполнять отдельно. Это связано с тем, что в алгоритме единовременно возможно срабатывание только 1-го exception’а. После этого его выполнение завершается с ошибкой. С учетом подобных особенностей финальное количество наборов тестовых данных (ТД) составило 73. Мы их раскидали по 4-м тест-кейсам:
-
Все наборы ТД для позитивных проверок;
-
Все наборы ТД для негативных проверок;
-
Сокращенные наборы ТД для позитивных проверок;
-
Сокращенные наборы ТД для негативных проверок.
Тест-кейсы со всеми наборами мы с коллегой прогнали единожды, а сокращенные подготовили для регрессионного тестирования. При этом значения для сокращенных наборов подбираются с помощью отдельного SQL-скрипта случайным образом из пула всех тестовых значений. Такой подход позволяет, с одной стороны, экономить время, с другой, — снижает влияние парадокса пестицида.
Наименьшее количество шагов среди этих тест-кейсов — в том, который содержит сокращенные наборы ТД для позитивных проверок, — 6 шагов. 3 из них содержат наборы значений, которыми нужно заполнить параметры (остальные служат для проверки факта заполнения таблиц после прогона алгоритма). С учетом того, того, что 15 входных параметров являются обязательными для заполнения, каждый из шагов будет содержать примерно такой текст:
-
В селекте «Алгоритм» выбрать «Алгоритм 1»
-
Заполнить входные параметры:
-
param1:
1 -
param2:
0 -
param3:
0 -
param4:
Y -
param5:
simple-mode -
param6:
12 -
param7:
6 -
param8:
calendar -
param9:
history_value -
param10:
new_value -
param11:
history_table -
param12:
new_table -
param13:
value_table -
param14:
resource;area;supplier -
param15:
true
-
-
Нажать кнопку «Запустить». Перейти на страницу «Анализ выполнения алгоритмов»
Вам о чем-то говорят названия параметров выше и значения для них? Если нет, то это ожидаемо. Думаю, это хорошая иллюстрация того, как тестировщики видят нетривиальные тест-кейсы, написанные коллегами. При выполнении описанных проверок по-хорошему понимать формулировки необязательно, если шаг остается воспроизводимым. Однако в случае доработок функциональности, за которыми ожидается апдейт тест-кейсов, понимание становится необходимым. И если тестировщик не является автором такого тест-кейса, ему приходится обращаться к документации и осваивать её в большей степени целиком, чтобы понять контекст.
Использование связанной wiki-страницы упрощает и действия в шаге, и понимание контекста за счет возможности размещения артефакта тест-дизайна, где описаны не только значения для тестирования, но и логика их формирования. Шаг с таким оформлением будет выглядеть так:
-
В селекте «Алгоритм» выбрать «Алгоритм 1»
-
Заполнить входные параметры, используя ТД из wiki-страницы:
Ссылка на страницу -
Нажать кнопку «Запустить». Перейти на страницу «Анализ выполнения алгоритмов»
Примерно так и выглядит шаг из реального тест-кейса. На связанной 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 |
|
|
… |
… |
… |
… |
Схема связей артефактов тестирования в этом случае выглядит так:

Здесь прямоугольники — это wiki-страницы, овалы — разделы wiki-страницы, пятиугольник — тест-кейс. На странице «Общие данные для строковых параметров с 1-м валидным значением» содержатся значения для параметров param8 — param15. Это те, что:
Есть и группа строковых параметров, где каждый из них принимает лишь 1 валидное значение в текущей версии алгоритма.
В Confluence мы подтягиваем информацию для такой страницы с помощью макросов Excerpt и Excerpt Include. Получается наследование данных, которое также упрощает поддерживаемость.
Выглядит это примерно так (значения параметров param8 — param15 наследуются, остальные — отличаются):
Wiki-страница с тестовыми данными 1
Общие данные для строковых параметров с 1-м валидным значением
param8:
calendarparam9:
history_valueparam10:
new_valueparam11:
history_tableparam12:
new_tableparam13:
value_tableparam14:
resource;area;supplierparam15:
true
-
param1:
1 -
param2:
0 -
param3:
0 -
param4:
Y -
param5:
simple-mode -
param6:
12 -
param7:
6
Wiki-страница с тестовыми данными 2
Общие данные для строковых параметров с 1-м валидным значением
param8:
calendarparam9:
history_valueparam10:
new_valueparam11:
history_tableparam12:
new_tableparam13:
value_tableparam14:
resource;area;supplierparam15:
true
-
param1:
0.5 -
param2:
0.5 -
param3:
0.5 -
param4:
M -
param5:
advanced-mode -
param6:
24 -
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’ах. Тем не менее думаю, это подходящая иллюстрация того, что за небольшими тестовыми данными может скрываться объемный контекст тестирования. Артефакты тест-дизайна, связываемые с тест-кейсами, упрощают работу с задачами по тестированию, особенно с теми, где используется большое количество тестовых данных.

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

Продолжение — в 3-й части:
https://habr.com/ru/companies/axenix/articles/863542/
ссылка на оригинал статьи https://habr.com/ru/articles/863196/
Добавить комментарий