Привет, меня зовут Андрей Николаев и я занимаюсь автоматизацией тестирования в hh. Более 2/3 наших десктопных пользователей прямо сейчас используют последнюю версию Google Chrome, поэтому мы хотим, чтобы и в наших E2E-автотестах (Java+Selenium) версия браузера была максимально приближена к пользовательской. Но не всегда апгрейд версии в тестах проходит гладко (то работа с куками поменяется, то remote DevTools по умолчанию оказываются недоступны, то просто наши хитровыдуманные клики начинают кликать не туда, и т.д. и т.п.). Поэтому нельзя просто так взять и поднять версию Chrome в автотестах — нужна предварительная проверка, которая при ручном выполнении требовала множества телодвижений, поэтому в какой-то момент мы решили, что раз работа серверов стоит дешевле работы человека, то пусть они и проверяют.

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

Предусловия

Наши тесты — это свой фреймворк поверх Selenium на Java, запускаем их через Jenkins, браузеры берем в selenoid-гриде (именно docker-образы Chrome от aerokube мы и проверяем на совместимость), а браузеры ходят на тестовые стенды через прокси.

Итак, приступим

Для реализации задачи мы создали план в Bamboo, который запускает python-скрипт (лежащий в одном из репозиториев, которые предварительно клонируется при старте плана) каждое утро, пока большинство ресурсов свободно (т.к. никто еще не проснулся и не пользуется ими).

Первым делом проверяем, есть ли новый образ Chrome: если нет, то выходим, иначе вычленяем из тега образа в docker registry номер версии браузера (отфильтровывая тег latest, т.к. для нас это слово бесполезно)

код
def get_latest_tag_for_last_day():
   try:
       image_tags_url = 'https://registry.hub.docker.com/v2/repositories/selenoid/chrome/tags'
       response = requests.get(image_tags_url)
   except Exception as e:
       logger.error('Failed to retrieve docker image tags: %s', e)
       sys.exit(1)

   if response.status_code != 200:
       logger.error('Failed to retrieve docker image tags: %s', response.content)
       sys.exit(1)
   try:
       datetime_pattern = '%Y-%m-%dT%H:%M:%S.%fZ'
       results = [result for result in response.json()['results'] if result['name'] != 'latest']
       latest_tag = max(results, key=lambda result: datetime.datetime.strptime(result['last_updated'], datetime_pattern))

       last_updated = datetime.datetime.strptime(latest_tag['last_updated'], datetime_pattern)
       if datetime.datetime.utcnow() - last_updated > datetime.timedelta(days=1):
           logger.info('No new chrome images for last 24 hours.')
           sys.exit(0)

       latest_version = latest_tag['name'].split('.')[0]
   except Exception as e:
       logger.error('Failed to parse docker image tags: %s', e)
       sys.exit(1)

   logger.info('The latest version of chrome image is %s', latest_version)
   return latest_version

Также в процессе предварительной подготовки мы получаем из строки запуска:

  • адрес selenoid-ноды, куда установим свежую версию Chrome. Выбрана она на глаз так, чтобы ее лимиты были чуть выше лимита тредов в запускаемых автотестах. Мы не стали делать оверинжиниринг с перебором конфигов нод и поиском в них минимального значения для лимита браузеров. Получение параметра сделано через стандартный argparse, никакого рокетсаенса.

  • опционально: версию Chrome для тестирования (на случай, если что-то пойдет не так и нужно будет перетестировать)

  • номер запуска плана в Bamboo, чтобы потом сослаться на него в оповещении и создать одноименные ветки в репозиториях

  • из окружения: данные для авторизации в Jenkins (задаем их в Bamboo, чтобы лишний раз не светились в строке запуска) — тоже ничего особенного: os.environ.get('JENKINS_USER')

Далее мы резервируем тестовый стенд (как начинали создаваться наши стенды в текущем виде, можно почитать здесь), на котором будем запускать автотесты, чтобы никто другой им не пользовался и не влиял на результаты тестирования (это стандартная у нас практика). Делается это через ручку нашего самописного CI, так что полный код тут будет бесполезен (используйте API вашей любимой CI/CD-системы), но выглядит это довольно стандартно:
result = requests.post(f'{CI_URL}/assign_stand', json=...)

Если что-то к этому моменту пошло не так, то скрипт просто падает, план в Bamboo краснеет, заинтересованные получают уведомление. Все остальные шаги мы оборачиваем в try-except-finally, чтобы в конце откатить внесенные в инфраструктуру изменения.

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

Выключаем ноду-жертву из балансировки: балансировку осуществляет аерокубовский же ggr — Go Grid Router — из его конфига мы и удаляем эту ноду целиком вот таким "элегантным" движением руки:

брюки превращаются...
def run_ggr_playbook(host, revert=False):
   cmd = 'ansible-playbook ggr.yml -i inventory -t "chrome_image_update" ' \
         + (f'-e "excluded_host={host}"' if not revert else '')

   logger.info('Starting ansible playbook: %s', cmd)
   subprocess.run(cmd, shell=True, check=True, cwd=os.path.join(os.pardir, 'somedir/playbooks'))

сам шаблон конфига ggr: quota.xml выглядит так
...
<browser name="chrome" defaultVersion="{{ selenoid_default_browser_version }}">
   <version number="{{ selenoid_default_browser_version }}">
       <region name="grid farm">
           {% for selenoidhost in groups['selenoid-user'] %}
               {% if selenoidhost != excluded_host|default('') %}
                   <host name="{{ selenoidhost }}" port="4242" count="{{ hostvars[selenoidhost]['selenoid_browser_limit'] }}"/>
               {% endif %}
           {% endfor %}
       </region>
   </version>
</browser>
...

Примерно таким же образом меняем версию образа Chrome на нашей ноде (переменная selenoid_default_browser_version рендерится в конфиг selenoid и в аргумент модуля docker_image в плейбуке)

превращаются брюки...
def run_selenoid_playbook(host, version=None, revert=False):

   cmd = f'ansible-playbook selenoid.yml -i inventory -l "{host}" -t "chrome_image_update" ' \

         + (f'-e "selenoid_default_browser_version={version}"' if not revert else '')

   logger.info('Starting ansible playbook: %s', cmd)

   subprocess.run(cmd, shell=True, check=True, cwd=os.path.join(os.pardir, 'somedir/playbooks'))

vars-файл для ansible
selenoid_chrome_image_version: "{{ selenoid_default_browser_version }}.0"

шаблон конфига selenoid - browsers.json
…
"chrome": {
 "default": "{{ selenoid_default_browser_version }}",
 "versions": {
   "{{ selenoid_default_browser_version }}": {
     "image": "selenoid/chrome:{{ selenoid_chrome_image_version }}"

часть плейбука selenoid
- name: pull chrome image
 docker_image:
   name: selenoid/chrome:{{ selenoid_chrome_image_version }}
   source: pull
 tags:
   - chromedriver_update

Теперь всё готово к запуску тестов в Jenkins, делаем это при помощи пакета jenkinsapi. В случае упавших тестов (как говорится, какой же русский не любит быстрых прогонов с флаки-тестами) даем стенду время "подостыть" и перезапускаем упавшие тесты.
Версию Chrome в автотестах мы получаем из Java system properties и передаем её в browser capabilities/chrome options тестов, поэтому при запуске мы просто оверрайдим её:

код
def run_test_job(user, token, stand, retry=False, params=None, job_pattern='__TESTS'):
   stand_name = stand.upper()...

   jenkins = Jenkins("https://jenkins_url.org/", username=user, password=token)

   job_name = stand_name + (job_pattern if not retry else '__RETRY_1')
   job = jenkins[job_name]

   logger.info('Starting jenkins job %s', job_name)
   queue_item = job.invoke(block=True, build_params=params if params else {})
   build = queue_item.get_build()
   build_result = build.get_status()

   logger.info('Job result is: %s', build_result)
   return build_result, build.get_build_url()

...

jenkins_job_params = {
   ...,
   'OPTIONAL_PARAMS': f'-Dselenoid.host=http://{grid_host}:4242 -Dchrome.image.version={chrome_image_version}'
}
jenkins_run_result, jenkins_run_url = run_test_job(jenkins_user, jenkins_token, test_stand, params=jenkins_job_params)
if jenkins_run_result == 'UNSTABLE':
   time.sleep(30)
   # retry jobs will disappear, so we keep link to original run
   run_test_job(jenkins_user, jenkins_token, test_stand, retry=True)

Если полученную ранее версию Chrome удалось и раскатить, и запустить с ней тесты, то, кажется, она валидная, и мы можем для облегчения себе ручной части работы сделать соответствующие коммиты с поднятием версии. Конечно, для более сложных правок можно использовать xml.etree.ElementTree, yamlpath, ruamel.yaml и другие пакеты, но в данном случае это показалось излишним.

код
def set_new_version_in_configs_and_commit(repo_name, branch, file_type, version):
   local_repo_path = os.path.join(..., repo_name)
   repo = Repo(local_repo_path)

   try:
       new_branch = repo.create_head(branch)
       new_branch.checkout()

       if file_type == 'yaml':
           local_file_path = 'path/to/ansible/var'
           key_to_modify = 'selenoid_default_browser_version'
           replacement_string = f'{key_to_modify}: {version}'

       elif file_type == 'properties':
           local_file_path = 'path/to/java/system.properties'
           key_to_modify = 'chrome.image.version'
           replacement_string = f'{key_to_modify}={version}'
       else:
           raise ValueError(f'invalid replacement file type {file_type}')

       file_path = os.path.join(local_repo_path, local_file_path)
       logger.info('Overriding file: %s', file_path)

       with open(file_path, 'r', encoding='utf-8') as file:
           content = file.read()

       content = re.sub(f'{key_to_modify}.*', replacement_string, content)

       with open(file_path, 'w', encoding='utf-8') as file:
           file.write(content)

       logger.info('Committing file: %s', file_path)
       repo.git.add(local_file_path)
       repo.git.commit('-m', f'Update Chrome image for autotests to {version}')
   except Exception as exception:
       logger.error('Error while creating new configs: %s', exception)
       raise exception
   finally:
       repo.git.checkout('master')

...
set_new_version_in_configs_and_commit('auto-tests-repo', options.branch, 'properties', chrome_image_version)
set_new_version_in_configs_and_commit('deploy-repo', options.branch, 'yaml', chrome_image_version)

Пушим ветки мы через соответствующие шаги в плане Bamboo ввиду некоторых ограничений в правах, но у себя в коде вы можете сделать repo.git.push('origin', branch)

Когда всё готово, и даже если нет, в finally-блоке мы прокатываем плейбуки (с параметром revert=True), освобождаем стенд (также POST-запросом на ручку нашего CI) и отправляем в командный чат сообщение с результатами тестирования, ссылками на созданные ветки в репозиториях и запуск плана в Bamboo.

Заключение

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

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


  1. gigimon
    21.04.2023 20:32

    а не думали переехать на playwright? в нем меньше проблем с совместимостью webdriver


    1. iBljad Автор
      21.04.2023 20:32

      Обычно проблем нет или они требуют минимальных правок, поэтому пока нет смысла тратить "человеко-год" на переезд проекта, где одних только тестовых классов почти 2000 :)

      Кроме того, насколько я помню, для playwright чистого селеноида недостаточно и нужен moon, а он платный (и нам бы понадобилась максимальная лицензия).


      1. dyadyaSerezha
        21.04.2023 20:32

        2000 классов тестового фреймворка или чего?

        Сам работал с Java + Selenium, но вовремя заморозили проект и перешли на более современную систему. То есть, оставили все те 2000 классов и существующих тестов, а все новые тесты использовали новый фреймворк.

        С Selenium тоже были периодические необъяснимые фейлы в тестах (кнопка не кликалась, элемент не находился и так далее).

        Кстати, вы используете headless mode в Хроме или как? Там тоже было много проблем (2-3 года назад).


        1. iBljad Автор
          21.04.2023 20:32

          ~2000 классов — это только с тестами, которые мы начали писать еще 2010-м, так что не было возможности "перейти на современный фреймворк" :)

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


          1. dyadyaSerezha
            21.04.2023 20:32

            Все равно не понял. Мы писали классы (много) для реализации специфических для нашей системы тестовых шагов, которые исполнялись тестовым енжином на основе cucumber (тестовый фреймворк). А уже qa-инженеры писали сложные тесты, используя эти шаги.

            Но это никак не мешало начать использовать и развивать новый фреймворк для новых тестов.