Вадим Цапий 13 ноября 2021

🐍 Создайте автотест Web UI на Python и Selenium за 7 шагов: инструкция для новичков

Если вам нужно тестировать веб-интерфейсы и логику отображения графических блоков на странице или просто автоматизировать работу в браузере, эта статья для вас. Читайте инструкцию по созданию автотеста на Python и Selenium за 7 простых шагов.
🐍 Создайте автотест Web UI на Python и Selenium за 7 шагов: инструкция для новичков
Мы будем использовать Selenium совместно с Python версий 3.x.x. Цель статьи – не дать фундаментальные знания по теории программирования и написания автотестов, а заинтересовать в этой области и показать, как они пишутся в целом.

1. Установка необходимых компонентов

Для начала работы нам потребуется установить Python на рабочую машину.

Переходим на официальный сайт Python и качаем установщик для вашей ОС (мы будем использовать Windows). В процессе инсталляции поставьте галочки на добавлении компонентов в системные переменные PATH. Дождитесь завершения процесса, и если программа попросит перезагрузки, перезагрузитесь. Если у вас Linux, интерпретатор может уже присутствовать в системе, в противном случае стоит установить его из репозитория пакетов вашего дистрибутива.

Проверьте корректность установки, перейдите в терминал (в Windows нажмите Win+R и запустите cmd или Alt+Ctrl+T в графической среде Linux). Выполните следующую команду:

        python --version
    
<i>Рис. 1. Должна быть выведена версия, а если что-то не получилось, проверьте выполнение по шагам и повторите попытку</i>
Рис. 1. Должна быть выведена версия, а если что-то не получилось, проверьте выполнение по шагам и повторите попытку

Далее нам понадобится сам Selenium:

        pip install selenium
    

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

        pip install pytest
    
Для создания приложений нужна интегрированная среда разработки или IDE (integrated development environment), но можно писать код и в обычном текстовом редакторе. Я выбрал самую популярную и удобную среду PyCharm от компании JetBrains.

Чтобы работать с браузером, помимо Selenium потребуется веб-драйвер: в нашем случае ChromeDriver – по сути это связующее звено в цепочке. Обратите внимание, что версия драйвера должна соответствовать версии браузера и вперед – к созданию проекта и написанию первого скрипта.

2. Первый скрипт с использованием драйвера

Все компоненты готовы, давайте создадим новый проект. Для этого запускаем PyCharm и в открывшимся окне выбираем New Project.

<i>Рис. 2</i>
Рис. 2

Указываем имя проекта и нажимаем Create.

Рис. 3
Рис. 3

Напишем первый тест, чтобы проверить работоспособность драйвера.

<i>Рис. 4. Пример кода в файле main.py</i>
Рис. 4. Пример кода в файле main.py

В качестве примера ресурса для тестирования возьмем популярный сайт для практики автоматизированного тестирования: https://www.saucedemo.com.

Кейс:

  • Зайти на страницу.
  • Найти элемент по id.
  • Вывести в консоль сообщение с результатом поиска.
main.py
        from selenium import webdriver

options = webdriver.ChromeOptions()
options.add_experimental_option("excludeSwitches", ["enable-logging"])
driver = webdriver.Chrome(options=options, executable_path=r'C:/Users/.../.../chromedriver.exe')
driver.get("https://www.saucedemo.com/")
input_username = driver.find_element_by_id("user-name")
if input_username is None:
   print("Элемент не найден")
else:
   print("Элемент найден")

    

После ввода кода необходимо установить библиотеку Selenium в наш проект.

Для этого нажмите на подсвеченный текст в редакторе, нажмите Alt + Enter и далее выберите Install package selenium. Это нужно делать для каждого неустановленного пакета.

<i>Рис. 5. Пример установки пакета в проект</i>
Рис. 5. Пример установки пакета в проект
🐍 Библиотека питониста
Больше полезных материалов вы найдете на нашем телеграм-канале «Библиотека питониста»
🐍🎓 Библиотека собеса по Python
Подтянуть свои знания по Python вы можете на нашем телеграм-канале «Библиотека собеса по Python»
🐍🧩 Библиотека задач по Python
Интересные задачи по Python для практики можно найти на нашем телеграм-канале «Библиотека задач по Python»

Запустить сценарий можно во встроенном эмуляторе терминала IDE или в любом другом:

        python main.py
    
<i>Рис. 6. <span>Пример
запуска скрипта из IDE</span></i>
Рис. 6. Пример запуска скрипта из IDE

Если все установлено правильно, должен запуститься браузер, который откроет страницу. Результатом запуска нашего сценария на Python, будет сообщение: “Элемент найден”.

Рис. 7. Результат выполнения скрипта.
Рис. 7. Результат выполнения скрипта.

3. Поиск элементов

В нашем скрипте присутствует следующая строка:

        input_username = driver.find_element_by_id("user-name")
    
Метод find_element_by_id позволяет процессу найти элемент в разметке HTML по наименованию атрибута id. В реализации драйвера есть несколько способов поиска элементов на странице: по name, xpath, css, id. Поиск по css и xpath являются более универсальным, но он сложнее для начинающих. Использование поиска по name и id намного удобнее, но в практической разработке используется редко. Далее я буду использовать только xpath.

Теперь давайте напишем кейс аутентификации пользователя на странице входа:

  • Шаг 1: пользователь вводит корректный username и password.
  • Шаг 2: нажимает кнопку ввода.
  • Ожидаемый результат: пользователь попадает на главную страницу магазина. Проверка заголовка на соответствие “PRODUCTS”.
main.py
        import time

from selenium import webdriver
from selenium.webdriver.common.keys import Keys


def first_test():
    options = webdriver.ChromeOptions()
    options.add_experimental_option("excludeSwitches", ["enable-logging"])
    driver = webdriver.Chrome(options=options, executable_path=r'C:/Users/.../.../chromedriver.exe')
    driver.get("https://www.saucedemo.com/")

    # Поиск элементов и присваивание к переменным.
    input_username = driver.find_element_by_xpath("//*[@id=\"user-name\"]")
    input_password = driver.find_element_by_xpath("//*[@id=\"password\"]")
    login_button = driver.find_element_by_xpath("//*[@id=\"login-button\"]")

    # Действия с формами
    input_username.send_keys("standard_user")
    input_password.send_keys("secret_sauce")
    login_button.send_keys(Keys.RETURN)

    # Поиск и проверка попадания на главную страницу
    title_text = driver.find_element_by_xpath("//*[@id=\"header_container\"]/div[2]/span")
    if title_text.text == "PRODUCTS":
        print("Мы попали на главную страницу")
    else:
        print("Ошибка поиска элемента")

    time.sleep(5)


if __name__ == '__main__':
    first_test()

    

Разберем пример пошагово:

  • Для работы с формой найдем и присвоим элементы переменным input_username, input_password и login_button с помощью xpath.
  • Далее вызовем для элемента метод send_keys с данными, которые хотим передать в текстовое поле. В нашем случае в username отправляем "standart_user", в password"secret_sauce". Проецируя поведение пользователя нажимаем Enter для ввода данных, используя метод send_keys для найденной кнопки с переданным аргументом Keys.RETURN. Этот аргумент позволяет работать с действиями клавиатуры в Selenium, аналогично нажатию на Enter на клавиатуре.
  • На главном экране нам необходимо найти и присвоить переменной элемент текста Products. Как я говорил раннее, не всегда есть возможность найти элемент по id – здесь как раз тот случай.
        title_text = driver.find_element_by_xpath("//*[@id=\"header_container\"]/div[2]/span")
    
  • Путь xpath до элемента: //*[@id=\"header_container\"]/div[2]/span.
  • Чтобы найти путь xpath, зайдите на https://www.saucedemo.com и нажмите F12, чтобы открыть инструменты разработчика. Затем выберите стрелку-указатель и кликните по элементу до которого хотите найти путь. В нашем случае до Products.
<i>Рис 8. Поиск xpath элемента в инструментах разработчика</i>
Рис 8. Поиск xpath элемента в инструментах разработчика
  • Откроется код элемента в дереве HTML, далее нужно открыть контекстное меню выделенной строки и скопировать xpath.
<i>Рис 9. Копирование пути xpath</i>
Рис 9. Копирование пути xpath
Если кратко рассматривать путь, то //* обозначает, что будут найдены все элементы на странице, а [@id=\"header_container\"] обозначает условие поиска (будут найдены все элементы на странице с тэгом id = "header_container").И далее /div[2]/span – спускаемся на второй дочерний элемент div и далее на дочерний элемент span. Сравните полученный xpath с деревом элемента в инструментах разработчика – сразу станет понятно что к чему.
  • Тут мы просто сравниваем текст найденного элемента с ожидаемым значением и выводим в консоль сообщение.
main.py
        if title_text.text == "PRODUCTS":
    print("Мы попали на главную страницу")
else:
    print("Ошибка поиска элемента")

    

При выполнении скрипта получили следующий результат:

Рис 10. Результат выполнения скрипта
Рис 10. Результат выполнения скрипта

4. Первый тест с поиском и переходом по странице

Кейс:

  • Введем логин и пароль пользователя и зайдем на главную страницу.
  • Найдем позицию с названием "Sauce Labs Fleece Jacket".
  • Перейдем на страницу товара и нажмем кнопку добавления в корзину.
  • Перейдем в корзину и проверим что там присутствует 1 позиция с названием "Sauce Labs Fleece Jacket".
main.py
        import time

from selenium import webdriver
from selenium.webdriver.common.keys import Keys


def first_test():
    options = webdriver.ChromeOptions()
    options.add_experimental_option("excludeSwitches", ["enable-logging"])
    driver = webdriver.Chrome(options=options, executable_path=r'C:/Users/…/…/chromedriver.exe')
    driver.get("https://www.saucedemo.com/")

    # Поиск элементов и присваивание к переменным.
    input_username = driver.find_element_by_xpath("//*[@id=\"user-name\"]")
    input_password = driver.find_element_by_xpath("//*[@id=\"password\"]")
    login_button = driver.find_element_by_xpath("//*[@id=\"login-button\"]")

    # Действия с формами
    input_username.send_keys("standard_user")
    input_password.send_keys("secret_sauce")
    login_button.send_keys(Keys.RETURN)

    # Поиск ссылки элемента позиции магазина и клик по ссылке
    item_name = driver.find_element_by_xpath("//*[@id=\"item_5_title_link\"]/div")
    item_name.click()

    # Поиск кнопки добавления товара и клик по этой кнопке
    item_add_button = driver.find_element_by_xpath("//*[@id=\"add-to-cart-sauce-labs-fleece-jacket\"]")
    item_add_button.click()

    # Поиск кнопки коризины и клик по этой кнопке
    shopping_cart = driver.find_element_by_xpath("//*[@id=\"shopping_cart_container\"]/a")
    shopping_cart.click()

    # Еще один поиск ссылки элемента позиции магазина
    item_name = driver.find_element_by_xpath("//*[@id=\"item_5_title_link\"]/div")
    if item_name.text == "Sauce Labs Fleece Jacket":
        print("Товар пристутствует в корзине")
    else:
        print("Товар отсутствует")

    time.sleep(5)


if __name__ == '__main__':
    first_test()

    

Из нового тут добавился только метод click(), который просто кликает по найденному элементу.

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

Ожидания в selenium: что нужно знать?

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

Selenium driver поддерживает два вида ожиданий: явное (explicit) и неявное (implicity). Для явных ожиданий есть специальные методы, которые помогут рационально использовать время выполнения теста: например, можно установить минимальное время ожидания и возвращать элемент, если он прогрузился раньше предполагаемого времени.

Пример явного ожидания:

        element = WebDriverWait(driver, 10).until(
    EC.element_to_be_clickable(
        (By.XPATH, '//*[@id=\"page_wrapper\"]/footer/ul/li[2]/a')
    )
)

    

Процесс ждет 10 секунд пока элемент станет доступным, чтобы по нему можно было кликнуть. Если элемент так и не прогрузился и недоступен для клика, генерируется исключение TimeoutException.

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

Пример неявного ожидания:

        driver.implicitly_wait(10)
    

Ожидать действия можно и с помощью time.sleep(5). У нас в примерах есть использование этого метода, но оно считается плохой практикой и обычно применяется только для дебага.

5. Рефакторинг теста, добавление ожиданий

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

Обновим наш тест, добавим необходимые ожидания для стабильности тестовых функций.

Также я вынес отдельную функцию под ожидания, куда мы просто передаем xpath и driver в виде аргументов.

main.py
        from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.common.by import By
from selenium.webdriver.support.wait import WebDriverWait
import time

from selenium import webdriver
from selenium.webdriver.common.keys import Keys


# Функция ожидания элементов
def wait_of_element_located(xpath, driver):
    element = WebDriverWait(driver, 10).until(
        EC.presence_of_element_located(
            (By.XPATH, xpath)
        )
    )
    return element


def test_add_jacket_to_the_shopcart():
    options = webdriver.ChromeOptions()
    options.add_experimental_option("excludeSwitches", ["enable-logging"])
driver = webdriver.Chrome(options=options, executable_path=r'C:/Users/…/…/chromedriver.exe')
    driver.get("https://www.saucedemo.com/")

    # Поиск и ожидание элементов и присваивание к переменным.
    input_username = wait_of_element_located(xpath='//*[@id=\"user-name\"]', driver=driver)
    input_password = wait_of_element_located(xpath='//*[@id=\"password\"]', driver=driver)
    login_button = wait_of_element_located(xpath='//*[@id=\"login-button\"]', driver=driver)

    # Действия с формами
    input_username.send_keys("standard_user")
    input_password.send_keys("secret_sauce")
    login_button.send_keys(Keys.RETURN)

    # Поиск и ождиание прогрузки ссылки элемента товара магазина и клик по ссылке
    item_name = wait_of_element_located(xpath='//*[@id=\"item_5_title_link\"]/div', driver=driver)
    item_name.click()

    # Поиск и ожидание кнопки добавления товара и клик по этой кнопке
    item_add_button = wait_of_element_located(xpath='//*[@id=\"add-to-cart-sauce-labs-fleece-jacket\"]', driver=driver)
    item_add_button.click()

    # Ждем пока товар добавится в корзину, появится span(кол-во позиций в корзине) и кликаем по корзине чтобы перейти
    wait_of_element_located(xpath='//*[@id=\"shopping_cart_container\"]/a/span', driver=driver).click()

    # Еще один поиск ссылки элемента позиции магазина
    item_name = wait_of_element_located(xpath='//*[@id=\"item_5_title_link\"]/div', driver=driver)
    if item_name.text == "Sauce Labs Fleece Jacket":
        print("Товар пристутствует в корзине")
    else:
        print("Товар отсутствует")

    time.sleep(5)


if __name__ == '__main__':
    test_add_jacket_to_the_shopcart()

    

Для запуска теста с помощью pytest в терминале введите pytest main.py. После прохождения всех этапов должен отобразиться результат прохождения.

6. Проверки, проверки, проверки

Мы плавно перешли к заключительному этапу написания теста – проверке вывода по известному ответу. Хотя тест выполняется успешно, он ничего не проверяет и является бессмысленным. Будем использовать стандартные инструкции assert или утверждения. Суть инструмента – проверить, что результат соответствует наши ожиданиям. Если соответствует, наш тест будет считаться пройденным, а в противном случае – проваленным.

Добавим в тест проверки. Будем проверять, что название куртки "Sauce Labs Fleece Jacket" и описание как в магазине.

main.py
        from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.common.by import By
from selenium.webdriver.support.wait import WebDriverWait

from selenium import webdriver
from selenium.webdriver.common.keys import Keys


# Функция ожидания элементов
def wait_of_element_located(xpath, driver):
    element = WebDriverWait(driver, 10).until(
        EC.presence_of_element_located(
            (By.XPATH, xpath)
        )
    )
    return element


def test_add_jacket_to_the_shopcart():
    options = webdriver.ChromeOptions()
    options.add_experimental_option("excludeSwitches", ["enable-logging"])
    driver = webdriver.Chrome(options=options, executable_path=r'C:/Users/…/…/chromedriver.exe')
    driver.get("https://www.saucedemo.com/")

    # Поиск и ожидание элементов и присваивание к переменным.
    input_username = wait_of_element_located(xpath='//*[@id=\"user-name\"]', driver=driver)
    input_password = wait_of_element_located(xpath='//*[@id=\"password\"]', driver=driver)
    login_button = wait_of_element_located(xpath='//*[@id=\"login-button\"]', driver=driver)

    # Действия с формами
    input_username.send_keys("standard_user")
    input_password.send_keys("secret_sauce")
    login_button.send_keys(Keys.RETURN)

    # Поиск и ождиание прогрузки ссылки элемента товара магазина и клик по ссылке
    item_name = wait_of_element_located(xpath='//*[@id=\"item_5_title_link\"]/div', driver=driver)
    item_name.click()

    # Поиск и ожидание кнопки добавления товара и клик по этой кнопке
    item_add_button = wait_of_element_located(xpath='//*[@id=\"add-to-cart-sauce-labs-fleece-jacket\"]', driver=driver)
    item_add_button.click()

    # Ждем пока товар добавится в корзину, появится span(кол-во позиций в корзине) и кликаем по корзине чтобы перейти
    wait_of_element_located(xpath='//*[@id=\"shopping_cart_container\"]/a/span', driver=driver).click()

    # Еще один поиск ссылки элемента позиции магазина
    item_name = wait_of_element_located(xpath='//*[@id=\"item_5_title_link\"]/div', driver=driver)

    item_description = wait_of_element_located(
        xpath='//*[@id=\"cart_contents_container\"]/div/div[1]/div[3]/div[2]/div[1]',
        driver=driver
    )

    assert item_name.text == "Sauce Labs Fleece Jacket"
    assert item_description.text == "It's not every day that you come across a midweight quarter-zip fleece jacket capable of handling everything from a relaxing day outdoors to a busy day at the office."

    driver.close()


if __name__ == '__main__':
    test_add_jacket_to_the_shopcart()

    

Теперь при расхождении результата и ожидаемого условия будет возвращена ошибка прохождения. Укажем название куртки "Sauce Labs Fleece Jacket1". Результат выполнения скрипта будет следующим:

Рис 11. Результат выполнения теста.
Рис 11. Результат выполнения теста.

7. Распределим логику

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

main.py
        import pytest
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.common.by import By
from selenium.webdriver.support.wait import WebDriverWait

from selenium import webdriver
from selenium.webdriver.common.keys import Keys


# Функция ожидания элементов
def wait_of_element_located(xpath, driver_init):
    element = WebDriverWait(driver_init, 10).until(
        EC.presence_of_element_located(
            (By.XPATH, xpath)
        )
    )
    return element


# Вынесем инициализцию драйвера в отдельную фикстуру pytest
@pytest.fixture
def driver_init():
    options = webdriver.ChromeOptions()
    options.add_experimental_option("excludeSwitches", ["enable-logging"])
    driver = webdriver.Chrome(options=options, executable_path=r'C:/Users/…/…/chromedriver.exe')
    driver.get("https://www.saucedemo.com/")
    yield driver
    driver.close()


# Вынесем аутентификацию юзера в отдельную функцию
def auth_user(user_name, password, driver_init):
    # Поиск и ожидание элементов и присваивание к переменным.
    input_username = wait_of_element_located(xpath='//*[@id=\"user-name\"]', driver_init=driver_init)
    input_password = wait_of_element_located(xpath='//*[@id=\"password\"]', driver_init=driver_init)
    login_button = wait_of_element_located(xpath='//*[@id=\"login-button\"]', driver_init=driver_init)

    # Действия с формами
    input_username.send_keys(user_name)
    input_password.send_keys(password)
    login_button.send_keys(Keys.RETURN)



def add_item_to_cart(xpath_item, driver_init):
    # Поиск и ождиание прогрузки ссылки элемента товара магазина и клик по ссылке
    item_name = wait_of_element_located(
        xpath=xpath_item,
        driver_init=driver_init)
    item_name.click()

    # Поиск и ожидание кнопки добавления товара и клик по этой кнопке
    item_add_button = wait_of_element_located(
        xpath='//*[@id=\"add-to-cart-sauce-labs-fleece-jacket\"]',
        driver_init=driver_init)
    item_add_button.click()

    # Ждем пока товар добавится в корзину, появится span(кол-во позиций в корзине)
    # Возвращаем True или False в зависимости добавлися товар или нет
    shop_cart_with_item = wait_of_element_located(
        xpath='//*[@id=\"shopping_cart_container\"]/a/span',
        driver_init=driver_init)
    return shop_cart_with_item


def test_add_jacket_to_the_shopcart(driver_init):
    # Аутентификация пользователя
    auth_user("standard_user", "secret_sauce", driver_init=driver_init)

    # Добавление товара в корзину и если товар добавлен переход в корзину
    add_item_to_cart(xpath_item='//*[@id=\"item_5_title_link\"]/div',
                     driver_init=driver_init).click()
    # Поиск корзины и клик
    wait_of_element_located(xpath='//*[@id=\"shopping_cart_container\"]/a',
                            driver_init=driver_init).click()

    # Поиск ссылки элемента позиции магазина
    item_name = wait_of_element_located(xpath='//*[@id=\"item_5_title_link\"]/div',
                                        driver_init=driver_init)

    # Поиск описания товара
    item_description = wait_of_element_located(xpath='//*[@id=\"cart_contents_container\"]/div/div[1]/div[3]/div[2]/div[1]',
                                               driver_init=driver_init)

    assert item_name.text == "Sauce Labs Fleece Jacket"
    assert item_description.text == "It's not every day that you come across a midweight quarter-zip fleece jacket" \
                                    " capable of handling everything from a relaxing day outdoors to a busy day at " \
                                    "the office."


if __name__ == '__main__':
    test_add_jacket_to_the_shopcart(driver_init=driver_init)

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

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

При желании можно и дальше проводить рефакторинг кода.

Рекомендации по архитектуре

  • Очевидно, что в одном файле хранить все вспомогательные функции и тесты неудобно. После добавления еще нескольких тестов даже с распределенной логикой скрипт будет похож на полотно с трудночитаемым кодом. К тому же если вы разрабатываете тесты с коллегами, без конфликтов в коде не обойтись. Для начала нужно разделить проект на модули: в одном будут находиться файлы с тестами, в другом частичная логика, в третьем – ресурсы, в четвертом – утилиты и т.д.
  • Далее следует переходить на разработку автотестов с использованием объектно-ориентированного программирования. Это сэкономит массу времени и поможет в написании сложного и лаконичного кода.
  • Стоит также обратить внимание на паттерны проектирования, особенно на PageObject и PageFactoroy. В эффективном тестировании UI они играют большую роль.
  • Все тестовые данные лучше хранить в неизменяемых классах, константах или в отдельных файлах (json, csv).

Заключение

На этом создание первого автотеста закончено. Некоторые моменты были рассмотрены поверхностно, что дает возможность пытливым умам поглотить информацию из других источников. Удачи!

МЕРОПРИЯТИЯ

Комментарии

ВАКАНСИИ

Добавить вакансию
Java Team Lead
Москва, по итогам собеседования
Senior Java Developer
Москва, по итогам собеседования
Разработчик С#
от 200000 RUB до 400000 RUB

ЛУЧШИЕ СТАТЬИ ПО ТЕМЕ