Симуляция разрыва сетевого соединения для AJAX элемента в Selenium WebDriver тестах с использованием BrowserMob-Proxy на Java

Иногда, особенно при тестировании AJAX-компонент страницы, вам бывает необходимо проверить насколько корректно такой компонент обрабатывает проблемы, возникшие на уровне сетевой инфраструктуры. Существует несколько подходов, решающих упомянутую задачу в автоматизированных тестах на Selenium и WebDriver. Так как сам Selenium не имеет инструментария для влияния на исходящий и входящий трафик, я покажу как использовать библиотеку BrowserMob-Proxy в таких случаях. Ниже представлены ссылки на "якорные" точки статьи, так что вы можете пропустить то, что уже знаете.

Давайте теперь взглянем на всё более детально.

Описание примера: асинхронный вызов со страницы при разорванном сетевом соединении

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

Мы собираемся реализовать Java-тест, используя WebDriver (в моём случае это GeckoDriver, но особого значения это не имеет), Selenium и BrowserMob-Proxy библиотеки. Тест будет заходить на страницу, убеждаться в том, что соединение определяется установленным, далее тест будет симулировать проблему с сетью и проверять что на странице определяется поведение, характерное для проблемы с сетью. Затем тест остановит симуляцию и убедится, что все работает как работало в исходном режиме.

В своих примерах я не буду использовать фреймворки юнит-тестирования, такие как JUnit или TestNG. Для упрощения представления я буду запускать всё из main метода.

Как реализован асинхронный контрол в примерах

На странице находится кнопка. На эту кнопку повешен обработчик события щелчка. Код обработчика показан ниже:

function test() {
  var xhttp = new XMLHttpRequest();
  xhttp.onreadystatechange = function() {
    if (this.readyState == 4) {
      if(this.status == 200){
        document.getElementById("result").innerHTML = 'Connection OK!'
      }else{
        document.getElementById("result").innerHTML = 'Connection NOT OK!'
      }
    }
  };
  xhttp.open("GET", "/en/welcome");
  xhttp.send();
}

Кнопка генерирует реквест, который по достижении состояния COMPLETED проверяет возвращенный статус. Таким образом, любое значение поля status отличное от 200 будет определять наличие сбоя в сетевой инфраструктуре.

Зависимости Maven

Так должна выглядеть секция dependencies в вашем файле pom.xml.

<dependencies>
    <dependency>
        <groupId>org.seleniumhq.selenium</groupId>
        <artifactId>selenium-java</artifactId>
        <version>3.141.59</version>
    </dependency>
    <dependency>
        <groupId>net.lightbody.bmp</groupId>
        <artifactId>browsermob-core</artifactId>
        <version>2.1.5</version>
    </dependency>
</dependencies>

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

Шаблон теста

Ниже представлен шаблон теста, реализующий 80% задачи.

package click.webelement.connection;

import net.lightbody.bmp.BrowserMobProxy;
import net.lightbody.bmp.BrowserMobProxyServer;
import net.lightbody.bmp.client.ClientUtil;
import org.openqa.selenium.By;
import org.openqa.selenium.NoSuchElementException;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.firefox.FirefoxDriver;
import org.openqa.selenium.firefox.FirefoxOptions;
import org.openqa.selenium.support.ui.ExpectedConditions;
import org.openqa.selenium.support.ui.FluentWait;
import org.openqa.selenium.support.ui.Wait;
import java.time.Duration;
import java.util.concurrent.TimeUnit;

public class TestBrokenConnection {

    WebDriver driver;
    BrowserMobProxy proxy;
    // PLACEHOLDER 1

    public TestBrokenConnection(){
        System.setProperty("webdriver.gecko.driver"
                , "/PATH_TO_WEBDRIVER/geckodriver");
        proxy = new BrowserMobProxyServer();
        proxy.start(0);
        // PLACEHOLDER 2
        FirefoxOptions ffOptions = new FirefoxOptions();
        ffOptions.setProxy(ClientUtil.createSeleniumProxy(proxy));
        driver = new FirefoxDriver(ffOptions);
    }

    private void test(){
        By CON_OK = By.xpath("//div[@id='result'][text()='Connection OK!']");
        By CON_NOK = By.xpath("//div[@id='result'][text()='Connection NOT OK!']");
        Wait<WebDriver> wait = new FluentWait<>(driver)
                .ignoring(NoSuchElementException.class)
                .withTimeout(Duration.ofSeconds(5));
        try{
            driver.get("https://webelement.click/stand/connection?lang=en");
            driver.findElement(By.tagName("button")).click();
            wait.until(ExpectedConditions.visibilityOfElementLocated(CON_OK));
            enableConnectivityIssue();
            driver.findElement(By.tagName("button")).click();
            wait.until(ExpectedConditions.visibilityOfElementLocated(CON_NOK));
            disableConnectivityIssue();
            driver.findElement(By.tagName("button")).click();
            wait.until(ExpectedConditions.visibilityOfElementLocated(CON_OK));
        }catch (Throwable e){
            System.err.println("Something wrong has happened");
            e.printStackTrace();
        }finally {
            driver.quit();
            proxy.stop();
        }
    }

    private void enableConnectivityIssue(){
        // PLACEHOLDER 3
    }

    private void disableConnectivityIssue(){
        // PLACEHOLDER 4
    }

    public static void main(String[] args){
        new TestBrokenConnection().test();
        System.out.println("End of test");
    }

}

Давайте кратко взглянем на то что тут происходит. Итак, основная часть теста сосредоточена в методе private void test(). Логика довольно проста. Тест проверяет что сетевое соединение определяется, затем вызывает метод, который должен испортить соединение, затем проверяет что соединение не определяется и, наконец, возвращает всё обратно, снова проверяя определяемость соединения. Методы enableConnectivityIssue() и disableConnectivityIssue() представляют собой объекты нашего интереса.

Еще одним важным местом шаблона является конструктор класса. Мы используем его для того чтобы инициализировать состояние для нашего теста. В нем мы настраиваем наш драйвер и запускаем BrowserMob-Proxy сервер.

Вы также можете заметить несколько мест, отмеченных как // PLACEHOLDER…​. Чуть позже мы рассмотрим несколько способов решить нашу основную задачу, и я буду ссылаться на эти места для подстановки нового кода с целью экономии места.

Почему эта задача сложная

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

Итак, вот те три способа, которые мы собираемся рассмотреть:

  1. Простой, но не особо надёжный способ - модификация статуса ответа от сервера

  2. Способ средней сложности и относительно надёжный 1 - нарушить механизм разрешения доменных имен

  3. Способ средней сложности и относительно надёжный 2 - через настройку SecurityManager запретить сокетам подключаться к определённым адресам

Способ №1: изменение статуса ответа через ResponseFilter

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

За

  • Такой подход содержит меньше подводных камней

Против

  • Этот подход симулирует неуспешный ответ сервиса, что далеко не то же самое, что проблема сетевого соединения.

  • Вы должны знать как реализован ваш контрол.

Реализация

1) Замените // PLACEHOLDER 1 на boolean badConnection;. Мы будем использовать этот флаг внутри фильтра, чтобы тот понимал когда ответ необходимо модифицировать, а когда нет.

2) Замените // PLACEHOLDER 2 на следующий код:

proxy.addResponseFilter((response, contents, messageInfo) -> {
    if(messageInfo.getOriginalUrl().contains("en/welcome") && badConnection){
        response.setStatus(HttpResponseStatus.BAD_GATEWAY);
    }
});

Здесь мы добавляем фильтр, в котором проверяется был ли запрос направлен на специфичный URI и необходимо ли "испортить" соединение. Если обе проверки возвращают true, то в ответе статус меняется на новый.

3) Замените // PLACEHOLDER 3 на badConnection = true;

4) Замените // PLACEHOLDER 4 на badConnection = false;

Это, в общем-то, всё.

Способ №2: нарушаем механизм разрешения доменных имён

В рамках второго способа мы внесём изменения в механизм, который используется BrowserMob-Proxy для разрешения доменных имён в ip-адреса. Этот подход несёт в себе ряд специфичных моментов, потому что здесь вовлекаются особенности протокола TCP.

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

За

  • Более надёжный подход. Он симулирует проблемы в компонентах сетевой инфраструктуры

Против

  • Может не работать если вы используете ip-адреса вместо доменных имён

Реализация

1) Замените // PLACEHOLDER 1 на AdvancedHostResolver defaultResolver;. Мы будем использовать это поле для хранения объекта, используемого для разрешения доменных имён по умолчанию. С его помощью мы собираемся восстановить настройки когда такая потребность появится.

2) Замените // PLACEHOLDER 2 на:

proxy.setIdleConnectionTimeout(1, TimeUnit.SECONDS);
defaultResolver = proxy.getHostNameResolver();

где proxy.setIdleConnectionTimeout(1, TimeUnit.SECONDS); - один из "обходных путей", о которых я говорил ранее. Эта строчка устанавливает минимально возможный период неактивности соединения, по истечении которого соединение принудительно разрывается. Это заставляет браузер открывать новое соединение, которое уже попадает в нашу ловушку. Строка defaultResolver = proxy.getHostNameResolver(); отвечает за сохранение настройки разрешения доменных имён для дальнейшего восстановления.

3) Замените // PLACEHOLDER 3 следующим кодом:

proxy.setHostNameResolver(new BasicHostResolver() {
    @Override
    public Collection<InetAddress> resolve(String host) {
        return null;
    }
});
try {
    Thread.sleep(1500);
} catch (InterruptedException e) {
    e.printStackTrace();
}

Здесь мы делаем две вещи. Сперва устанавливаем новый резолвер для нашей прокси. Этот резолвер переопределяет метод resolve(String host) таким образом, что возвращает пустое значение, что, очевидно, приводит к невозможности установить соединение. Далее мы реализуем второй "обходной путь". Т.к. неактивные соединения убиваются через 1 секунду, нам необходимо подождать чуть больше этой секунды, прежде чем продолжать взаимодействие со страницей.

4) Замените // PLACEHOLDER 4 на:

proxy.setIdleConnectionTimeout(60, TimeUnit.SECONDS);
proxy.setHostNameResolver(defaultResolver);

этим мы восстанавливаем значения по умолчанию, заданные для резолвера и таймаута неактивных соединений.

Способ №3: блокируем сокеты при помощи SecurityManager

Последний способ, который мы рассмотрим, подразумевает использование механизма управления разрешениями SecurityManager. Код стандартных библиотек Java широко использует такой механизм для авторизации тех или иных действий. Например, через него мы можем запретить открывать соединения через сокеты к определенным хостам и портам. Так как BrowserMob-Proxy работает в той же JVM что и код теста и использует те же инструменты сетевого взаимодействия, запретив соединение через сокеты, мы блокируем трафик от прокси к нашему целевому серверу.

В данном подходе мы сталкиваемся с теми же особенностями открытия и поддержки соединений, что и в предыдущем примере. Поэтому и "обходные пути" будут такими же.

За

  • Надёжный метод, работающий на базовом уровне языка. Симулирует проблемы на уровне сетевой инфраструктуры.

Против

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

  • Этот подход не очень гибок. Обходных путей здесь будет больше чем в предыдущем.

Реализация

1) Место // PLACEHOLDER 1 в этом примере мы заполнять не будем. Начнем с замены // PLACEHOLDER 2 на:

proxy.setIdleConnectionTimeout(1, TimeUnit.SECONDS);

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

2) Замените // PLACEHOLDER 2 на следующий код:

SecurityManager securityManager = new SecurityManager(){
    @Override
    public void checkConnect(String host, int port) {
        if(!"localhost".equals(host)){
            throw new AccessControlException("Blocked for test purpose.");
        }
    }

    @Override
    public void checkPermission(Permission perm) {

    }
};
System.setSecurityManager(securityManager);
try {
    Thread.sleep(1500);
} catch (InterruptedException e) {
    e.printStackTrace();
}

В этом фрагменте мы устанавливаем новый SecurityManager. Мы также переопределяем метод, используемый для проверки возможности открывать соединение через сокет по определённому адресу. Мы исключаем localhost из проверки, потому что в противном случае наша клиентская библиотека Selenium потеряет способность отправлять команды на WebDriver. Мы также переопределяем метод, используемый для проверки любых привилегий таким образом, что любая проверка разрешит запрошенное действие.

Как и в предыдущем способе, мы ждем чуть более секунды, чтобы дать возможность неактивным соединениям разорваться.

3) Замените // PLACEHOLDER 3 на следующий код:

proxy.setIdleConnectionTimeout(60, TimeUnit.SECONDS);
System.setSecurityManager(null);

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

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