Java 8 интерфейсы под микроскопом. Part 2 - универсальный ElementsSupplier

Надеюсь, вы уже прониклись сутью интерфейсов на простеньких примерах из предыдущей статьи.
Теперь же мы рассмотрим более сложную тему. Я обещал приберечь рефлекшены на закуску. Самое время к ней приступать. К слову, любителям почитать на английском - оригинал.

В данной статье мы поговорим о том, как можно применить полный боекомплект Java 8 фич для построения универсального инициализатора кастомных элементов. Причем, речь пойдет о более эффективной, как по мне, связке - By + WebDriverWait + ExpectedConditions.

Вначале мы посмотрим, как можно спуститься до уровня PageElement, близкому к HtmlElements, но в основе которого лежит выше упомянутая связка.

Писать универсальный инициализатор для единственного кастомного типа было бы слишком просто. Посему, мы усложним задачу, добавив прототип SikuliDriver'а и некоего ScreenElement'а. Конечно придется их замокать, т.к. на данный момент SikuliX, к сожалению, не предоставляет интерфейс, близкий в WebDriver'у.

Итого, на входе у нас будет 2 драйвера, 2 кастомные аннотации и 2 кастомные обертки над элементами. При этом, 1 из них будет содержать набор типизированных наследников. Основная задача - создать универсальный инициализатор элементов, который сумеет подготовить все поля к непосредственному взаимодействию через WebDriver / SikuliDriver.

За основу мы возьмем код, созданный в предыдущей статье. А начнем пожалуй с самого простого - кастомных элементов. Сразу оговорюсь, листинг будет не полный, в целях экономии места. Так что если каких-то методов не досчитаетесь, полные же сорсы будут доступны по ссылке ниже на GitHub. Буду стараться передавать лишь суть.

public class HTMLElement extends By {
    public enum SearchBy {
        ID,
        XPATH,
        LINK_TEXT,
        CLASS_NAME,
        CSS_SELECTOR,
        TAG_NAME,
        NAME,
        PARTIAL_LINK_TEXT
    }

    private By locator;
    private WebDriverWait wait;

    private String elementValue;
    private SearchBy elementSearchCriteria;

    private static final long DEFAULT_TIMEOUT = 5;

    public HTMLElement(final WebDriver driver, final SearchBy elementSearchCriteria, final String elementValue) {
        this.elementSearchCriteria = elementSearchCriteria;
        this.elementValue = elementValue;

        if (driver != null) {
            this.wait = new WebDriverWait(driver, DEFAULT_TIMEOUT);
        }

        if (getElementSearchCriteria() != null && getElementValue() != null) {
            initElement(getElementSearchCriteria(), getElementValue());
        }
    }

    public WebElement waitUntil(final Function<By, ExpectedCondition<WebElement>> condition) {
        return waitUntil(condition, Optional.<Long>empty());
    }

    public WebElement waitUntil(final Function<By, ExpectedCondition<WebElement>> condition, final Optional<Long> timeout) {
        try {
            return getWait(timeout).until(condition.apply(getLocator()));
        } catch (Exception e) {
            throw new AssertionError("Unable to find element by " + getElementSearchCriteria() + " = \"" + getElementValue() + "\"", e);
        }
    }

    private FluentWait<WebDriver> getWait(final Optional<Long> timeout) {
        return timeout.map(sec -> wait.withTimeout(sec, TimeUnit.SECONDS))
                .orElse(wait.withTimeout(DEFAULT_TIMEOUT, TimeUnit.SECONDS));
    }

    private void initElement(final SearchBy searchBy, final String searchString) {
        switch (searchBy) {
            case ID:
                locator = By.id(searchString);
                break;
            case CSS_SELECTOR:
                locator = By.cssSelector(searchString);
                break;
            case CLASS_NAME:
                locator = By.className(searchString);
                break;
            case XPATH:
                locator = By.xpath(searchString);
                break;
            case LINK_TEXT:
                locator = By.linkText(searchString);
                break;
            case TAG_NAME:
                locator = By.tagName(searchString);
                break;
            case PARTIAL_LINK_TEXT:
                locator = By.partialLinkText(searchString);
                break;
            case NAME:
                locator = By.name(searchString);
                break;
        }
    }
}

Как мы видим, наш базовый HTMLElement оборачивает класс By, чтобы в итоге быть схожим по формату со стандартной связкой @FindBy + WebElement.

Одним из ключевых моментов тут является присутствие WebDriverWait и кастомного watUntil. Не так давно в одной из тем я уже показывал подобный способ передачи ExpectedConditions через функцию:

Function<By, ExpectedCondition<WebElement>> condition

Что в итоге нам позволяет задать кастомное поведение для поиска через единственный параметр:

waitUntil(ExpectedConditions::visibilityOfElementLocated);
waitUntil(ExpectedConditions::presenceOfElementLocated);
waitUntil(ExpectedConditions::elementToBeClickable);

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

Теперь давайте посмотрим на одного из потомков:

public class TextInput extends HTMLElement {
    public TextInput(final WebDriver driver, final SearchBy elementSearchCriteria, final String elementValue) {
        super(driver, elementSearchCriteria, elementValue);
    }

    public void type(final CharSequence text) {
        type(text, true);
    }

    public void clearInput() {
        waitUntil(ExpectedConditions::elementToBeClickable).clear();
    }

    public void type(final CharSequence text, final boolean clear) {
        if (clear) {
            clearInput();
        }
        waitUntil(ExpectedConditions::elementToBeClickable).sendKeys(text);
    }
}

Т.е. для инпута нашим кастомным условием будет ожидание, пока элемент не станет кликабельным.

Теперь Sikuli прототип и реализация кастомного элемента.

public interface SikuliDriver {
    ScreenElement findElement(String path, float similarity);
}

public interface ScreenElement {
    void click();

    void type(String text);
}

public class ImageElement implements ScreenElement {
    private static final Logger LOGGER = Logger.getLogger(ImageElement.class.getName());

    private String path;
    private float similarity;
    private SikuliDriver driver;

    public ImageElement(final SikuliDriver driver, final String path, final float similarity) {
        this.driver = driver;
        this.path = path;
        this.similarity = similarity;
    }

    public ScreenElement findElement() {
        return driver.findElement(path, similarity);
    }

    public void click() {
        LOGGER.info("Clicking " + toString());
        findElement().click();
    }

    public void type(final String text) {
        LOGGER.info("Typing '" + text + "' into " + toString());
        findElement().type(text);
    }

    public String toString() {
        return "ImageElement{" +
                "path='" + path + '\'' +
                ", similarity=" + similarity +
                '}';
    }
}

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

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

Теперь перемещаемся в BaseTest, который является временной свалкой хранилищем наших драйверов. Тут же мы и замокаем SikuliDriver.

private static final Map<Long, WebDriver> THREAD_REMOTE_DRIVER = new ConcurrentHashMap<>();

private static final ThreadLocal<SikuliDriver> SIKULI_DRIVER = new ThreadLocal<SikuliDriver>() {
	public SikuliDriver initialValue() {
		return mock(SikuliDriver.class);
	}
};

public static WebDriver getWebDriver() {
	return THREAD_REMOTE_DRIVER.get(currentThread().getId());
}

public static SikuliDriver getSikuliDriver() {
	return SIKULI_DRIVER.get();
}

Итак, с подготовкой все. В первой части статьи мы “готовили” PageObjectsSupplier, который доставлял нас прямиком к пустой BasePage. Теперь то всю нагрузку сместим на наш абстрактный пейдж, ибо он будет реализовывать наш магический интерфейс - инициализатор элементов - ElementsSupplier.

Сразу скажу, что материал сложный, будет много рефлекшена, лямбд, стримов и прочего хардкора. Но я попытаюсь разложить все по-кусочкам и объяснить общую концепцию. Для начала листинг целиком:

public interface ElementsSupplier {
    default <T> void initElements(final T targetPage) {
        final Stream<Class> pageChain = Stream.iterate(targetPage.getClass(), Class::getSuperclass);
        takeWhile(pageChain, pageObject -> !pageObject.equals(Object.class))
                .flatMap(pageObject -> Stream.of(pageObject.getDeclaredFields()))
                .forEach(field -> getSupportedAnnotationTypes()
                        .filter(field::isAnnotationPresent)
                        .findAny()
                        .map(field::getAnnotation)
                        .ifPresent(annotation -> initElement(targetPage, field, annotation)));
    }

    default <T> void initElement(final T page, final Field field, final Annotation annotation) {
        final Object[] values = getValues(annotation);
        getSupportedDrivers()
                .map(driver -> createInstance(field.getType(), add(values, 0, driver)))
                .filter(Optional::isPresent)
                .findAny()
                .ifPresent(value -> {
                    makeAccessible(field);
                    setField(field, page, value.get());
                });
    }

    @SuppressWarnings("unchecked")
    default Optional<?> createInstance(final Class<?> classToInit, final Object... args) {
        try {
            return Optional.ofNullable(classToInit.getConstructor(Stream.of(args)
                    .map(arg -> convertType(arg.getClass(), WebDriver.class, SikuliDriver.class))
                    .toArray(Class<?>[]::new))
                    .newInstance(args));
        } catch (Exception ignored) {
            return Optional.empty();
        }
    }

    default Object[] getValues(final Annotation annotation) {
        try {
            return Stream.of(annotation.annotationType().getDeclaredMethods())
                    .sorted(methodsComparator())
                    .map(method -> invokeMethod(method, annotation))
                    .toArray();
        } catch (Exception ignored) {
            return new Object[0];
        }
    }

    default Comparator<Method> methodsComparator() {
        return (m1, m2) -> m1.getName().compareTo(m2.getName());
    }

    default Class<?> convertType(final Class<?> fieldType, final Class<?>... types) {
        if (isPrimitiveWrapper(fieldType)) {
            return wrapperToPrimitive(fieldType);
        }

        return Stream.of(types)
                .filter(type -> type.isAssignableFrom(fieldType) && !fieldType.equals(Object.class))
                .findAny()
                .orElse(fieldType);
    }

    Stream<?> getSupportedDrivers();

    Stream<Class<? extends Annotation>> getSupportedAnnotationTypes();
}

Итак, на входе мы имеем 2 кастомных элемента, 2 кастомные аннотации и 2 драйвера, которые передаются в качестве первого аргумента конструкторам. Так что начнем мы пожалуй с конца.

Было бы неплохо каким-то образом огласить нашему инициализатору список поддерживаемых типов аннотаций и непосредственных инстансов драйверов, т.к. по факту у нас их больше 1, а могло бы быть и больше…

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

    Stream<?> getSupportedDrivers();
    Stream<Class<? extends Annotation>> getSupportedAnnotationTypes();

Шагаем в BasePage и покорно выполняем условия контракта:

public abstract class BasePage implements ElementsSupplier {
    public BasePage() {
        initElements(this);
    }
 
    @Override
    public Stream<?> getSupportedDrivers() {
        return Stream.of(BaseTest.getWebDriver(), BaseTest.getSikuliDriver());
    }
 
    @Override
    public Stream<Class<? extends Annotation>> getSupportedAnnotationTypes() {
        return Stream.of(HTML.class, Image.class);
    }
}

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

Здесь же мы видим, как конструктор явно вызывает initElements(this); - дефолтный метод подключенного интерфейса. Что-то напоминает, не так ли? :wink: При этом, this характеризует высокоуровневый элемент цепочки наследования, к примеру, LoginPage, от которого мы и будем отталкиваться при проходе по всем пейджам-суперклассам для инициализации филдов.

Теперь взглянем на базовый алгоритм:

  • Для начала мы должны пройтись по всем классам цепочки наследования, чтобы получить список доступных филдов. Сделать это можно при помощи Stream.iterate API. К сожалению, бесконечный цикл из коробки можно остановить лишь вызовом операции limit. Но нам это не подходит, ибо изначально мы не знаем, сколько родителей будет в нашей цепи. Было бы неплохо реализовать следующее условие выхода !currentPage.equals(Object.class), не так ли? Тут нам на помощь приходит библиотека-надстройка над стандартными стримами - com.codepoetics.protonpack.StreamUtils со своим takeWhile API.
final Stream<class> pageChain = Stream.iterate(targetPage.getClass(), Class::getSuperclass);
takeWhile(pageChain, pageObject -> !pageObject.equals(Object.class))
  • Далее, нам необходимо пройтись по всем филдам пейджей и проверить, присутствует ли любая из заданных нами аннотаций:
.flatMap(pageObject -> Stream.of(pageObject.getDeclaredFields()))
    .forEach(field -> getSupportedAnnotationTypes()
        .filter(field::isAnnotationPresent)
        .findAny()
  • Если что-либо было найдено, вызываем специализированный метод initElement, передав ему информацию о текущей странице, филде и непосредственной аннотации.
.map(field::getAnnotation)
.ifPresent(annotation -> initElement(targetPage, field, annotation)));
  • initElement может быть разбит на несколько логических блоков. Первым делом, мы должны получить значения найденной аннотации. Самым неприятным моментом тут является то, что getDeclaredMethods() не гарантирует строгого порядка следования элементов, как они были объявлены в классе. Но для вызова того или иного конструктора порядок таки важен. Для решения этой проблемы был добавлен дефолтный comparator по имени методов. В целом, он полностью подходит под условия конкретной задачи. Но в случае чего, вы всегда сможете переопределить дефолтный метод, задав свою собственную логику, в классе реализующем наш интерфейс.
default Object[] getValues(final Annotation annotation) {
    try {
        return Stream.of(annotation.annotationType().getDeclaredMethods())
                .sorted(methodsComparator())
                .map(method -> invokeMethod(method, annotation))
                .toArray();
    } catch (Exception ignored) {
        return new Object[0];
    }
}
  • Допустим мы получили список значений, заключенных под нашими кастомными аннотациями. Но как быть с драйверами? У нас есть список инстансов. Мы знаем, что они должны передаваться в качестве первого аргумента конструктору. Но вот незадача - драйвера то у нас generic типа. Чисто технически мы конечно можем получить список всех доступных интерфейсов по ссылке на объект. Но вот какой из них нужный? Какой будет соответствовать типу первого параметра конструкторов наших кастомных элементов? Т.е. легкого пути у нас нет. Но кто мешает нам попробовать оба драйвера, и при этом корректно хэндлить потенциальные эксепшены? Итого, мы циклически проходимся по списку драйверов и пробуем создать нужный нам инстанс путем вызова специализированного метода. При этом, мы на ходу модифицируем список значений, пришедших от аннотации, добавив в начало инстанс нашего драйвера.
final Object[] values = getValues(annotation);
getSupportedDrivers()
    .map(driver -> createInstance(field.getType(), add(values, 0, driver)))
  • createInstance использует стандартный джавовский рефлекшен API. При этом, следует заметить, что раз мы не знаем точного generic типа наших драйверов, нам предварительно придется проверить все аргументы при помощи isAssignableFrom API, подменив “на лету” нужный нам тип, к примеру с FirefoxDriver на WebDriver.
try {
    return Optional.ofNullable(classToInit.getConstructor(Stream.of(args)
            .map(arg -> convertType(arg.getClass(), WebDriver.class, SikuliDriver.class))
            .toArray(Class<?>[]::new))
            .newInstance(args));
} catch (Exception ignored) {
    return Optional.empty();
}

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

.filter(Optional::isPresent)
.findAny()
.ifPresent(value -> {
    makeAccessible(field);
    setField(field, page, value.get());
});

Вот такая коварная схема. Следует отметить, что в случае с единственным драйвером, эта же задача решалась бы намного проще, буквально в 2 дефолтных метода. Но условия неопределенности внесли свои коррективы.

Дабы все успешно заработало, нам осталось замокать ScreenElements где-нибудь в @BeforeMethod:

@BeforeMethod
void mockSikuliElements() {
    ScreenElement input = mock(ScreenElement.class);
    ScreenElement button = mock(ScreenElement.class);
    when(getSikuliDriver().findElement("inputFilePath.png", 0.8f)).thenReturn(input);
    when(getSikuliDriver().findElement("buttonUpload.png", 0.8f)).thenReturn(button);
    doNothing().when(input).type("path");
    doNothing().when(button).click();
}

Естественно это будет работать при условии существования соответствующих элементов, скажем, в HomePage:

@Image(name = "inputFilePath.png", similarity = 0.8f)
private ImageElement inputFilePath;
 
@Image(name = "buttonUpload.png", similarity = 0.8f)
private ImageElement buttonUpload;
 
@Step("Upload a file \"{0}\".")
public HomePage uploadFile(final String path) {
    inputFilePath.type(path);
    buttonUpload.click();
    return this;
}

Итоговый вариант теста будет выглядеть следующим образом:

@Test
public void correctLoginIntoGoogleAccount() {
    loadUrl("https://accounts.google.com")
            .setEmail("email")
            .clickNext()
            .setPassword("password")
            .staySignedIn(false)
            .signIn()
            .uploadFile("path");
 
    verifyTextEquals(homePage().getUsername(), "Sergey", "Username");
}

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


Это будет означать, что наши ImageElements были успешно проинициализированы, наряду с типизированными HTMLElements.

К слову, обновленный LoginPage с кастомными элементами будет выглядеть следующим образом:

public class LoginPage extends BasePage implements PageObjectsSupplier {
    @HTML(searchBy = ID, value = "Email")
    private TextInput inputEmail;

    @HTML(searchBy = ID, value = "Passwd")
    private TextInput inputPassword;

    @HTML(searchBy = ID, value = "next")
    private Button buttonNext;

    @HTML(searchBy = ID, value = "signIn")
    private Button buttonSignIn;

    @HTML(searchBy = ID, value = "PersistentCookie")
    private CheckBox checkBoxStaySignedIn;

    @Step("Type email = \"{0}\".")
    public LoginPage setEmail(final String email) {
        inputEmail.type(email);
        return this;
    }

    @Step("Type password = \"{0}\".")
    public LoginPage setPassword(final String password) {
        inputPassword.type(password);
        return this;
    }

    @Step("Set \"Stay signed in\" option = {0}.")
    public LoginPage staySignedIn(final boolean staySignedIn) {
        if (staySignedIn) {
            checkBoxStaySignedIn.check();
        } else {
            checkBoxStaySignedIn.unCheck();
        }
        return this;
    }

    @Step("Click \"Next\" button.")
    public LoginPage clickNext() {
        buttonNext.click();
        return this;
    }

    @Step("Click \"Sign in\" button.")
    public HomePage signIn() {
        buttonSignIn.click();
        return homePage();
    }
}

На этом все. Надеюсь, кому-то пригодится. Не забываем о лайках.
Фул сорс на GitHub. Пожелания, предложения, улучшения оформляем в виде PR. Enjoy it. :wink:

15 лайков