Интро


Всем привет! Сегодня я расскажу вам о том, как с помощью PHP можно работать с Selenium.

Чаще всего это бывает необходимо, когда перед вами стоит задача написать автотесты для web интерфейса или свой парсер/краулер.

С Википедии
«Selenium — это инструмент для автоматизации действий веб-браузера.
В большинстве случаев используется для тестирования Web-приложений, но этим не
ограничивается. В частности, реализация Selenium WebDriver для браузера phantomjs
часто используется как веб-граббер.»


Мы рассмотрим следующие нюансы:

  • Использование Behat/Mink для соединения с Selenium
  • Запуск Selenium в docker, и удаленный доступ по VNC
  • Расширим функционал Behat с помощью Extension Feature

Итак, поехали!

1. Готовим Behat и Mink


Behat это php фреймворк, который изначально был создан для поведенческого тестирования (BDD). Он является официальной PHP реализацией более известного продукта Cucumber, который часто используется в других языках программирования.

Сам по себе работать с Selenium он не умеет и предназначен больше для написания функциональных тестов без использования браузера.

Устанавливается как обычный пакет:

$ composer require "behat/behat"

Чтобы научить behat работать с браузером, нам понадобится его расширение Mink, а также бридж для работы с конкретным вендором (в нашем случае это Selenium). Полный список вендоров вы сможете найти на страничке Mink. С учетом версий, ваш composer.json должен выглядеть примерно так:

    "require":       {
        "behat/behat" : "^3.4",
        "behat/mink-extension" : "2.2",
        "behat/mink-selenium2-driver" : "^1.3"
    }

После установки у вас появится vendor/bin/behat файл, ответственный за запуск тестов. Если vendor/bin/behat --version показало вам установленную версию, то с высокой долей вероятности установка прошла успешно :)

Завершающей фазой является конфигурация

Создадим основной файл конфигурации behat.yml в корне проекта
default:

# Указываем путь автолоадеру к «контекст» классам
  autoload:
    '': '%paths.base%/src/Context'
  suites:
# объявляем тест сьюты    
    facebook_suite:
# путь(и) к файлам сценариев, написанных на Gherkin language 
      paths:
        - '%paths.base%/scenario/facebook'
      contexts:
# Закрепляем определенный «контекст» класс за сьютом. 
# API класса доступно в сценарии
        - Dossier\Context\FacebookContext:
# опционально передаем параметры в конструктор класса FacebookContext
            base_url: 'https://www.facebook.com/'
            user: 'email@gmail.com'
            pass: 'password'
    vk_suite:
      paths:
        - '%paths.base%/scenario/vk'
      contexts:
        - Dossier\Context\VkContext:
# Здесь передаем инстанс класса как зависимость
            - "@lookup"
      services:
# маппим алиас к классу сервису
          lookup: 'Dossier\Context\AccessLimit\Lookup'
  extensions:
# Объявляем список расширений, используемых behat
    Behat\MinkExtension:
      browser_name: 'chrome'
      default_session: 'selenium2'
      selenium2:
# адрес Selenium сервера. В данном случае стандартный IP докера(в вашем случае может быть localhost или удаленный сервер)
        wd_host: '<a href="http://172.17.0.1:4444/wd/hub">http://172.17.0.1:4444/wd/hub</a>'
# браузер используемый по умолчанию
        browser: chrome


Файлы сценариев или (*.feature файлы) — yml файлы, написанные на псевдо-языке Gherkin, содержат, по сути, набор пошаговых инструкций, которые выполнит ваш браузер в ходе исполнения конкретного сьюта. Подробнее о синтаксисе вы можете узнать, перейдя по ссылке выше.

Каждая такая «инструкция» в свою очередь матчится на методы класса «контекста» с помощью регулярных выражений, указанных в аннотациях класса. Behat\MinkExtension\Context\MinkContext
Имена самих методов роли не играют, хотя хорошим тоном будет придерживаться аналогичного аннотациям именования в CamelCase.

Если вам не хватает доступных по умолчанию конструкций Gherkin, вы можете расширить функционал в классах наследниках MinkContext правильно указав аннотации. Эту роль и выполняют «контекстные» классы.

2. Установка и настройка окружения


Те из вас, кто уже работал с Selenium знают, что после старта теста, на машине запустится браузер и пройдет шаги, указанные в .feature файле.

Запуск Selenium в Docker немного сложнее. Во-первых вам понадобятся Иксы в контейнере, во-вторых – вам захочется увидеть, что же происходит внутри контейнера.

Ребята из Selenium уже обо всём позаботились и собирать свой контейнер вам не придется. Контейнер со Standalone сервером на борту будет сразу доступен по 5900 порту, куда можно постучаться с любого VNC клиента (например с этого). Внутри контейнера вас встретит приветливый интерфейс Fluxbox с предустановленным Chrome. В моем случае это выглядит так:



Чтобы прийти к успеху, вы можете запустить докер контейнер, согласно инструкции на сайте:

$ docker run -d -p 4444:4444 -p 5900:5900 -v /dev/shm:/dev/shm selenium/standalone-chrome-debug:3.11.0-californium

Важный момент, без шаред волюма /dev/shm хрому не хватит памяти и он не сможет запуститься, поэтому не забываем его указать.

В моем случае используется docker-compose, и YAML файл будет выглядеть следующим образом:

version: '2'

services:
  selenium:
    image: selenium/standalone-chrome-debug:3.11.0
    ports:
     - "4444:4444"
     - "5900:5900"
    volumes:
     - /dev/shm:/dev/shm
    network_mode: "host"

Я хочу, чтобы мои тесты заходили на Facebook через VPN, включенный на хост-машине, поэтому важно указать network_mode.

Чтобы запустить контейнер, используя compose, выполним следующую команду:

$ docker-compose up

Теперь пробуем подсоединиться по VNC на localhost:5900 и открыть браузер внутри контейнера. Если вам это удалось и вы видите что-то похожее на скриншот выше — вы прошли этот уровень.

3. От теории к практике. Автоматизируем


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

src/scenario/facebook/facebook.feature
Feature: Facebook Parse
  In order parse fb

  @first-level
  Scenario: Find person in facebook
    Given I am on "https://facebook.com/"
    When I fill in "email" with "some@gmail.com"
    And I fill in "pass" with "somepass"
# Кастомная инструкция	
    Then I press tricky facebook login button
    Then I should see "Поиск"
# Кастомные инстукции
    Then I am searching by input params
    Then I dump users


И соответственно Context класс (констуктор и нэймспейсы опущены)

src/Context/FacebookContext.php
class FacebookContext extends MainContext
{
    /**
     * @Then /^I press tricky facebook login button$/
     */
    public function pressFacebookButton()
    {
        $this->getSession()->getPage()->find(
             'css', 
             'input[data-testid="royal_login_button"]'
        )->click();
    }

    /**
     * Собираем интересующую меня информацию. Аватар, ссылки, доп. информацию
     * @Then /^I dump users$/
     */
    public function dumpUsers()
    {
        $session = $this->getSession();
        $users = $this->getSession()->getPage()->findAll(
            'xpath', 
            $session->getSelectorsHandler()
               ->selectorToXpath('css', 'div._4p2o')
        );
        if (!$users) {
            throw new \InvalidArgumentException("The user with this name was not found");
        }
        $collection = new UserCollection('facebook_suite');
        foreach ($users as $user) {
            $img = $user->find('xpath', $session->getSelectorsHandler()
              ->selectorToXpath(
                 'xpath', 
                 $session->getSelectorsHandler()->selectorToXpath('css', 'img')
            ));
            $link = $user->find('xpath', $session->getSelectorsHandler()
              ->selectorToXpath(
                'xpath',              
                $session->getSelectorsHandler()->selectorToXpath('css','a._32mo')
            ));
            $outputInfo = new OutputUserInfo('facebook_suite');
            $outputInfo->setName($link ? $link->getText(): '')
                ->addPublicLinks($link ? $link->getAttribute('href') : '')
                ->setPhoto($img ? $img->getAttribute('src') : '');        

           $collection->append($outputInfo);
        }
        $this->saveDump($collection);
    }

    /**
     *  Получаем поисковый запрос и подставляем его в URL
     * @Then /^I am searching by input params$/
     */
    public function search()
    {
        if (!Registry::has('query')) {
            throw new \BadMethodCallException('No search query received');
        }
        $criteria = Registry::get('query');  
        $this->getSession()->visit("https://www.facebook.com/search/people/?q=" . urldecode($criteria->getQuery()));
    }
}


Часто возникает необходимость в кастомных методах типа FacebookContext::pressFacebookButton, т.к по умолчанию все селекторы в mink умеют искать только по name|value|id|alt|title.

Если вам нужна выборка по другому атрибуту, придется писать свой метод. Кнопка Login у фейсбука имеет атрибут id, но меняет его значение периодически по какой-то своей логике. Поэтому мне и пришлось перепривязаться к data-testid, который, пока что, остается статичным.

Теперь, чтобы всё это завелось, необходимо убедиться, что Selenium запущен и слушает указанный порт.

После чего выполним:

$ vendor/bin/behat 

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

4. Кастомизация behat. Расширения


В Behat фреймворк встроен прекрасный механизм расширения через behat.yml. Обратите внимание, что многие классы фреймворка объявлены как final, чтобы умерить соблазн просто их наследовать.

Расширение позволяет дополнять функционал behat, объявлять новые консольные аргументы и опции, модифицировать поведение других расширений и др. Оно состоит из класса имплементирующего
Behat\Testwork\ServiceContainer\Extension интерфейс (он же указывается в behat.yml) и вспомогательных классов, если нужно.

Я хочу научить behat принимать ФИО искомого человека через новый входящий аргумент --search-by-fullname, чтобы в последствие использовать эти данные внутри сьюта.

Ниже привожу код, выполняющий необходимые операции:

SearchExtension
use Behat\Behat\Gherkin\ServiceContainer\GherkinExtension;
use Behat\Testwork\Cli\ServiceContainer\CliExtension;
use Behat\Testwork\ServiceContainer\Extension;
use Behat\Testwork\ServiceContainer\ExtensionManager;
use Symfony\Component\Config\Definition\Builder\ArrayNodeDefinition;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Definition;
use Symfony\Component\DependencyInjection\Reference;

class SearchExtension implements Extension
{
    /**
     * Здесь можно модифицировать контейнер со всем Extensions перед выводом
     */
    public function process(ContainerBuilder $container)  { }

    /**
     * Уникальный префикс для конфигурации раширения в behat.yml
     * @return string
     */
    public function getConfigKey()
    {
        return 'search';
    }

    /**
     * Этот метод вызывается сразу после активации всех расширений, но
     * перед вызовом метода configure(). Это позволяет расширениям
     * вклиниться в конфигурацию других расширений
     * @param ExtensionManager $extensionManager
     */
    public function initialize(ExtensionManager $extensionManager){}

    /**
     * Установка дополнительной конфигурации расширения
     * @param ArrayNodeDefinition $builder
     */
    public function configure(ArrayNodeDefinition $builder){ }

    /**
     * Загружает сервисы расширения в контейнер
     * @param ContainerBuilder $container
     * @param array $config
     */
    public function load(ContainerBuilder $container, array $config)
    {
      $definition = new Definition('Dossier\BehatSearch\SearchController', array(
            new Reference(GherkinExtension::MANAGER_ID)
     ));  
     $definition->addTag(CliExtension::CONTROLLER_TAG, array('priority' => 1));    
     $container->setDefinition(
          CliExtension::CONTROLLER_TAG .' . search', 
          $definition
     );
    }
}


В методе SearchExntesion::load пробрасывается Сервис SearchController, отвечающий непосредственно за объявление параметров и их прием/обработку.

SearchController
use Behat\Testwork\Cli\Controller;
use Dossier\Registry;
use Dossier\User\Criteria\FullnameCriteria;
use Symfony\Component\Console\Command\Command as SymfonyCommand;
use Symfony\Component\Console\Exception\InvalidOptionException;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;

class SearchController implements Controller
{
    const SEARCH_BY_FULLNAME = 'search-by-fullname';

    /**
     * Configures command to be executable by the controller.
     * @param SymfonyCommand $command
     */
    public function configure(SymfonyCommand $command)
    {
        $command->addOption( '--' . self::SEARCH_BY_FULLNAME,
            null,
            InputOption::VALUE_OPTIONAL,
            "Specify the search query based on fullname of the user. 
            Must be started from surname"
        );
    }

    /**
     * Executes controller.
     *
     * @param InputInterface $input
     * @param OutputInterface $output
     *
     * @return null|integer
     */
    public function execute(InputInterface $input, OutputInterface $output)
    {

        $reflect = new \ReflectionClass(__CLASS__);
        foreach ($reflect->getConstants() as $constName => $option) {
            if ($input->hasOption($option) && ($optValue = $input->getOption($option))) {
                $queryArgs = explode(',', $optValue);
                Registry::set('query', new FullnameCriteria(
                    $queryArgs[0], 
                    $queryArgs[1] ?? null, 
                    $queryArgs[2] ?? null)
                );
                return null;
            }
        }
        throw new \InvalidOptionException("You must specify one of the following
            options to proceed: " . implode(', ', $reflect->getConstants()));
    }
}


Если всё объявлено правильно, то список доступных команд behat дополнится новым аргументом --search-by-fullname:

$ vendor/bin/behat --help

[mkardakov@mkardakov-local dossier.io]$ vendor/bin/behat --help | grep search-by-fullname
      --search-by-fullname[=SEARCH-BY-FULLNAME]  Specify the search query based on fullname of the user. Must be started from surname
[mkardakov@mkardakov-local dossier.io]$ 

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

На этом всё. Спасибо за внимание!

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