Monkeyrunner. Pixel-perfect тестирование web-страниц на Android

    С тех пор как Гугл выпустил в свет инструмент для автоматизации тестирования monkeyrunner прошло немало времени, а улучшений в нем не видно. Тем не менее, для задачи регулярной проверки веб-страниц на корректность верстки лучшего инструмента не нашлось. Те, кому просто нужен готовый скрипт для сравнения скриншотов страниц на андроиде с поддержкой прокрутки, могут сразу скачать его по ссылке. Под катом же будет рассказано, какие проблемы таит манкейраннер, и как их преодолевать.

    Задача

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

    Алгоритм решения

    Сам по себе он выглядит достаточно просто:
    1. Открыть страницу из списка
    2. Сделать скриншот, сравнить его с рефренсным
    3. Если скриншот не сильно отличается, прокрутить страницу на один экран и повторить пункт 2
    4. Если страница закончилась, или результат сильно отличается от оригинала, перейти к следующей странице

    Для снятия скриншотов monkeyrunner имеет готовую функцию takeSnapshot, а для их сравнения есть совершенно прекрасный ImageMagick, который позволяет оценить насколько картинки похожи, а не просто требовать 100% совпадения. У ИмейджМеджика множество метрик сравнения, я использую -metric RMSE.
    Чтобы определить, когда пора закончить скролить станицу вниз, достаточно сравнить два последних скрина, если они совпали, значит конец достигнут.
    Для экспорта результатов в CI или любимую IDE используется python-junit-xml

    Подводные камни

    Теперь самое интересное: если сделать все это «в лоб», то ничего не будет работать. Причин несколько.

    1. Windows
    monkeyrunner имеет интересную «фичу», monkeyrunner.bat под Windows в процессе исполнения подменяет текущий каталог на тот, где лежит сам манкейраннер, потому что ему так удобно. В итоге в нашем скрипте перестают работать все относительные пути и директивы import
    Чтобы побороть это поведение, срипт должен сам определить свое местоположение и дальше действовать только по абсолютным путям. Делается это примерно так:
    filepath = path.split(os.path.realpath(__file__))[0]
    BASE_PATH = path.split(filepath)[0].encode(FILENAMES_ENCODING)
    
    try:
        import config
        from junit_xmls import TestSuite, TestCase
    except ImportError:
        #dirty hack that loads 3rd party modules from script's dir not from working dir, which is always changed by windows monkeyrynner
        import imp
        
        config = imp.load_source('config', BASE_PATH+'/src/config.py')
        junit_xml = imp.load_source('junit_xml', BASE_PATH+'/src/junit_xml/__init__.py')
        TestSuite = junit_xml.TestSuite
        TestCase = junit_xml.TestCase
    


    2. Русские символы
    Первая же попытка протестировать русскую википедию провалилась, так как манкейраннер основан на втором питоне и наследует все известные проблемы unicode и национальными символами. Пришлось в явной форме указывать кодировку везде, где возможно, а также немного модифицировать junit_xml, автор которой не подозревал о национальных символах.
    Например, для корректного создания файлов нужно перевести имя в unicode явно
    filePath.decode("utf-8")

    Кроме того на русскоязычных версиях Windows скрипт просто будет падать, если задан неверный путь к ImageMagick, потому что Popen просто не ожидает получить русские символы в сообщении об ошибке, а Windows свои сообщения локализует.

    3. Скрол
    Если 10 раз проскролировать одну и ту же страницу в одном и том же браузере на одном и том же девайсе с помощью MonkeyDevice.drag() мы получим 10 разных результатов. Drag просто не гарантирует, что он будет скролировать страницу одинаково. Чтобы решить эту проблему, пришлось применить следующий трюк: сравнивая новую страницу со старой, я отрезаю по несколько пикселей сверху и снизу страницы и ищу ее место ее вхождения в оригинал (к счастью ImageMagick умеет даже такое), то, насколько страница ниже или выше предполагаемой позиции, и есть погрешность скрола, нужно вычесть ее при следующем скроле. Такая обратная связь позволяет скрипту не отвалиться на третей итерации и благополучно дожить до конца страницы.

    4. Расход памяти
    Если открыть 10-20 страниц подряд любой браузер просто создаст 10-20 вкладок, со временем они сожрут всю память и браузер просто перестанет нормально реагировать на команды. В лучшем случае загрузка страниц замедлится, но обычно это приводит к тому, что monkeyrunner просто отваливается по таймауту. Чтобы избежать этого я пошел на небольшой хак: перед открытием новой страницы, текущий процесс браузера просто убивается через adb, примерно так
    device.shell('am force-stop ' + BROWSER_PACKAGE_NAME)

    Хак этот неплохо помогает стандартному AOSP браузеру в эмуляторе, но не очень действует на Хром, Оперу и ФФ, поэтому в итоге просто пришлось написать свою обертку на web-view, по сути — легковесный браузер без вкладок. Попутно решились и две других проблемы: самописный браузер не спрашивает подтверждения на доступ к местоположению пользователя и не портит скриншоты, а кроме того его можно включить в состав скрипта и он будет устанавливаться автоматически через MonkeyDevice.installPackage()

    5. Неудачные подключения
    Если завершить работу скрипта и тут же перезапустить его, скорее всего MonkeyRunner.waitForConnection() просто свалится с ошибкой, при этом повторный вызов обычно проходит уже без проблем. Поэтому финальный вариант скрипта всегда пробует подключиться к устройству дважды.

    6. Блокировка экрана
    Если девайс был заблокирован в момент запуска, скрипт просто будет работать некорректно. Этого можно избежать, сымитировав нажатие на аппаратную кнопку меню (по неизвестной причине это разблокирует Андроид аппараты)
    MonkeyDevice.press("KEYCODE_MENU", MonkeyDevice.DOWN_AND_UP)

    Но поскольку на разблокированном устройстве это приведет к появлению меню, лучше предварительно убедиться заблокирован ли девайс, сделать это можно через dumpsys
       lockScreenRegexp = re.compile('mShowingLockscreen=(true|false)')
       result = lockScreenRegexp.search(device.shell('dumpsys window policy'))
       if result:
           return (result.group(1) == 'true')
       raise RuntimeError("Couldn't determine screen lock state")
    


    Разблокировка сама по себе не включит экран на «спящем» устройстве, так что перед всеми этими операциями стоит сделать принудительный вызов MonkeyRunner.wake()
    После того как устройство успешно разблокировано, можно полагаться на то, что остальные функции начнут работать корректно.

    Итого

    Со всеми правками уже можно проверять корректность отображения страниц, не опасаясь падений через каждый 5 минут, но для лучшего результата стоит выбирать либо тестовый браузер, либо AOSP Browser из ванильного андроида.

    П.С. Код лежит на битбакете и открыт для копирования и модификаций.
    Share post

    Comments 1

    Only users with full accounts can post comments. Log in, please.