Привет, меня зовут Андрей Николаев и я занимаюсь автоматизацией тестирования в 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.
Заключение
Описанный подход помог сэкономить время, которые мы потратили на более полезные вещи (например, написание этой статьи). Надеюсь, наш опыт будет вам полезен и вдохновит на свежие идеи по автоматизации рутины, буду рад ответить на вопросы в комментариях.
gigimon
а не думали переехать на playwright? в нем меньше проблем с совместимостью webdriver
iBljad Автор
Обычно проблем нет или они требуют минимальных правок, поэтому пока нет смысла тратить "человеко-год" на переезд проекта, где одних только тестовых классов почти 2000 :)
Кроме того, насколько я помню, для playwright чистого селеноида недостаточно и нужен moon, а он платный (и нам бы понадобилась максимальная лицензия).
dyadyaSerezha
2000 классов тестового фреймворка или чего?
Сам работал с Java + Selenium, но вовремя заморозили проект и перешли на более современную систему. То есть, оставили все те 2000 классов и существующих тестов, а все новые тесты использовали новый фреймворк.
С Selenium тоже были периодические необъяснимые фейлы в тестах (кнопка не кликалась, элемент не находился и так далее).
Кстати, вы используете headless mode в Хроме или как? Там тоже было много проблем (2-3 года назад).
iBljad Автор
~2000 классов — это только с тестами, которые мы начали писать еще 2010-м, так что не было возможности "перейти на современный фреймворк" :)
headless не используем, т.к. контейнеры селеноида запускают хром в Xvfb (X virtual framebuffer), насколько я помню, выигрыш в ресурсах по сравнению с ним у хэдлесса минимальный, а спецэффекты присутствуют, поэтому больше потеряем на разборе проблем, чем выиграем от экономии железа.
dyadyaSerezha
Все равно не понял. Мы писали классы (много) для реализации специфических для нашей системы тестовых шагов, которые исполнялись тестовым енжином на основе cucumber (тестовый фреймворк). А уже qa-инженеры писали сложные тесты, используя эти шаги.
Но это никак не мешало начать использовать и развивать новый фреймворк для новых тестов.