Хабрахабр

Монолит для сотен версий клиентов: как мы пишем и поддерживаем тесты

Всем привет!

На прошлогодней конференции HighLoad я выступал с докладом, текстовым вариантом которого и хочу поделиться с вами. Я бэкенд-разработчик в серверной команде Badoo. Этот пост будет наиболее полезен тем, кто самостоятельно пишет тесты для бэкенда и испытывает проблемы с тестированием legacy-кода, а также тем, кто хочет тестировать сложную бизнес-логику.

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

Наш процесс разработки

Мы проиллюстрировали наш процесс разработки:

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

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

Большое значение имеет принятая в компании культура разработки: бэкенд-разработчик отвечает за фичу от момента её реализации на бэкенде до последней интеграции на последней платформе, на которой изначально планировалось реализовывать эту фичу.

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

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

Что хочется сделать в первую очередь, чтобы удостовериться в том, что фича работает?

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

И вот тут нам на помощь приходят автоматические тесты.

Unit-тесты

Самые простые тесты, которые мы пишем, — Unit-тесты. В качестве основного языка для бэкенда мы используем PHP, а в качестве фреймворка для модульного тестирования — PHPUnit. Забегая вперёд, скажу, что все наши бэкенд-тесты написаны на базе этого фреймворка.

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

SoftMocks

Главная трудность, с которой сталкиваются разработчики при написании модульных тестов, — нетестируемый код, и обычно это legacy-код.

Компании Badoo 12 лет, когда-то она была очень маленьким стартапом, который развивали несколько человек. Простой пример. Потом мы стали достаточно большими и поняли, что без тестов жить нельзя. Стартап вполне успешно существовал вообще безо всяких тестов. Не переписывать же его только ради покрытия тестами! Но к этому времени было написано много кода, который работал. Это было бы не очень разумно с точки зрения бизнеса.

Она перехватывает все include/require PHP-файлов и на лету заменяет исходный файл на модифицированное содержимое, то есть на переписанный код. Поэтому мы разработали маленькую open source-библиотеку SoftMocks, которая делает наш процесс написания тестов дешевле и быстрее. Здесь подробно рассказано о том, как функционирует библиотека. Это позволяет нам создавать заглушки для любого кода.

Примерно вот так это выглядит для разработчика:

//mock константы
\Badoo\SoftMocks::redefineConstant($constantName, $newValue);
//mock любых методов: статических, приватных, финальных
\Badoo\SoftMocks::redefineMethod( $class, $method, $method_args, $fake_code
);
//mock функций
\Badoo\SoftMocks::redefineFunction( $function, $function_args, $fake_code
);

C помощью таких несложных конструкций мы можем глобально переопределять всё что хотим. В том числе они позволяют нам обходить ограничения стандартного мокера PHPUnit. То есть мы можем mock’ать статические и приватные методы, переопределять константы и делать многое другое, что невозможно в обычном PHPUnit.

Но такой подход ведёт к усложнению кода и накапливанию «костылей». Однако мы столкнулись с проблемой: разработчикам кажется, что при наличии SoftMocks нет нужды писать тестируемый код — всегда можно «причесать» код нашими глобальными mock’ами, и всё будет хорошо работать. Поэтому мы приняли несколько правил, которые позволяют нам держать ситуацию под контролем:

  1. Весь новый код должен легко тестироваться стандартными mock’ами PHPUnit. Если это условие соблюдено, значит, код тестируемый и можно легко выделить маленький кусочек и протестировать только его.
  2. SoftMocks допустимо применять со старым кодом, который написан не подходящим для unit-тестирования образом, а также в случаях, когда по-другому делать слишком дорого/долго/трудно (нужное подчеркнуть).

Соблюдение этих правил тщательно контролируется на этапе code review.

Мутационное тестирование

Отдельно хочу сказать о качестве юнит-тестов. Я думаю, что многие из вас используют такую метрику, как процент покрытия (code coverage). Но она, к сожалению, не отвечает на один вопрос: «Хороший ли юнит-тест я написал?». Вполне возможно, что вы написали такой тест, который на самом деле ничего не проверяет, не содержит ни одного assert’а, зато генерирует отличное покрытие кода. Конечно, пример утрирован, но ситуация не так уж далека от реальности.

Это довольно старая, но не очень известная концепция. С недавнего времени мы начали внедрять мутационное тестирование. Алгоритм такого тестирования довольно прост:

  • берём код и code coverage;
  • парсим и начинаем менять код: true на false, > на >=, + на — (в общем, всячески вредить );
  • для каждого такого изменения-мутации прогоняем наборы тестов, которые покрывают изменённую строку;
  • если тесты упали, то они хорошие и действительно не позволяют нам сломать код;
  • если же тесты прошли, скорее всего, они недостаточно эффективны, несмотря на покрытие, и, возможно, стоит посмотреть на них более внимательно, докинуть какие-то assert’ы (или есть не покрытый тестом участок).

Для PHP есть несколько готовых фреймворков, например Humbug и Infection. К сожалению, нам они не подошли, потому что несовместимы с SoftMocks. Поэтому мы написали свою маленькую консольную утилиту, которая делает то же самое, но использует наш внутренний формат code coverage и дружит с SoftMocks. Сейчас разработчик запускает её вручную и анализирует написанные им тесты, но мы работаем над внедрением инструмента в наш процесс разработки.

Интеграционное тестирование

С помощью интеграционных тестов мы проверяем взаимодействие с различными сервисами и базами данных.

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

Промо должно быть показано, если:

  • у пользователя в поле «Работа» указано «программист»,
  • пользователь участвует в А/В-тесте HL18_promo,
  • пользователь зарегистрирован более двух лет назад.

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

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

Рассмотрим стандартный способ тестирования взаимодействия с базой данных, предлагаемый PHPUnit:

  1. Поднимаем тестовую базу данных.
  2. Подготавливаем DataTables и DataSets.
  3. Запускаем тест.
  4. Очищаем тестовую базу данных.

Какие сложности нас подстерегают при таком подходе?

  • Нужно поддерживать структуры DataTables и DataSets. Если мы изменили схему таблицы, то необходимо отразить эти изменения в тесте, что не всегда удобно и требует дополнительного времени.
  • Необходимо время на подготовку базы данных. Каждый раз при настройке теста нам нужно что-то туда заливать, создавать какие-то таблицы, а это долго и хлопотно, если тестов много.
  • И самый важный недостаток: параллельный запуск таких тестов делает их нестабильными. Мы запустили тест А, он начал писать в тестовую таблицу, которую сам создал. Одновременно мы запустили тест Б, который хочет работать с той же тестовой таблицей. Как следствие, возникают взаимные блокировки и прочие непредвиденные ситуации.

Чтобы избежать этих проблем, мы разработали свою маленькую библиотеку DBMocks.

DBMocks

Принцип работы таков:

  1. С помощью SoftMocks мы перехватываем все обёртки, через которые работаем с базами данных.
  2. Когда
    запрос проходит через mock, парсим SQL-запрос и вытаскиваем из него DB + TableName, а из connection достаём хост.
  3. На том же хосте в tmpfs мы создаём временную таблицу с такой же структурой, как у оригинальной (структуру копируем с помощью SHOW CREATE TABLE).
  4. После этого все запросы, которые будут приходить через mock’и к этой таблице, мы переадресуем в свежесозданную временную.

Что нам это даёт:

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

API-тестирование

Разница между юнит- и API-тестами хорошо иллюстрируется этой гифкой:


Замок работает отлично, но вот прикреплён он не к той двери.

Наши тесты имитируют клиентскую сессию, умеют отправлять запросы к бэкенду, соблюдая наш протокол, и бэкенд отвечает им как реальному клиенту.

Пул тестовых пользователей

Что нам нужно для успешного написания таких тестов? Вернёмся к условиям показа нашего промо:

  • у пользователя в поле «Работа» указано «программист»,
  • пользователь участвует в А/В-тесте HL18_promo,
  • пользователь зарегистрирован более двух лет назад.

Как видно, здесь всё про пользователя. Да и в реальности 99% API-тестов требуют наличия авторизованного зарегистрированного пользователя, который присутствует во всех сервисах и базах данных.

Можно попробовать зарегистрировать его в момент тестирования, но: Где его взять?

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

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

  1. Пользователей мы не регистрируем каждый раз, а используем многократно.
  2. После теста сбрасываем данные пользователя до первоначального состояния (на момент регистрации). Если этого не делать, тесты со временем станут нестабильными, потому что пользователи окажутся «загрязнены» информацией из других тестов.

Работает это примерно так:

Почему мы вообще захотели этого? В какой-то момент мы захотели запускать наши API-тесты в production-окружении. Потому что devel-инфраструктура не то же самое, что production.

Чтобы быть абсолютно уверенными в том, что новый билд соответствует ожиданиям и нет никаких проблем, мы выкладываем новый код на preproduction-кластер, который работает с production-данными и сервисами, и уже там прогоняем наши API-тесты. Хотя мы и пытаемся постоянно повторять production-инфраструктуру в уменьшенном размере, devel никогда не будет её полноценной копией.

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

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

Как осуществить изоляцию? У каждого нашего пользователя есть флаг is_test_user. На этапе регистрации он становится yes или no, и больше уже не меняется. По этому флагу мы изолируем пользователей во всех сервисах. Также важно, что мы исключаем тестовых пользователей из бизнес-аналитики и результатов А/В-тестирования, чтобы не искажать статистику.

Если у вас геосервис, это вполне рабочий способ. Можно пойти и более простым путём: мы начинали с того, что всех тестовых пользователей «переселяли» в Антарктиду.

QA API

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

Это, по сути, бэкдор для тестирования, представляющий собой хорошо документированные API-методы, которые позволяют быстро и легко управлять данными пользователя и менять их состояние в обход основного протокола нашего общения с клиентами. Для решения этих проблем у нас есть QA API. Методы пишут бэкенд-разработчики для QA-инженеров и для использования в UI и API-тестах.

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

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

  • В поле «Работа» указано «программист»:
    addUserWorkEducation?user_id=ID&works[]=Badoo,
    программист
  • Пользователь участвует в A/B-тесте HL18_promo:
    forceSplitTest?user_id=ID&test=HL18_promo
  • Зарегистрирован более двух лет назад:
    userCreatedChange?user_id=ID&created=2016-09-01

Мы защитили наш сервис несколькими способами: Поскольку это бэкдор, крайне важно подумать о безопасности.

  • изолировали на уровне сети: к сервисам можно обратиться только из офисной сети;
  • с каждым запросом мы передаём secret, без которого нельзя получить доступ к QA API даже из офисной сети;
  • методы работают только с тестовыми пользователями.

RemoteMocks

Для работы с удалённым бэкендом API-тестов нам могут требоваться mock’и. Для чего? К примеру, если API-тест в production-окружении начнёт обращаться к базе данных, нам нужно позаботиться о том, чтобы данные в ней были очищены от тестовых. Кроме того, mock’и помогают сделать ответ теста более пригодным для тестирования.

У нас есть три текста:

Наши локализаторы постоянно работают над улучшением переводов, проводят А/В-тесты с лексемами, ищут более удачные формулировки. Badoo — многоязычное приложение, у нас есть сложный компонент локализации, который позволяет быстро переводить и получать переводы для текущего местоположения пользователя. Зато мы можем с помощью RemoteMocks проверить, правильно ли обратились к компоненту локализации. И, проводя тест, мы не можем знать, какой текст будет возвращён сервером, — он может измениться в любой момент.

Тест просит бэкенд инициализировать их для своей сессии, и при получении всех последующих запросов бэкенд проверяет наличие mock’ов для текущей сессии. Как работают RemoteMocks? Если они есть, он просто инициализирует их с помощью SoftMocks.

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

$this->remoteInterceptMethod( \Promo\HighLoadConference::class, 'saveUserEmailToDb', true
);

Ну а теперь соберём наш API-тест:

//получаем эмулятор клиента с уже авторизованным пользователем
$app_startup = [ 'supported_promo_blocks' => [\Mobile\Proto\Enum\PromoBlockType::GENERIC_PROMO]
];
$Client = $this->getLoginedConnection(BmaFunctionalConfig::USER_TYPE_NEW, $app_startup);
//подстраиваем пользователя
$Client->getQaApiClient()->addUserWorkEducation(['Badoo, программист']);
$Client->getQaApiClient()->forceSplitTest('HL18_promo');
$Client->getQaApiClient()->userCreatedChange('2016-09-01');
//мокаем запись в базу данных
$this->remoteInterceptMethod(\Promo\HighLoadConference::class, 'saveUserEmail', true);
//проверяем, что вернулся промоблок, согласно протоколу
$Resp = $Client->ServerGetPromoBlocks([]);
$this->assertTrue($Resp->hasMessageType('CLIENT_NEXT_PROMO_BLOCKS'));
$PromoBlock = $Resp->CLIENT_NEXT_PROMO_BLOCKS;

//пользователь жмёт на CTA, проверяем, что вернулся ответ, согласно протоколу
$Resp = $Client->ServerPromoAccepted($PromoBlock->getPromoId());
$this->assertTrue($Resp->hasMessageType('CLIENT_ACKNOWLEDGE_COMMAND'));

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

Правила использования API-тестов

Вроде бы всё хорошо, но мы снова столкнулись с проблемой: API-тесты получились слишком удобными для разработки и появился соблазн использовать их везде. В результате однажды мы осознали, что начинаем решать с помощью API-тестов задачи, для решения которых они не предназначены.

Потому что API-тесты очень медленные. Почему это плохо? Поэтому мы разработали набор правил использования API-тестов:
Они ходят по сети, обращаются к бэкенду, который поднимает сессию, ходит в базу и кучу сервисов.

  • цель API-тестов — проверять протокол взаимодействия между клиентом и сервером, а также правильность интеграции нового кода;
  • допустимо покрывать ими сложные процессы, например цепочки действий;
  • нельзя с их помощью тестировать мелкую вариативность ответа сервера — это задача модульных тестов;
  • в ходе code review мы проверяем в том числе и тесты.

UI-тесты

Раз уж мы рассматриваем пирамиду автоматизации, расскажу немного и о UI-тестах.

Мы покрываем UI-тестами фичу, когда она уже доведена до ума и стабилизирована, потому что считаем, что неразумно тратить ресурсы на достаточно дорогую автоматизацию фичи, которая, возможно, дальше А/В-теста не пойдёт. Бэкенд-разработчики в Badoo не пишут UI-тесты — для этого у нас есть специальная команда в департаменте QA.

Здесь рассказывается про нашу платформу для автоматизации и тестирования. Для мобильных автотестов мы используем Calabash, а для веба — Selenium.

Прогон тестов

У нас сейчас 100 000 модульных тестов, 6000 — интеграционных и 14 000 API-тестов. Если попробовать запустить их в один поток, то даже на самой мощной нашей машине полный прогон всех займёт: модульных — 40 минут, интеграционных — 90 минут, API-тестов — десять часов. Это слишком долго.

Параллелизация

О нашем опыте параллелизации unit-тестов мы рассказывали в этой статье.

Но мы пошли дальше и сделали облако для параллельного запуска, чтобы иметь возможность масштабирования аппаратных ресурсов. Первое решение, которое кажется очевидным, — запускать тесты в несколько потоков. Упрощённо его работа выглядит так:

Самая интересная задача здесь — распределение тестов между потоками, то есть их разбивка на чанки.

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

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

Начали собирать статистику по времени прогона каждого теста, а затем стали компоновать чанки таким образом, чтобы один поток согласно статистики выполнялся не дольше 30 секунд. Что сделали мы? При этом мы довольно плотно упаковываем тесты в чанки, чтобы их получилось меньше.

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

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

То есть на полный прогон вместо 12 часов уходит не больше 22 минут. В результате модульные тесты стали прогоняться за минуту, интеграционные — за пять минут, а API-тесты — за 15 минут.

Прогон тестов на основе покрытия кода

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

В какой-то момент мы пришли к выводу, что не нужно каждый раз прогонять все тесты — можно делать прогоны, основанные на code coverage:

  1. Берём наш branch diff.
  2. Формируем список изменённых файлов.
  3. По каждому файлу получаем список тестов,
    которые его покрывают.
  4. Из этих тестов создаём набор и запускаем его в тестовом облаке.

Где взять coverage? Мы собираем данные раз в сутки, когда простаивает инфраструктура среды разработки. Количество прогоняемых тестов заметно уменьшилось, скорость получения обратной связи от них, напротив, увеличилась в разы. Профит!

Несмотря на то, что Badoo уже давно не стартап, мы всё ещё можем быстро внедрять изменения в production, быстро выливать hot fix, раскатывать фичи, менять конфигурацию. Дополнительным бонусом стала возможность прогонять тесты для патчей. Новый подход дал большой прирост в скорости обратной связи от тестов, потому что нам теперь не нужно долго ждать полного прогона. Как правило, нам очень важна скорость выкатывания патчей.

Мы релизим бэкенд два раза в день, и покрытие актуально только для первого релиза, до первого билда, после чего начинает отставать на один билд. Но без недостатков никуда. Для нас это гарантия того, что code coverage нигде не отстал и выполнены все необходимые тесты. Поэтому для билдов мы прогоняем полный тестовый набор. Но такое бывает очень редко. Худшее, что может случиться, — это то, что какие-то упавшие тесты мы поймаем на этапе сборки билда, а не на предыдущих этапах.

По ходу тестирования логики они поднимают кучу разных файлов, ходят в сессии, базы и так далее. Однако подход не очень эффективен в случае с API-тестами, поскольку они генерируют очень обширный code coverage. Если в одном из затрагиваемых файлов что-то поменять, все API-тесты попадут в тестовый набор и преимущества подхода будут нивелированы.

Заключение

  • Вам нужны все уровни пирамиды автоматизации тестирования, чтобы быть уверенными в корректной работе функциональности. Если вы пропустили какой-то уровень, вероятно, какая-то из проблем остаётся непокрытой.
  • Количество тестов ≠ качество. Выделяйте время на code review тестов и мутационное тестирование, это полезный инструмент.
  • Если вы планируете работать с тестовыми пользователями, подумайте, как их изолировать от реальных. И не забудьте исключить их из статистики и аналитики.
  • Не бойтесь бэкдоров. Они действительно упрощают и ускоряют написание тестов и очень сильно помогают в ручном тестировании.
  • Статистика, и ещё раз статистика! Имея статистические данные по тестам, можно улучшать распараллеливание прогонов и уменьшать количество прогоняемых тестов.

Он целиком и полностью будет посвящён автотестам для PHP-разработчика. Пользуясь случаем, напомню про второй Badoo PHP Meetup 16 марта. Приглашаю поучаствовать онлайн! Места в зале закончились, но будет трансляция. Стартуем в 12:00, стрим — на нашем YouTube-канале.

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

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

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

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

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