Темная сторона Selenide

Привет! Решил написать заметку об одной странности Selenide, на которую натолкнулся буквально пару дней назад.

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

fd4a4f968c90335ce886f1fb3d106f5f

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

Я рассказывал о паттерне PageObject и его реализациях с использованием все того же Selenide.

Буквально пару недель назад я уже писал подобную заметку о PageObject.

Итак, мы можем описывать наши страницы в таком виде:

public class MainPage {

    public void enter_keywords(String keyword) {
        $(name("search")).type(keyword);
    }

    public void lookup_terms() {
        $(".lookupButton").click();
    }
}

Все будет работать отлично, но не всем нравится такой формат. Ок, мы можем написать немного по-другому:

public class MainPage {

    private SelenideElement searchInput =  $("#search"));
    private SelenideElement lookupButton = $(".lookupButton");

    public void enterKeywords(String keyword) {
        searchInput.val(keyword);
        return this;
    }

    public void lookupTerms() {
        lookupButton.click();
    }
}

Из-за того, что метод $() возвращает lazy proxy, мы можем писать такой код и он будет отлично работать. Ну, по крайней мере, я так думал =)

Давайте напишем тест:

class Test{

    @Test
    void testLookup(){
        Selenide.open("http://site.com",MainPage.class)
                .enterKeywords("hello")
                .lookupTerms()
        // assertion here
    }
}

Вроде как все лаконично и красиво. Запускаем тест и получаем такую ошибку:

INFO: Close webdriver: 1 -> FirefoxDriver: firefox on MAC (f966a581-ec80-784d-b57f-ac10a336544f)

Element not found {by id or name "searchInput"}
Expected: exist

Screenshot: file:/Users/sepi/Github/test/build/reports/tests/1481817473566.0.png
Timeout: 4 s.

Опа! Обратите внимание, тест падает из-за того, что не может найти элемент с именем searchInput. Но у меня нету такого локатора, у меня есть поле класса с именем searchInput.

В результате выяснения причин такого поведения выяснилось, что при написании подобного кода (см. ниже) нельзя использовать PageFactory от Selenide !!!

public class MainPage {

    public SelenideElement searchInput = $("#twotabsearchtextbox");
}

То бишь нельзя этот класс передавать в методы Selenide.open() и Selenide.page().

Что же делать в таком случае?

Нужно инициализировать страницы, как простые обекты, то есть через new MainPage(), тогда все будет работать нормально.

P/S Имхо немного неприятное поведение, которое сначала приводит к мысли о багах в библиотеке, но на самом деле причиной такого поведения является Selenium PageFactory. Именно в этом классе есть метод initElements, который и обрабатывает поля класса. Если вы c использованием Selenium напишите так:

class Page{

    WebElement header;

}

PageFactory.initElements(driver, Page.class)

В таком случае Selenium попытается найти элемент header или по name, или по id. Не верите? Посмотрите на реализацию метода org.openqa.selenium.support.pagefactory.Annotations.buildBy. Там еще есть один метод - buildByFromDefault. Именно здесь и происходит магия.

Что нужно, чтобы поправить такое поведение Selenide?

Вариант 1: (Самый простой)

Не использовать методы open() и page() для инициализации классов PageObject, написанных без исползования аннотации @FindBy:

Вариант 2: (Варварский)

Наследоваться от класса org.openqa.selenium.support.pagefactory.Annotations и переопределить поведение метода buildByFromDefault. Звучит неплохо, но на самом деле все, что мы можем, - это вызвать исключание c сообщением "You are using page factory to initialize element without @FindBy annotation". В таком случае конечный юзер хоть будет понимать, что он сделал неправильно.

Вариант 3: (Имхо правильный)

Нужно просто написать SelenidePageFactory, переопределить там метод initElements и подправить метод proxyFields.

Пример:

import org.openqa.selenium.support.PageFactory;
import org.openqa.selenium.support.pagefactory.FieldDecorator;

import java.lang.reflect.Field;

/**
 * Created by sergey on 17.12.16.
 */
public class SelenidePageFactory extends PageFactory {

    public static void initElements(FieldDecorator decorator, Object page) {
        Class<?> proxyIn = page.getClass();
        while (proxyIn != Object.class) {
            proxyFields(decorator, page, proxyIn);
            proxyIn = proxyIn.getSuperclass();
        }
    }

    private static void proxyFields(FieldDecorator decorator, Object page, Class<?> proxyIn) {
        Field[] fields = proxyIn.getDeclaredFields();
        for (Field field : fields) {
            if(isInitialized(page, field)){
                continue;
            }
            Object value = decorator.decorate(page.getClass().getClassLoader(), field);
            if (value != null) {
                try {
                    field.setAccessible(true);
                    field.set(page, value);
                } catch (IllegalAccessException e) {
                    throw new RuntimeException(e);
                }
            }
        }
    }

    private static boolean isInitialized(Object page, Field field){
        try {
            field.setAccessible(true);
            return field.get(page) != null;
        } catch (IllegalAccessException e) {
            throw new RuntimeException(e);
        }
    }
}

Почему я считаю такой вариант самым удачным?

Потому что все начнет работать, как должно:

class Page {

    public ElementsCollection posts = $$(".post");

    public SelenideElement userCounter = $(".users");

    public SelenideElement headerElement;

    @FindBy(css = ".button")
    public SelenideElement button;
}

Теперь мы может без опаски использовать методы open() и page().

Если сообщество поддержит, я превращу свое исследование в pull request.

Спасибо, что читали! До новых заметок ;)