Хабрахабр

Белый ящик Пандоры

Обсуждая тестирование, чаще всего спикеры говорят об особенностях подхода, известного как «черный ящик». Но здесь мы поговорим о противоположном сценарии — «белом ящике», позволяющем формулировать вопросы к коду, понимая его внутреннюю структуру.

В основе статьи — расшифровка доклада Никиты Макарова (Одноклассники) с нашей декабрьской конференции Heisenbug 2017 Moscow.

Теория

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

Мы как бы присоединяемся к нему — видим и тестируем ее так же.

Это все классно, но почему-то очень мало говорится про белый ящик.

Что такое тестирование белого ящика? Однажды мне и самому стало интересно, почему так.

Определение белого ящика

Я полез разбираться. Начал искать источники. Качество русскоязычных оказалось очень низким, переведенных с английского на русский — чуть выше. И я добрался до англоязычных источников — до самого Гленфорда Майерса (G. Myers), который написал замечательную книгу «The Art of Software Testing».

Two of the most prevalent strategies include black-box testing and white-box testing…» Буквально во второй главе  автор начинает говорить про тестирование белого ящика:
«To combat the challenges associated with testing economics, you should establish some strategies before beginning.

Перевод

Существуют две превалирующие стратегии: тестирование черного и белого ящика. Чтобы оставаться в рамках разумного в плане затрат, связанных с тестированием, перед тем как начать, вы должны выработать какую-то стратегию.

В конце в словаре Майерс дает некое определение тестированию белого ящика:
«White-box testing — A type of testing in which you examine the internal structure of a program».

Перевод

Тестирование белого ящика — это такой тип тестирования, в котором вы исследуете внутреннюю структуру программы.

Что же на практике? Майерс предлагает строить тестовые сценарии, ориентируясь на покрытие:

  • Statement coverage — покрытие операторов в коде;
  • Decision coverage — покрытие решений;
  • Condition coverage — покрытие условий;
  • Decision-condition coverage — покрытие условий и решений;
  • Multiple-condition coverage — комбинаторное покрытие условий и решений.

Все, о чем говорит Майерс, было 35 лет назад. Какой софт писали тогда и какой — сейчас? С какими кодовыми базами работали тогда — и сейчас? Очень многое изменилось. Покрытие — это, конечно, хорошо, и для его измерения есть много инструментов, о которых мы поговорим ниже. Но покрытие — это далеко не все. Особенно с учетом того, что мы живем в мире распределенных систем, где браслет с руки человека пробрасывает данные через телефон в облачные сервисы.

Мы смотрим в код, понимаем структуру и зависимости, которые есть в этом коде, задаем вопросы, делаем выводы и проектируем тесты на основе этих данных. Что сейчас нужно понимать под тестированием белого ящика? Это и есть наш профит. Мы выполняем эти тесты вручную или автоматически и на их основе получаем новые данные о состоянии нашей системы — о том, как она может или не может работать.

Зачем нужен белый ящик?

Зачем нам всем этим заниматься, если у нас есть черный ящик — то есть то, как пользователь видит систему? Ответ очень простой: жизнь сложна.

Это стек вызовов обычного современного энтерпрайзного приложения, написанного на языке Java:

На любом другом языке это будет выглядеть примерно так же. Не только на Java все так многословно и обильно. Что здесь есть?

Здесь есть web-фреймворк и еще один web-фреймворк (потому что в 2017 году нельзя просто так взять и написать энтерпрайзное приложение на одном web-фреймворке). Здесь есть вызовы веб-сервера; security framework-а, который делает авторизацию, аутентификацию, проверяет права и все остальное. И здесь есть маленький желтый квадратик — это один вызов бизнес-логики. Здесь есть фреймворки для работы с базой данных и преобразования объектов в столбцы, таблицы, колонки и все остальное. Все, что под и над ним, происходит в вашем приложении каждый раз.

А иногда вам это очень надо, особенно когда поведение пользователей меняет что-то в security, пользователя перенаправляют в какие-то другие места или что-то происходит в базе данных. Пытаясь подобраться к этой штуке где-то снаружи с черным ящиком (как это видит пользователь), вы очень много чего можете не протестировать. Именно поэтому нужно залезать внутрь — в белый ящик. Черный ящик не позволяет вам этого сделать.

Давайте посмотрим на практике. Как это делать?

Практика

Чтобы не было неправильных или завышенных ожиданий, давайте с самого начала проясним некоторые детали:

  • Готовых рецептов не будет. Вообще. Все, что я буду показывать, требует прикладывания напильника, рук и головы.
  • Многое зависит от контекста. Я пришел из Java-разработки (я этим занимаюсь уже достаточно давно). У нас есть свои инструменты. Одни могут показаться чудесными, другие — уродливыми. Некоторые из них не могут или не должны существовать в вашем контексте. Это нормально. Я пришел не показывать инструменты, а поделиться идеями. Именно поэтому все мои примеры упрощены до предела.
  • Чтобы вы могли заниматься всем этим с вашей командой разработки, нужно иметь на нее влияние. Что я под этим понимаю? Вы должны уметь читать код, который пишут разработчики. Вы должны уметь разговаривать с ними на одном языке. Без этого всем, о чем я расскажу дальше, заниматься не получится.

Чтобы мой дальнейший рассказ был более-менее структурированным, я разбил его на три уровня. Начнем с самого простого — easy level.

Easy level

Как я говорил, мы смотрим в код и видим:

  • код не отформатирован;
  • код написан не по гайдлайнам;
  • наименования методов, классов и переменных не соответствует тому, что принято в компании;
  • код написан стилистически неправильно (опять же, гайдлайны не соответствуют);
  • любой статический анализатор кода найдет кучу стандартных проблем для вашего языка;
  • unit-тесты на код либо отсутствуют, либо написаны так, что не выдерживают критики.

Исправление этого — самая первая и простая штука, которую можно сделать в области тестирования белого ящика. Со всем этим замечательно справляются инструменты статического анализа кода, которые сегодня уже достаточно комплексные — типа Sonar для Java и аналогов для ваших языков (на самом деле Sonar мультиязыковой и подходит практически всем).

Про это есть куча интересных докладов. Я не хочу долго здесь задерживаться.

Medium level

Средний уровень сложности отличается масштабом. Когда вы работаете в маленькой компании или команде, вы единственный тестировщик, у вас есть три-четыре разработчика (как и в среднем по отрасли), 100 тыс. строк кода на всех, а код-ревью осуществляется метанием тапка ведущего разработчика в того, кто провинился, — какие-то специальные инструменты вам не нужны. Но так бывает редко.

А размер кодовой базы начинается с миллиона строк кода. Большие успешные проекты обычно «размазаны» на несколько офисов и команд разработки.

Когда в проекте очень много кода, разработчики начинают выстраивать формальные правила, по которым пишется этот код:

  • код должен попадать в определенные места, в определенные пакеты;
  • код должен быть оформлен правильно;
  • он должен быть унаследован от определенного класса, в нем должен быть правильный логгер, расставлены необходимые аннотации, чтобы все метрики на продакшене правильно считались и собиралась статистика, эксепшены отправлялись туда, куда надо.

Иными словами, по мере роста объема кода возникают формальные правила, которые можно проверять. Соответственно, появляются инструменты, которые позволяют это делать.

Давайте посмотрим на примере.

ArchUnit

Исходный код примера

Так изнутри проекта ArchUnit позволяет проверять, что в нем соблюдается «санитарный минимум». ArchUnit позволяет в виде более-менее проблемно-ориентированного языка описывать формальные правила того, что должно или не должно быть в коде, и запихивать их в виде стандартов unit-тестов внутрь каждого проекта.

Итак, у нас есть правило ArchRuleDefenition:

@Test public void testNoDirectUsagesOfSelenium()

Правило говорит, что ни в одном классе (.noClasses()), который находится в соответствующим пакете с тестами (org.example.out.test), не должно быть обращений напрямую к внутренностям Selenium (..org.openqa.selenium..).

Он замечательно падает: Давайте запустим этот тест.

Что более ценно, в виде стек-трейса он показывает все строчки, где это правило не соблюдается. При этом он пишет, что у нас нарушено правило (когда класс, находящийся в таком-то пакете, стучится в классы, которые находятся в другом пакете).

Но у него есть один недостаток: он проверяет все тогда, когда код уже написан и куда-то закоммитчен (то есть сработает либо commit hook, который отклонит этот коммит, либо еще что-то). ArchUnit — замечательный инструмент, который позволяет встраивать подобные штуки в CI/CD цикл, то есть писать внутри проекта тесты, которые проверяют какие-то архитектурные правила. А бывают ситуации, когда нужно, чтобы плохой код вообще нельзя было написать.

Annotation Processing

Исходный код примера:
На прошлом Heisenbug-е летом 2017 мой коллега по цеху из Яндекса, Кирилл Меркушев, рассказывал о том, как кодогенерация решает проблемы автоматизации тестирования. Кто не смотрел его выступление — пожалуйста, посмотрите, видео есть тут.

Она позволяет не только создавать код, который не хочется писать, но и запрещать создание кода, который не должен быть написан. На самом деле кодогенерация может решить много проблем. Давайте посмотрим, как это работает.

У меня есть проект, где описана пара процессоров аннотаций, которые специфичны для мира Java-разработки, — в частности, аннотация Pojo. Большая часть кодогенерации работает на процессинге аннотаций. Отцы-основатели Java сейчас думают о том, чтобы ввести структуры в язык программирования. В программах на Java нет такого понятия как структуры. Но мы смогли выкрутиться — у нас есть Pojo (plain old java object), то есть объекты с полями, геттерами, сеттерами, но в них больше ничего нет — никакой логики. В Cи это уже было, у нас — еще нет (хотя прошло больше 40 лет).

И у меня есть два процессора таких аннотаций. У меня есть аннотация, которая характеризует собой объект Pojo, а также аннотация, которая характеризует собой Helper — это объект без состояния, в который напиханы всякие методы процедурного рода (чистая бизнес-логика).

Аналогично действует процессор аннотаций Helper (вот ссылка на аннотации и процессоры аннотаций). Процессор аннотаций Pojo ищет в коде соответствующие аннотации, а когда находит, проверяет код на соответствие тому, что является (или не является) Pojo.

У меня есть маленький проект, я запускаю в нем компиляцию: Как это все работает?

Я вижу, что оно даже не компилируется:

Это происходит потому, что в этом проекте содержится код, который нарушает правила:

package a.b.c;
import annotations.Pojo;
@Pojo
public class AnotherFailed { private long point;
}

В отличие от предыдущего примера, эта штука встраивается внутрь среды разработки, внутрь continuous integration, то есть позволяет охватывать больший контур внутри CI/CD-цикла.

Nightmare level

Когда вы наигрались на предыдущих уровнях, вам хочется чего-то большего.

Покрытие кода

Исходный код примера

Они есть практически для каждого языка программирования. Для измерения покрытия кода, с тех пор, как Майерс написал свою книжку, появилось очень много разных инструментов. Здесь я привел только то, что я посчитал популярным по количеству ссылок на них в интернете (вы можете сказать, что это неправильно — я с вами соглашусь):

  • Jacoco, Cobertura — Java;
  • OpenCover — .NET;
  • Coverage — Python;
  • SimpleCov — Ruby;
  • OpenCppCoverage — C++;
  • cover, gcov — Go.

В некоторых языках программирования (для меня это было удивлением) — допустим, в Python и в Go — инструменты для измерения покрытия кода тестами встроены в сам язык.

Инструменты есть и, более того, существует интеграция этих инструментов со средами разработки, когда мы видим эту замечательную штучку слева, свидетельствующую о том, что этот кусок кода покрыт unit-тестами (зеленый цвет), а этот — нет (красный цвет).

Где-то можно! И глядя на это в контексте unit-тестов, хочется задать вопрос — почему так нельзя сделать с интеграционными или с функциональными тестами?

Тестировать можем все, что угодно (главное тестировать не фигню), но пользователи давят в какое-то одно место, потому что они этим пользуются 95% времени. Но кроме тестов у нас есть пользователи. И почему нельзя сделать такие же красивые полосочки, но только для кода, который используется или не используется?

Давайте посмотрим, как. На самом деле, так сделать можно.

Представьте, что я тестировщик этого приложения. И мне оно попадает на регрессионное тестирование («Срочно, горим, делаем мега-стартап, надо проверить, что работает, что не работает»). Я провожу с ним все эти манипуляции — все работает, мы отпускаем в релиз. Релиз проходит успешно, все хорошо.

За полгода разработчики что-то там поменяли. Проходит полгода — ситуация повторяется. Могу ли я это узнать — это отдельный вопрос. Что именно, я не знаю. Все ли я проверил нажатием одной единственной кнопки или не все? Но самое главное — какой код теперь вызывается? Понятно, что не все, но не пропустил ли я чего-то важного?

На эти вопросы можно найти ответы, если вместе с приложением запустить агент, снимающий с него покрытие.

Можно брать любой, главное, чтобы вы потом могли понять, что он вам намерял. Я использовал Jacoco. В результате работы агента у нас появился файлик jacoco.exec:

Из этого файлика, исходного приложения и бинарника приложения можно создать отчет, из которого будет видно, как это все работает.

У меня есть маленький скрипт, который проанализировал эту штуку и создал папку html:

Скрипт показывает вот такой отчет:

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

Красным — которые не продавил. В этом отчете зеленым подсвечиваются те строчки, которые я «продавил».

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

Для проверки падения сети можно обрушить сетку или внедрить Fault Injection testing, а можно написать другой Fault Injection implementation, положив ее в каталог с приложением, получать статус-код не 200, а, например, 401.

Мы же делаем пользовательский сервис. Пытаясь ответить на вопросы о том, что проверяется нашими тестами, куда давят наши пользователи и как на самом деле одно соотносится с другим, мы в Одноклассниках создали сервис, который умеет сводить все воедино. Мы можем тестировать какой-то забытый уголок нашего большого портала, куда никто не заходит, но какая в этом ценность?

Но потом из-за опечатки одного из наших инженеров мы переименовали его в KOVЁR. Сначала мы назвали его Cover.

И KOVЁR позволяет нам сравнивать отчеты по тому, что было, допустим, на прошлой неделе, и на этой; по тому, что мы сделали автотестами, и тому, что продавили люди руками. KOVЁR знает о нашем цикле разработки ПО, в частности, когда нужно включить замер покрытия, когда нужно его выключить, когда с этого надо срендерить отчеты.

Выглядит это так (это реальные скриншоты с KOVЁR):

Слева находятся автотесты, справа — пользователи. Получаем side-by-side сравнение одного и того же кода. Красным подсвечено то, что не продавлено, зеленым то, что продавлено (в данном случае автотесты продавливают конкретный кусок бизнес-логики намного лучше, чем пользователи).

Как вы понимаете, все может корректироваться: лево и право могут меняться, используемые цвета — тоже.

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

Где есть покрытие автотестами, но нет людей, надо хорошо подумать. Там, где у нас есть покрытие и автотестами, и людьми — его нужно сравнивать, и с этим KOVЁR работает. С другой — это может быть функционал, который используется людьми в каких-то экстренных обстоятельствах (восстановление пользователей, разблокировка, бэкап, восстановление из бэкапа — то, что вызывается крайне редко). С одной стороны, это может быть мертвый код — очень большая проблема современной разработки.

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

С ними вы сможете: Инструменты Code Coverage уже существуют, и надо их просто интегрировать к себе.

  • использовать их для интроспекции ручного тестирования;
  • получить мерило качества для автотестов;
  • находить с их помощью мертвый код и мертвые фичи.

Метаинформация

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

Они выглядят так: Предположим, у меня есть 10 автотестов.

В реальности каждый автотест бегает разное время. Поэтому в определенный момент времени они выглядят вот так:

И у нас есть два ресурса, на которых мы их запускаем:

  • Ресурс для запуска тестов № 1
  • Ресурс для запуска тестов № 2

Я не знаю, что это — jenkins slave, виртуальные машины, docker-контейнеры, телефоны — все, что угодно.

Если мы возьмем эти 10 тестов и раскинем их на два ресурса поровну, получим такую картину:

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

Не меняя количество тестов на каждом из этих ресурсов, можно просто перегруппировать их и получить вот такую картинку:

Когда мы первый раз врубили у себя эту оптимизацию, она реально сэкономила нам 20%. В каждом ресурсе осталось по пять тестов, но простой сократился — мы сэкономили примерно 20% времени тестирования. То есть эта цифра не с потолка, а из практики.

Дальше вы должны ее балансировать и как-то оптимизировать. Если рассматривать эту закономерность дальше, то скорость тестов — это всегда функция от того, сколько у вас есть ресурсов и сколько у вас есть тестов.

Почему это важно?

Предположим, к вам кто-то прибегает на ваш Continuous integration server и говорит, что нам нужно срочно запустить тесты — проверить фикс и сделать это как можно быстрее. Потому что не все всегда одинаково.

Вы можете пойти на поводу у этого человека и дать ему все возможные ресурсы для запуска тестов.

Это первое. Правда может оказаться в том, что их фикс не очень важен по сравнению с текущим релизом, который должен выкатываться через два часа.

То есть картинка, которую я показал раньше, где у вас 10 тестов и два ресурса, — это очень большое упрощение. А второе — тестов на самом деле не так много, как у вас ресурсов. И эта игра с тем, сколько кому дать ресурсов, начинает влиять на всех. Ресурсов может быть 200, а тестов — 10 тыс.

Чтобы правильно играть в эту игру, нужно всегда иметь ответы на два вопроса: сколько у вас ресурсов для запуска и сколько тестов.

Если вы будете достаточно долго думать над вопросом о том, сколько у вас ресурсов и сколько у вас тестов (особенно над последним), рано или поздно вы придете к мысли о том, что было бы неплохо парсить код ваших тестов и разбираться в том, что же в нем происходит:

Все среды разработки уже делают это, чтобы показывать вам вот такие подсказки: Вам эта мысль может показаться безумной, но не гоните ее сразу.

Более того, все среды разработки делают это хорошо, а некоторые даже поставляют библиотеки, которые позволяют решать такие задачи буквально в шесть строчек (по крайней мере, для Java). Причем они занимаются парсингом не только кода, но и всех зависимостей в нем.
Они умеют это делать.

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

И имея все это в голове, мы создали сервис, который называется Berrimor.

BERRIMOR умеет говорить «овсянка, сэр!», а еще он умеет:

  • выкачивать код из репозиториев GIT;
  • правильно парсить код (в т.ч. регулярно);
  • выделяет метаинформацию, а именно: считает тесты; получает метаинформацию из тестов (тэги, отключенные тесты); знает владельцев тестов.

BERRIMOR поставляет все эти данные наружу.

Вся его мощь кроется внутри API. Я мог бы показать вам интерфейс BERRIMOR, но вы бы все равно ничего там равно бы.

Социальный анализ кода

В 2010 году я читал лекции Сергея Архипенко по управлению программными проектами и мне запомнилась вот эта вот цитата:

"…реальность, которая заключена в особой специфике производства программ, по сравнению с любой другой производственной деятельностью, потому что то, что производят программисты – нематериально, это коллективные ментальные модели, записанные на языке программирования" (Сергей Архипенков, Лекции по управлению программными проектами, 2009).

У людей есть почерк, но не у всех он хороший. Ключевое слово — коллективные. Между людьми существуют какие-то взаимосвязи: кто-то пишет фичу, кто-то ее патчит, кто-то ее чинит. У программистов тоже есть почерк (и также не всегда хороший). И они влияют на качество того, что происходит в проекте. Эти зависимости есть внутри каждого коллектива, внутри каждой команды разработки.

Я выделил три видео, которые есть в открытом доступе и могут помочь вам понять, что же это такое. Социальный анализ кода — нарождающаяся дисциплина.

Вон они

Социальный анализ кода позволяет:

  • понять, кто чинит и кто ломает;
  • найти неявные связи в коде. Когда у вас меняется класс и тест на него — это явная связь в коде, и это нормально. А когда у вас меняется класс, тест и еще что-то, и так происходит каждый раз — это неявная связь в коде;
  • найти горячие точки в коде, где чаще всего фиксят, меняют, ломают;
  • найти мертвый код и мертвые фичи. Очень странно сейчас (в 2017 году) выглядит код, который написан один раз в 2013 — 2015 годах и не менялся с тех пор. Либо он идеальный и хорошо работает — и метрики это покажут, либо он мертвый;
  • если вы знаете, как в вашем коде выглядит технический долг, вы тоже можете его найти.

Чуть подробнее про технический долг. У меня есть слабая гипотеза о техническом долге.

  • на абстрактном проекте в вакууме есть баг-треккер (issue-трекер). В баг-трекере есть все баги, таски, и у каждого из них есть какой-то ID;
  • есть система контроля версий Git — в самом упрощенном случае. В Git есть коммиты, а у коммитов — комментарии, где пишут ссылки на ID таски;
  • гипотеза моя заключается в том, что те файлы в Git, в которых чаще всего что-то меняют под баги — это и есть места скопления технического долга.

У нас в «Одноклассниках» это выглядит так:

В силу NDA я не могу показывать вам социальный анализ кода на примере репозиториев «Одноклассников». Когда я что-то пишу и коммитчу, я указываю ссылку на тикет в Jira. Я покажу на примере open source-проекта Kafka.

У Kafka есть открытый issue -трекер, открытый репозиторий с кодом:

Давайте посмотрим, что же там происходит.

Итак, у меня есть (маленькое утилитное приложение), которое поднимает все коммиты в этом репозитории и разбирает все комментарии к ним, обеспечивая поиск по регулярному выражению Pattern.compile("KAFKA-\\d+") коммитов, которые ссылаются на какой-то тикет.

То есть точность анализа на треть меньше, чем хотелось бы. В консоли видно, что коммитов всего 4246, а коммитов без такого упоминания — 1562.

Составляем все эти индексы в большой хэшмап: имя файла — список тикетов, по которым этот файл менялся. Дальше мы поднимаем каждый коммит, составляем из него индекс — какие файлы в нем менялись (под какой тикет). Вот как это выглядит:

Например, у нас есть файл KafkaApis и рядом огромный список issue, по которым он менялся (API меняется часто).

На выходе мы получаем маленький хэш, где написано, что это за штука, и какой у нее приоритет (это все только баги): Дальше мы идем в issue-трекер Kafka и определяем, по каким issue эта штука менялась  - это был баг, фича, оптимизация?

итоге мы получаем вот такой вывод:

Где мы пишем, какой процент изменений был в том или ином файле:

С большой вероятностью технический долг сосредоточен именно в этих файлах. Например, для верхней строки общее количество тикетов, которые прошло в коммитах через этот файл, — 231, из них багов — 128 и, соответственно, 128 делим на 231 — получаем 55% — доля изменений.

Итоги

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

Если тема тестирования и обработки ошибок вам так же близка, как и нам, наверняка вас заинтересуют вот эти доклады на нашей майской конференции Heisenbug 2018 Piter:

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть