Привет, меня зовут Коля, занимаюсь тестированием 7 лет, автоматизацией — 6 лет. Так уж сложилось, что не особо люблю WebUI-тесты, но почему-то именно они у меня получаются лучше всего.
В один день мне позвонил мой друг Рома и сказал: «Коля, помнишь ты у нас автотесты делал с селениумом? Помоги мне сделать так же красиво».
Отправив Роме ссылку на свой гитхаб и устроив пару созвонов с объяснениями, что и как работает, я добился от него заветного «Я понял».
Спустя два дня Рома позвонил снова: «Коля, я всё понял, а вот ребята в моей команде — нет. Помоги мне объяснить им».

Внутри я расскажу от том, как:
-
ускорить написание тестов;
-
снизить затраты на их поддержку;
-
прокачаться в написании фреймворков;
-
сэкономить деньги компании;
-
сохранить нервы сотрудников.
Что внутри статьи
В статье будет описана эволюция фреймворка для тестов, использующего:
-
Python;
-
Pytest;
-
Selenium (версии 3).
Использование Selenium версии 3.* обусловлено тем, что Selenium 4.*-вебдрайвер не дружит с синглтоном. На мой взгляд, причин для перехода с 3 на 4 нет совсем — изменения только в работе с Selenium Grid, который во всём проигрывает Selenoid. Если есть желание использовать новое-новое, то лучше посмотреть в сторону Playwright — с небольшими изменениям код из статьи можно адаптировать и под него.
Двигаться по статье будем так:
-
пишем работающий код;
-
разбираем, почему код выглядит плохо;
-
улучшаем код;
-
GOTO 1.
Установить всё необходимое поможет этот набор статей — Selenium для Python. Глава 1. Установка / Хабр (habr.com)
Статья рассчитана на опытного специалиста, который уже знает такие слова, как xpath, selenium webdriver, и уже имел опыт с Pytest, поэтому я пропущу объяснения основ.
Пишем тесты
Для примера возьмём два простых сценария:
-
зайти на habr.com, проверить, что мы есть на хабре;
-
зайти на habr.com, сделать поиск по фразе “ozon tech”, проверить, что все статьи содержат фразу “ozon tech”.
Здесь будет много кода, потому что это отправная точка всей статьи.
requirements.txt
selenium==3.141.0 pytest==7.4.3 urllib3<=2.0
Заметка — requirements.txt будет упомянут всего два раза. Фиксированные версии библиотек необходимы для возможности повторить результат на каждом этапе статьи.
Использование urllib3 <= 2.0 требуется только для запуска selenium webdriver.
Установка зависимостей происходит командой pip install -r requirements.txt.
driver.py
from selenium.webdriver import Firefox class Driver(Firefox): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.implicitly_wait(5) # default time waiting for a locator
conftest.py
import pytest from driver import Driver @pytest.fixture() def browser() -> Driver: driver = Driver(executable_path="path/to/driver") driver.set_window_size(1920, 1080) driver.get("https://habr.com/ru/all") yield driver driver.quit()
test_ozon.py
from selenium.webdriver.common.by import By from selenium.webdriver.support import expected_conditions from selenium.webdriver.support.wait import WebDriverWait from driver import Driver def test_main_page(browser: Driver): browser.find_element_by_xpath('//a[contains(@class, "tm-header__logo")]') browser.find_element_by_xpath('//a[contains(@class, "tm-header-user-menu__search")]') browser.find_element_by_xpath('//section[@id="news_block_1"]') def test_search(browser: Driver): search_string = "ozon tech" open_search = browser.find_element_by_xpath('//a[contains(@class, "tm-header-user-menu__search")]') open_search.click() search_input = browser.find_element_by_xpath('//div[contains(@class, "tm-search__input")]//input') search_input.send_keys(search_string) search_button = browser.find_element_by_xpath('//div[contains(@class, "tm-search__input")]//span') search_button.click() WebDriverWait(browser, 10).until_not(expected_conditions.presence_of_element_located((By.XPATH, '//div[contains(@class, "placeholder-wrapper")]'))) browser.find_element_by_xpath('//a[contains(@class, "tm-header__logo")]') browser.find_element_by_xpath('//a[contains(@class, "tm-header-user-menu__search")]') articles = browser.find_elements_by_xpath('//div[@class="tm-articles-list"]/article') assert articles, "empty articles list" errors = [] for article in articles: if search_string not in article.text.lower(): errors.append(f"'{search_string}' not found in '{article.text.lower()}'") assert not errors, errors
Добавляем PageObject
Тесты написаны, но выглядят как-то не так, очевидное улучшение, которое можно сделать — добавить переиспользуемость кода из тестов.
Когда мы говорим о тестах для веба, этот паттерн называется PageObject — взаимодействие со страницей и её возможностями на основе методов, которые есть у страницы.
Создадим новый класс страницы и поместим туда пару методов для работы с объектами страницы:
pages/main_page.py
from driver import Driver class MainPage: header_logo_xpath = '//a[contains(@class, "tm-header__logo")]' header_search_xpath = '//a[contains(@class, "tm-header-user-menu__search")]' search_input_xpath = '//div[contains(@class, "tm-search__input")]//input' search_button_xpath = '//div[contains(@class, "tm-search__input")]//span' news_block_xpath = '//section[@id="news_block_1"]' @staticmethod def verify_page(driver: Driver): driver.find_element_by_xpath(MainPage.header_logo_xpath) driver.find_element_by_xpath(MainPage.header_search_xpath) driver.find_element_by_xpath(MainPage.news_block_xpath) @staticmethod def open_search(driver: Driver): open_search = driver.find_element_by_xpath(MainPage.header_search_xpath) open_search.click() @staticmethod def input_search(driver: Driver, search: str): search_input = driver.find_element_by_xpath(MainPage.search_input_xpath) search_input.send_keys(search) @staticmethod def press_search_button(driver: Driver): search_button = driver.find_element_by_xpath(MainPage.search_button_xpath) search_button.click()
pages/search_page.py
from selenium.webdriver.common.by import By from selenium.webdriver.support import expected_conditions from selenium.webdriver.support.wait import WebDriverWait from driver import Driver class SearchPage: header_logo_xpath = '//a[contains(@class, "tm-header__logo")]' header_search_xpath = '//a[contains(@class, "tm-header-user-menu__search")]' article_xpath = '//div[@class="tm-articles-list"]/article' preview_xpath = '//div[contains(@class, "placeholder-wrapper")]' @staticmethod def wait_for_loader_off(driver: Driver): WebDriverWait(driver, 10).until_not(expected_conditions.presence_of_element_located((By.XPATH, SearchPage.preview_xpath))) @staticmethod def verify_page(driver: Driver, expected_string_in_results: str): SearchPage.wait_for_loader_off(driver) driver.find_element_by_xpath(SearchPage.header_logo_xpath) driver.find_element_by_xpath(SearchPage.header_search_xpath) articles = driver.find_elements_by_xpath(SearchPage.article_xpath) assert articles, "empty articles list" errors = [] for article in articles: if expected_string_in_results not in article.text.lower(): errors.append(f"'{expected_string_in_results}' not found in '{article.text.lower()}'") assert not errors, errors
Применим наши изменения к тестам:
test_ozon.py
from driver import Driver from pages.main_page import MainPage from pages.search_page import SearchPage def test_main_page(browser: Driver): MainPage.verify_page(browser) def test_search(browser: Driver): search_string = "ozon tech" MainPage.open_search(browser) MainPage.input_search(browser, search_string) MainPage.press_search_button(browser) SearchPage.verify_page(browser, search_string)
Тесты выглядят намного проще, не так ли?
Приколачиваем PageElement
Стало лучше, но на разных страницах у нас есть пересечение по элементам. Посмотрим в будущее, сделаем отдельный класс для повторяющихся элементов, в нашем случае — для Header.
Для этого воспользуемся паттерном PageElement — обычно он рядом с PageObject, если у вас есть повторяющиеся элементы на страницах.
Создадим новый класс компонента Header, добавим в него общие элементы.
components/header.py
from driver import Driver class ComponentHeader: header_logo_xpath = '//a[contains(@class, "tm-header__logo")]' header_search_xpath = '//a[contains(@class, "tm-header-user-menu__search")]' @staticmethod def verify_component(driver: Driver): driver.find_element_by_xpath(ComponentHeader.header_logo_xpath) driver.find_element_by_xpath(ComponentHeader.header_search_xpath) @staticmethod def open_search(driver: Driver): open_search = driver.find_element_by_xpath(ComponentHeader.header_search_xpath) open_search.click()
Добавим новый компонент в страницы.
pages/main_page.py
from components.header import ComponentHeader from driver import Driver class MainPage: Header = ComponentHeader search_input_xpath = '//div[contains(@class, "tm-search__input")]//input' search_button_xpath = '//div[contains(@class, "tm-search__input")]//span' news_block_xpath = '//section[@id="news_block_1"]' @staticmethod def verify_page(driver: Driver): MainPage.Header.verify_component(driver) driver.find_element_by_xpath(MainPage.news_block_xpath) @staticmethod def input_search(driver: Driver, search: str): search_input = driver.find_element_by_xpath(MainPage.search_input_xpath) search_input.send_keys(search) @staticmethod def press_search_button(driver: Driver): search_button = driver.find_element_by_xpath(MainPage.search_button_xpath) search_button.click()
pages/search_page.py
from selenium.webdriver.common.by import By from selenium.webdriver.support import expected_conditions from selenium.webdriver.support.wait import WebDriverWait from components.header import ComponentHeader from driver import Driver class SearchPage: Header = ComponentHeader article_xpath = '//div[@class="tm-articles-list"]/article' preview_xpath = '//div[contains(@class, "placeholder-wrapper")]' @staticmethod def wait_for_loader_off(driver: Driver): WebDriverWait(driver, 10).until_not(expected_conditions.presence_of_element_located((By.XPATH, SearchPage.preview_xpath))) @staticmethod def verify_page(driver: Driver, expected_string_in_results: str): SearchPage.wait_for_loader_off(driver) SearchPage.Header.verify_component(driver) articles = driver.find_elements_by_xpath(SearchPage.article_xpath) assert articles, "empty articles list" errors = [] for article in articles: if expected_string_in_results not in article.text.lower(): errors.append(f"'{expected_string_in_results}' not found in '{article.text.lower()}'") assert not errors, errors
Чуть-чуть поменяем тесты с учётом нового компонента.
test_ozon.py
def test_search(browser: Driver): search_string = "ozon tech" MainPage.Header.open_search(browser) MainPage.input_search(browser, search_string) MainPage.press_search_button(browser) SearchPage.verify_page(browser, search_string)
Driver Driver Driver Driver. Убираем копипасту — добавляем Singleton
Нынешнее решение уже довольно хорошо выглядит — нет поисков локаторов в тестах, всё инкапсулировано в страницах. Страницы, в свою очередь, разделены на разные классы — удобно добавлять изменения в определённые страницы, не ломая в это время другие.
Можно ли что-то ещё добавить для улучшения? Можно. Но не добавить, а убрать — избавимся от вездесущей копипасты с запихиванием веб-драйвера в аргументы.
Браузер в тестах у нас всегда один, зачем его постоянно упоминать? Можно сделать Singleton, который будет возвращать наш браузер.
Мнение — я верю, что тесты с использованием селениума проверяют интерфейс, а не бэкенд. Если у вас возникает необходимость использовать две сессии одновременно, скорее всего, вы проверяете работу двух пользователей над одной сущностью, чего в большинстве случаев можно добиться, реализовав работу второго пользователя через бэкенд, не браузер.
В любом случае остаётся возможность использования Singleton и двух активных веб-драйверов, нужно только сделать переключение активного браузера.
Меняем код драйвера.
driver.py
from selenium.webdriver import Firefox class Singleton(type): _instances = {} def __call__(cls, *args, **kwargs): if cls not in cls._instances: cls._instances[cls] = super(Singleton, cls).__call__(*args, **kwargs) return cls._instances[cls] class Driver(Firefox, metaclass=Singleton): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.implicitly_wait(5) # default time waiting for a locator
После данных изменений ничего не запустится — особенности urlllib3 и selenium webdriver. Поднимем версию urllib3.
requirements.txt
selenium==3.141.0 pytest==7.4.3 urllib3==2.1.0
Обновим teardown в фикстуре и добавим autouse:
conftest.py
import pytest from driver import Driver @pytest.fixture(autouse=True) def browser() -> Driver: driver = Driver(executable_path="path/to/driver") driver.set_window_size(1920, 1080) driver.get("https://habr.com/ru/all") yield driver try: driver.quit() finally: driver.__class__._instances = {}
Уберём прокидывание Driver в методы компонента:
components/header.py
from driver import Driver class ComponentHeader: header_logo_xpath = '//a[contains(@class, "tm-header__logo")]' header_search_xpath = '//a[contains(@class, "tm-header-user-menu__search")]' @staticmethod def verify_component(): Driver().find_element_by_xpath(ComponentHeader.header_logo_xpath) Driver().find_element_by_xpath(ComponentHeader.header_search_xpath) @staticmethod def open_search(): open_search = Driver().find_element_by_xpath(ComponentHeader.header_search_xpath) open_search.click()
Повторим для страниц:
pages/main_page.py
from components.header import ComponentHeader from driver import Driver class MainPage: Header = ComponentHeader search_input_xpath = '//div[contains(@class, "tm-search__input")]//input' search_button_xpath = '//div[contains(@class, "tm-search__input")]//span' news_block_xpath = '//section[@id="news_block_1"]' @staticmethod def verify_page(): MainPage.Header.verify_component() Driver().find_element_by_xpath(MainPage.news_block_xpath) @staticmethod def input_search(search: str): search_input = Driver().find_element_by_xpath(MainPage.search_input_xpath) search_input.send_keys(search) @staticmethod def press_search_button(): search_button = Driver().find_element_by_xpath(MainPage.search_button_xpath) search_button.click()
pages/search_page.py
from selenium.webdriver.common.by import By from selenium.webdriver.support import expected_conditions from selenium.webdriver.support.wait import WebDriverWait from components.header import ComponentHeader from driver import Driver class SearchPage: Header = ComponentHeader article_xpath = '//div[@class="tm-articles-list"]/article' preview_xpath = '//div[contains(@class, "placeholder-wrapper")]' @staticmethod def wait_for_loader_off(): WebDriverWait(Driver(), 10).until_not(expected_conditions.presence_of_element_located((By.XPATH, SearchPage.preview_xpath))) @staticmethod def verify_page(expected_string_in_results: str): SearchPage.wait_for_loader_off() SearchPage.Header.verify_component() articles = Driver().find_elements_by_xpath(SearchPage.article_xpath) assert articles, "empty articles list" errors = [] for article in articles: if expected_string_in_results not in article.text.lower(): errors.append(f"'{expected_string_in_results}' not found in '{article.text.lower()}'") assert not errors, errors
Почистим тесты от упоминания Driver:
test_ozon.py
from pages.main_page import MainPage from pages.search_page import SearchPage def test_main_page(): MainPage.verify_page() def test_search(): search_string = "ozon tech" MainPage.Header.open_search() MainPage.input_search(search_string) MainPage.press_search_button() SearchPage.verify_page(search_string)
Избавились от копипасты с webdriver, Driver, driver — теперь не нужно дополнительно везде добавлять его как параметр, достаточно в необходимом месте сделать вызов Driver.
Заворачиваем PageObject в PageObject
От копипасты избавились уже аж два раза, но что, если я скажу, что у нас всё ещё остаётся копипаста? А что, если мы представим, что каждый элемент DOM-дерева — это контейнер методов?
Спойлер — так оно и есть, надо только сделать удобный интерфейс для взаимодействия.
Попробуем сделать отдельный класс для элемента DOM-дерева. Пусть у него будет xpath и ссылка на webelement (объект в DOM-дереве на открытой драйвером странице).
locator.py
from selenium.webdriver.remote.webelement import WebElement class Locator: xpath: str = None name: str = None webelement: WebElement = None def __init__(self, xpath: str, name: str): self.xpath = xpath self.name = name
Добавим метод для проверки наличия локатора на странице:
locator.py
def is_on_page(self) -> bool: elements = Driver().find_elements_by_xpath(self.xpath) if elements: return True return False def wait_for_disappear(self): WebDriverWait(Driver(), 10).until_not(expected_conditions.presence_of_element_located((By.XPATH, self.xpath))) def get_all_elements(self): response = [] for element in Driver().find_elements_by_xpath(self.xpath): locator = Locator(xpath=self.xpath, name=self.name) locator.webelement = element response.append(locator) return response
Добавим ✨магический✨ метод получения webelement:
locator.py
def _webelement_required(self): def update_webelement(): try: self.webelement = Driver().find_element_by_xpath(self.xpath) return self except Exception: raise Exception(f'Error: cannot locate {self.name}') # magic function to update webelement if there is no webelement or if DOM element changed if not self.webelement: update_webelement() return try: # if not fails, webelement still has the DOM object link self.webelement.location return except StaleElementReferenceException: update_webelement()
Магия работает просто — если удалось обратиться к webelement, значит он ещё есть на странице. Если не удалось обратиться — значит DOM-дерево перестроилось, необходимо найти новый элемент.
Важно — не всем методам необходим webelement. Например, чтобы проверить, что элемент отображается на странице, нам необязательно привязываться к webelement, достаточно xpath-локатора.
Добавим интерактивных методов, таких как клик, получение и ввод текста:
locator.py
def click(self): self._webelement_required() self.webelement.click() def text(self) -> str: self._webelement_required() return self.webelement.text def input(self, value: str): self._webelement_required() self.webelement.send_keys(value)
Полный код locator.py:
locator.py
from __future__ import annotations from selenium.common.exceptions import StaleElementReferenceException from selenium.webdriver.common.by import By from selenium.webdriver.remote.webelement import WebElement from selenium.webdriver.support import expected_conditions from selenium.webdriver.support.wait import WebDriverWait from driver import Driver class Locator: xpath: str = None name: str = None webelement: WebElement = None def __init__(self, xpath: str, name: str): self.xpath = xpath self.name = name def _webelement_required(self): def update_webelement(): try: self.webelement = Driver().find_element_by_xpath(self.xpath) return self except Exception: raise Exception(f'Error: cannot locate {self.name}') # magic function to update webelement if there is no webelement or if DOM element changed if not self.webelement: update_webelement() return try: # if not fails, webelement still has the DOM object link self.webelement.location return except StaleElementReferenceException: update_webelement() def is_on_page(self) -> bool: elements = Driver().find_elements_by_xpath(self.xpath) if elements: return True return False def click(self): self._webelement_required() self.webelement.click() def text(self) -> str: self._webelement_required() return self.webelement.text def input(self, value: str): self._webelement_required() self.webelement.send_keys(value) def wait_for_disappear(self): WebDriverWait(Driver(), 10).until_not(expected_conditions.presence_of_element_located((By.XPATH, self.xpath))) def get_all_elements(self) -> [Locator]: response = [] for element in Driver().find_elements_by_xpath(self.xpath): locator = Locator(xpath=self.xpath, name=self.name) locator.webelement = element response.append(locator) return response
Рефакторим странички под новую реальность
Новая сущность — интерфейс для работы с xpath — готова, теперь надо применить её к существующему коду.
После недолгих манипуляций получаем следующую картину:
components/header.py
from locator import Locator class ComponentHeader: header_logo = Locator('//a[contains(@class, "tm-header__logo")]', "Header logo") header_search = Locator('//a[contains(@class, "tm-header-user-menu__search")]', "Header search") @staticmethod def verify_component(): assert ComponentHeader.header_logo.is_on_page() assert ComponentHeader.header_search.is_on_page() @staticmethod def open_search(): ComponentHeader.header_search.click()
pages/main_page.py
from components.header import ComponentHeader from locator import Locator class MainPage: Header = ComponentHeader search_input = Locator('//div[contains(@class, "tm-search__input")]//input', "Search input") search_button = Locator('//div[contains(@class, "tm-search__input")]//span', "Search button") news_block = Locator('//section[@id="news_block_1"]', "News block") @staticmethod def verify_page(): MainPage.Header.verify_component() assert MainPage.news_block.is_on_page() @staticmethod def input_search(search: str): MainPage.search_input.input(search) @staticmethod def press_search_button(): MainPage.search_button.click()
pages/search_page.py
from components.header import ComponentHeader from locator import Locator class SearchPage: Header = ComponentHeader article = Locator('//div[@class="tm-articles-list"]/article', "Article") preview = Locator('//div[contains(@class, "placeholder-wrapper")]', "Article placeholder") @staticmethod def wait_for_loader_off(): SearchPage.preview.wait_for_disappear() @staticmethod def verify_page(expected_string_in_results: str): SearchPage.wait_for_loader_off() SearchPage.Header.verify_component() articles = SearchPage.article.get_all_elements() assert articles, "empty articles list" errors = [] for article in articles: if expected_string_in_results not in article.text().lower(): errors.append(f"'{expected_string_in_results}' not found in '{article.text().lower()}'") assert not errors, errors
Теперь методы внутри страниц являются однострочниками. Секундочку, это что получается, теперь можно не писать методы для каждой страницы, а просто обращаться к классу Locator?
Почистим код страниц ещё раз, а потом посмотрим на тесты.
components/header.py
from locator import Locator class ComponentHeader: header_logo = Locator('//a[contains(@class, "tm-header__logo")]', "Header logo") header_search = Locator('//a[contains(@class, "tm-header-user-menu__search")]', "Header search") @staticmethod def verify_component(): assert ComponentHeader.header_logo.is_on_page() assert ComponentHeader.header_search.is_on_page()
pages/main_page.py
from components.header import ComponentHeader from locator import Locator class MainPage: Header = ComponentHeader search_input = Locator('//div[contains(@class, "tm-search__input")]//input', "Search input") search_button = Locator('//div[contains(@class, "tm-search__input")]//span', "Search button") news_block = Locator('//section[@id="news_block_1"]', "News block") @staticmethod def verify_page(): MainPage.Header.verify_component() assert MainPage.news_block.is_on_page()
pages/search_page.py
from components.header import ComponentHeader from locator import Locator class SearchPage: Header = ComponentHeader article = Locator('//div[@class="tm-articles-list"]/article', "Article") preview = Locator('//div[contains(@class, "placeholder-wrapper")]', "Article placeholder") @staticmethod def verify_page(expected_string_in_results: str): SearchPage.preview.wait_for_disappear() SearchPage.Header.verify_component() articles = SearchPage.article.get_all_elements() assert articles, "empty articles list" errors = [] for article in articles: if expected_string_in_results not in article.text().lower(): errors.append(f"'{expected_string_in_results}' not found in '{article.text().lower()}'") assert not errors, errors
test_ozon.py
from pages.main_page import MainPage from pages.search_page import SearchPage def test_main_page(): MainPage.verify_page() def test_search(): search_string = "ozon tech" MainPage.Header.header_search.click() MainPage.search_input.input(search_string) MainPage.search_button.click() SearchPage.verify_page(search_string)
Как видим в примере тестов, инкапсуляция работы с селениумом позволяет убрать всю боль от работы с DOM внутрь фреймворка.
Результаты
Какие плюсы после всех этих улучшений:
-
быстрее внедрение новых возможностей во фреймворк;
-
проще и быстрее написание тестов за счёт уменьшения объёма кода;
-
проще и быстрее добавление новых страниц за счёт инкапсуляции работы с DOM;
-
снижение порога входа для начала автоматизации за счёт ограничения доступных методов для взаимодействия;
-
нужен минимум опытных людей для поддержки фреймворка.
Получившийся фреймворк похож на конструктор, который позволяет неразбирающимся в автоматизации начать автоматизировать под присмотром старших товарищей — более не требуются глубокие знания селениума для начала работы.
Бонус — добавляем Allure
Как proof of concept лёгкости добавления новых возможностей во фреймворк сейчас мы добавим Allure-отчёты в наши тесты.
Установим Allure:
pip install allure-pytest
Добавим его использование в класс Locator:
locator.py
def click(self): with allure.step(f"Click on {self.name}"): self._webelement_required() self.webelement.click() def input(self, value: str): with allure.step(f"Input '{value}' in {self.name}"): self._webelement_required() self.webelement.send_keys(value) def wait_for_disappear(self): with allure.step(f"Wait for disappear {self.name}"): WebDriverWait(Driver(), 10).until_not(expected_conditions.presence_of_element_located((By.XPATH, self.xpath)))
Запустим тесты с генерацией отчёта:
pytest --alluredir=./allure_results
Восхитимся нашим отчётом:
allure serve allure_results/

Вышло довольно просто, не так ли?
В конце добавлю ссылку на свой гитхаб с чуть более развитым (и чуть более устаревшим) фреймворком — https://github.com/giant-whale/selenoid-python-framework
Надеюсь, эта статья поможет улучшить тестирование в вашей компании.
P.S. Рома, теперь ты можешь просто кинуть ссылку на эту статью своим коллегам, сомневающимся в полезности изменений.
ссылка на оригинал статьи https://habr.com/ru/articles/786000/
Добавить комментарий