Привет Хабр! Меня зовут Вячеслав Разводов, я ведущий разработчик Группы "Иннотех".

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

Ответом IT-сообщества, было появлению множества инструментов для тестирования PHPUnit, Selenium, Pytest, Unittest, AssertJ. Инструменты позволили сосредоточить на том, что тестируем и c минимальными затратами на разработку теста. Развивалась область автоматического тестирования, тестовые кейсы можно описать в виде небольших скриптов, с помощью тестовых фреймворков. Такие тесты, разработчики могут запускать в любой момент своей работы, чтобы поддерживать качество продукта. Для автоматизации тестирования веб-приложений применяется Selenium и его производные.

Selenium - это проект с открытым исходным кодом. Проект является “зонтичным” - то есть собирательным, потому что в его состав входят множество независимых компонентов Selenium WebDriver, Selenium Grid, Selenium Server, Selenium IDE и т.д. Но в сообществе когда говорят “Selenium”, часто подразумевают Selenium WebDriver.

Автотесты применяются на различных уровнях ПО. Автотестами можно проверить работу функции, модуля программы или набора модулей. Для систематизации понятий тестов, Майк Кон придумал абстракцию, которая группирует тесты по уровню детализации и назначению. Назвал ее пирамидой тестирования и описал в книге «Scrum: гибкая разработка ПО». С Selenium WebDriver специалист может разрабатывать end-to-end тесты - которые находятся на вершине пирамиды тестирования. End-to-end тесты - проверяют полную работу системы и имитируют действия пользователя. Но как сделать такой автотест для веб-приложения?

Нам понадобится скрипт на языке программирования, в котором будет описана логика и выполняются проверки результатов работы системы. Например, авторизоваться в системе, добавить товар в корзину, проверить размер картинки и заранее известным расширением экрана. Выполнять все манипуляции с нашим веб-приложением мы будем в браузере, а желательно иметь возможность выполнять скрипт в разных браузерах: для проверки на кросс-браузерность. Так вот, чтобы выполнить наш скрипт в браузере, нам понадобится универсальный интерфейс - Selenium WebDriver. WebDriver позволяет написать 1 раз тест, а использовать его практически на любом браузере. WebDriver поддерживает несколько языков программирования, но самыми популярными являются Java и Python. Разберем реализацию такого скрипта, на примере работы с календарем на web-странице. Писать будем на языке программирования Python.

Постановка задачи

Задача: у нас есть несколько страниц c фильтром данных по дате с помощью поля с календарем (далее datepicker). Необходимо создать инструмент, который позволит задавать дату и локаторы элементов (идентификаторы, например см. css-селекторы) управления календарем. Инструмент был уже реализовал алгоритмом выбора даты в календаре. Алгоритм выбора даты включает шаги описанные на схеме 1.

Схема 1. Алгоритм решения задачи автоматизации работы с календарем
Схема 1. Алгоритм решения задачи автоматизации работы с календарем

При разработке решения задачи, нужно учитывать что строка Месяц+Год, может быть единой строкой с разделителем (рис. 2) или находиться в разных тэгах (см. рис 3).

Рис. 2 Месяц и год разделены запятой.
Рис. 2 Месяц и год разделены запятой.
Рис. 3. Месяц и год находится в разных тегах.
Рис. 3. Месяц и год находится в разных тегах.

Разработка решения

Назовем разрабатываемое программное решение - движок календаря (calendar_engine). Для иллюстрации движка календаря работы, напишем тесты с использованием Selenium WebDriver и фреймворка pytest. Фреймворк pytest - это библиотека на python, упрощает структурирование и разработку тестов.

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

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

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

  • input_locators - это локатор для Selenium элемента клик по которому будет вызываться календарь. Таким элементом может быть как кнопка с иконкой календаря, так и само поле.

  • back_button_locators - это локатор кнопки для Selenium предыдущий месяц, на календаре.

  • next_button_locators - это локатор кнопки для Selenium следующий месяц, на календаре.

  • calendar_locators - это локатор для Selenium области календаря.

  • day_locators - это локатор элемента для Selenium в котором выводятся номер дня.

  • browser - объект WebDriver, который мы используем для взаимодействия с содержимым браузера.

  • month_locators - это локатор элемента для Selenium в котором хранится название месяца (опционально).

  • year_locators - это локатор элемента для Selenium в котором хранится значение года (опционально).

  • month_and_year_locators - это локатор для Selenium элемента в котором месяц и год в одну строке и разделены delimiter (опционально).

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

  • delimiter - это разделитель для случая, когда месяц и год в одну строку (опционально).

# Описание конструктора класса Calendar
class Calendar:
	_input = None
  _back_button_locators = None
  _next_button_locators = None
  _day_locators = None
  _month_locators = None
  _year_locators = None
  _month_and_year_locators = None
  _lang = None

  month_name = {
      'ru': {
          'январь': 1,
          'февраль': 2,
          'март': 3,
          'апрель': 4,
          'май': 5,
          'июнь': 6,
          'июль': 7,
          'август': 8,
          'сентябрь': 9,
          'октябрь': 10,
          'ноябрь': 11,
          'декабрь': 12
      }
  }

	def __init__(self,
	                 input_locators: Tuple[str, str],
	                 back_button_locators: Tuple[str, str],
	                 next_button_locators: Tuple[str, str],
	                 calendar_locators: Tuple[str, str],
	                 day_locators: Tuple[str, str],
	                 browser: WebDriver,
	                 month_locators: Optional[Tuple[str, str]] = None,
	                 year_locators: Optional[Tuple[str, str]] = None,
	                 month_and_year_locators: Optional[Tuple[str, str]] = None,
	                 lang='ru',
	                 delimiter=','
	                 ):
	        self._input = input_locators
	        self._back_button_locators = back_button_locators
	        self._next_button_locators = next_button_locators
	        self._calendar_locators = calendar_locators
	        self._day_locators = day_locators
	        self._month_locators = month_locators
	        self._year_locators = year_locators
	        self._month_and_year_locators = month_and_year_locators
	        self._lang = lang
	        self.browser = browser
	        self.delimiter = delimiter

Что такое локатор элемента для Selenium? Локатор элемента для Selenium - это специальное выражение, которое используется в автоматизации тестирования веб-приложений с помощью инструмента Selenium. Локатор элемента позволяет идентифицировать и взаимодействовать с конкретными элементами веб-страницы, такими как кнопки, текстовые поля, выпадающие списки и другие. Selenium поддерживает различные типы локаторов элементов, которые можно использовать для их поиска. В данном проекте используется CSS-selectors.

Вот пример локатора для поля ввода даты который будет использоваться тестах для проекта.

# Пример кода определения локаторов (locators.py)

# импортируем констранты которые описывают поодерживаемы типа локаторов (селеторов)
from selenium.webdriver.common.by import By

# определяем локатор поля ввода даты 
input_value = (By.CSS_SELECTOR, "div#datepicker1 input")

Если вы знакомы с таким js-фреймворком JQuery, запись локатора вам будет понятна. В противном случае, рекомендую почитать отдельные материалы на эту тему. Тема локатор достаточно объемна, чтобы рассматривать ее в рамках это статьи.

Прежде чем перейти к реализации “рабочего” метода класса Calendar, нам нужно реализовать вспомогательные методы:

  • проверка что элемент кликабелен.

  • получения элемента/элементов в течении таймаута.

Методы необходимы из-за разницы скорости работы браузера и скрипта. Скрипт выполняется быстрей, чем браузер отрабатывает отображение страницы или взаимодействие c элементами управления. Как следствие, в скрипте нужны паузы в процессе выполнения. Например ожидание, что элемент кликабелен - это значит что он не перекрыт другими элементами, не отключен и пользователь может на него нажать. Размер - это паузы, он зависит разных факторов начиная со скорости интернет-соединения до характеристик компьютера клиента.

Исходя из описанных условий Selenium, предоставляет инструмент “неявное ожидание”. Неявное ожидание - это когда в скрипте задается условие проверки (например, что элемент кликабелен) и максимальное время ожидание (например, 10 секунд). В течении указанного таймаута, Selenium переодически будет выполнять заданную проверку. Если условие выполнится раньше заданного таймату - то успех, в противном случае ошибка.

Разберем пример реализации получения элемента в течении таймаута. WebDriverWait - это класс в библиотеке Selenium, который предоставляет возможность ожидания определенных условий перед выполнением действий веб-драйвером. С помощью WebDriverWait реализуем неявное ожидание появления элемента на странице. Для этого используем условие реализации условия проверки EC.presence_of_element_located. Если условие выполнится до истечения заданого таймауна (см. timeout), то функция вернет нам найденый элемент.

Локатор элемента - метод get_element_timeout на вход получает отдельными переменными. Переменная how (Как? ) - то есть подразумевается типа локатора, в этом случае CSS_SELECTOR. Переменная what - непосредственно значение локатора (селектора) по которому ищем элемент.

from selenium.webdriver.remote.webelement import WebElement
from selenium.common.exceptions import TimeoutException
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.support.ui import WebDriverWait


class Calendar:

		...

        # Пример функции получения элемента в течении таймаута
		def get_element_timeout(self, how: str, what: str, timeout: int = 10) -> Optional[WebElement]:
		    """Возвращает элемент, или если вышел timeout None.
		
		    :param how: тип селектора (CSS_SELECTOR, XPATH, ID и т.д.)
		    :param what: селектор
		    :param timeout: время ожидания появления элемента
		    :return: объект класс WebElement - который описывает найденный элемент
		    """
		    try:
		        element = WebDriverWait(self.browser, timeout).until(EC.presence_of_element_located((how, what)))
		    except TimeoutException:
		        return None
		    return element

Реализация функций, это проверка, что элемент кликабелен, получение элементов в течении таймаута - отличается от приведенной выше реализации - только сменой условия проверки готовности элемента. Код реализации можно посмотреть к репозиторий проекта на github.com.

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

# Реализация методов взаимодействия с кнопками вперед и назад
class Calendar:

		...	
		
	def next_click(self):
        """Выполняет нажатие по кнопке Следующий."""
        button = self.get_element_timeout(*self._next_button_locators)
        button.click()

    def back_click(self):
        """Выполняет нажатие по кнопке Предыдущий."""
        button = self.get_element_timeout(*self._back_button_locators)
        button.click()

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

# Функция которая возвращает текущий выбранный месяц и год.
class Calendar:

		...	
	
		def get_current_position(self, month_element: Optional[WebElement], year_element: Optional[WebElement],
		                     month_and_year: Optional[WebElement] = None) -> Tuple[int, int]:
            """Возвращает текущий выбранный месяц и год."""
            
            if month_and_year:
                month_name, year_select = month_and_year.text.split(self.delimiter)
                month_select = self.month_name[self._lang][month_name.lower()]
                return month_select, int(year_select)
            
            m = month_element.text.lower().replace(',', '')
            month_select = self.month_name[self._lang][m]
            year_select = int(year_element.text)
            return month_select, year_select

Используя полученные значения месяца и года при вызове функции get_current_position, можно определять, что нашли нужный месяц и год, или же нужно листать календарь в какую-нибудь сторону. Описанная логика реализована в методе find_cursor.

Метод find_cursor возвращает положительный ответ, если порядковый номер месяца и год в текущем открытой странице календаря совпадает, значения переданные во входным параметрам метода. В методе реализовано условие разного поведения при условии, что задана _month_and_year_locators. Такое условие необходимо, когда у месяц и год находятся в одном тэге, разделенные к примеру пробелом.

class Calendar:

		...				

		def find_cursor(self, month: int, year: int) -> bool:
            """Сравнивает текущий месяц и год с заданными значениями.
    
            :param month: месяц, который нужно выбрать
            :param year: год, который нужно выбрать
            :return:
            """
    
            if self._month_and_year_locators:
                month_and_year = self.get_element_timeout(*self._month_and_year_locators)
                month_select, year_select = self.get_current_position(month_element=None, year_element=None,
                                                                      month_and_year=month_and_year)
                if month_select == month and int(year_select) == year:
                    return True
            else:
                month = self.get_element_timeout(*self._month_locators)
                year = self.get_element_timeout(*self._year_locators)
                month_select, year_select = self.get_current_position(month_element=month, year_element=year)
                if month_select == month and year_select == year:
                    return True
    
            return False

После “подготовительных” работ по реализации вспомогательных методов, перейдем к реализации основного метода select_date. В методе реализуем бизнес-логику описанную в схеме 1. Кликом по datepicker вызываем календарь, и ожидаем его появления по таймайту, если не появился - сразу метод вызывает ошибку “Не удалось обнаружить календарь. Проверьте локаторы или увеличьте таймаут.”

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

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

class Calendar:

		...	

		def select_date(self, date):
            """Выбирает переданную дату в календаре.
    
            :param date: дата в формате ДД.MM.ГГГГ
            :return:
            """
    
            field = self.get_element_clickable_and_timeout(*self._input)
            field.click()
    
            day, month, year = date.split('.')
            calendar_element = self.get_element_timeout(*self._calendar_locators)
            if calendar_element:
                find = self.find_cursor(int(month), int(year))
                while not find:
                    # Определение текущего положения
                    if self._month_and_year_locators:
                        month_and_year = self.get_element_timeout(*self._month_and_year_locators)
                        month_select, year_select = self.get_current_position(month_element=None,
                                                                              year_element=None,
                                                                              month_and_year=month_and_year)
                    else:
                        month_element = self.get_element_timeout(*self._month_locators)
                        year_element = self.get_element_timeout(*self._year_locators)
                        month_select, year_select = self.get_current_position(month_element=month_element,
                                                                              year_element=year_element)
    
                    if year_select == int(year):
                        if month_select > int(month):
                            self.back_click()
                        elif month_select == int(month):
                            break
                        else:
                            self.next_click()
                    elif year_select > int(year):
                        self.back_click()
                    else:
                        self.next_click()
    
                    find = self.find_cursor(int(month), int(year))
    
                days = self.browser.find_elements(*self._day_locators)
                for d in days:
                    if d.text.strip():
                        if int(d.text) == int(day):
                            d.click()
                            return
                raise NoDateCanBeSelected(f"Не могу выбрать дату {date}")
            raise NotFoundCalendar("Не удалось обнаружить календарь. Проверьте локаторы или увеличьте таймаут.")

В качестве примера, приведу код вызова класса в рамках одного из тестов проекта. В первой строке, мы вычисляем дату, отнимая от текущей даты 42 дня. После получаем объект класс Calendar, передав на вход необходые локаторы элементов и объект Selenium Webdriver. После вызываем метод select_date и на вход передаем дату в виде строки. В конце теста проверяем, что после наших манипуляций поле ввода (input) указана дата, которую мы задавали.

def test_calendar_prev_month(self):
    """Тест выбора дня в предыдущем месяце."""
    prev_month = datetime.datetime.now() - datetime.timedelta(days=42)
    obj = Calendar(
        input_locators=locators.input_datepicker1,
        back_button_locators=locators.back_button_locators,
        next_button_locators=locators.next_button_locators,
        calendar_locators=locators.calendar,
        day_locators=locators.day_locators,
        browser=self.driver,
        month_and_year_locators=locators.month_and_year_locators,
        delimiter=' ',
    )

    obj.select_date(prev_month.strftime('%d.%m.%Y'))
    input_value = self.driver.find_element(*locators.input_value)
    assert input_value.get_attribute('value') == prev_month.strftime('%d.%m.%Y'), "Не выбрана дата."

Выводы

В данной статье мы рассмотрели, как с помощью Selenium WebDriver на языке Python можно автоматизировать работу с календарем (datepicker) на веб-странице.

После прочтения статьи, может появиться закономерный вопрос: Почему бы сразу не подставлять даты в поле в вода? Зачем нужны манипуляции с календарем datepicker?

Приведеные примеры в проекте - это упрощая вариация использования datapicker. В рабочих задачах вы можете столкнуться со следующими ситуациями:

  • поле ввода datapicker будет заблокированно на прямой ввод данных, и можно выбрать дату только через календарь.

  • прямое изменение значения в поле ввода, не вызывает триггер нужного события.

  • в календаре можно выбрать только определенные дни в месяце, помеченные определенным css классом.

При написании автоматических тестов с Selenium WebDriver, в указанных ситуациях приведенный пример будет незаменим. Теперь вы можете написать свой собственный тестовый сценарий для любого веб-приложения, используя Selenium WebDriver! Исходный код проекта можно посмотреть здесь.

Комментарии (4)


  1. nikolz
    18.07.2023 03:47

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

    Прикольно, не предполагал, что об этом кто-то спорил.

    Предположу, что спорили либо те, кто не изучал технологию разработки ПО,

    либо те, кто считает себя гением программирования и был уверен,

    что пишет в один присест ПО ,

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

    --------------------------

    Законы Мерфи (для тех, кто не знал, что ПО надо тестировать):

    1. Если бы строители строили здания так же, как программисты пишут программы, первый залетевший дятел разрушил бы цивилизацию."

    2. Неточно спланированная программа требует в три раза больше времени, чем предполагалось; тщательно спланированная - только в два раза.

    3. Самая грубая ошибка будет выявлена, лишь когда программа пробудет в производстве, по крайней мере, полгода.


  1. accp775
    18.07.2023 03:47

    Все здорово, только mouth_locators (mouth_select и т.д.) слабо ассоциируется с месяцами.


  1. MyShinobi Автор
    18.07.2023 03:47

    Справедливо. Буду исправляться)


  1. soldatov056
    18.07.2023 03:47

    Полезная статья)