Components Collection
В предыдущей статье мы рассмотрели как можно создавать свои типы элементов, сохраняя полную обратную совместимость с WebElement на примере ProtractorJS.
Но один элемент это хорошо, но иногда возникает необходимость работать с множеством однотипных элементов. Это могут быть результаты поиска, подсказки в autocomplete, список статей, и многое другое. Давайте для наглядности возьмем примером результаты поиска:
Что мы видим здесь? Сама страница результатов довольно типична для интернет магазинов, есть поиск, сортировки, и собственно сами результаты поиска.
Легко заметить, что все карточки продуктов имеют практически идентичную структуру DOM (небольшие различия в NEW/DISCOUNTED продуктах):
Давайте взглянем поближе на один продукт в результатах:
<article class="product-column">
<a class="link" href="https://demo.litecart.net/someUrl" title="Yellow Duck" data-id="1" data-sku="RD001" data-name="Yellow Duck" data-price="18.00">
<div class="image-wrapper">
<img class="image img-responsive" src="https://demo.litecart.net/some.jpg" alt="Yellow Duck">
<div class="sticker sale" title="On Sale">Sale</div> </div>
<div class="info">
<div class="name">Yellow Duck</div>
<div class="manufacturer-name">ACME Corp.</div>
<div class="price-wrapper">
<del class="regular-price">$20</del> <strong class="campaign-price">$18</strong>
</div>
</div>
</a>
<button class="preview btn btn-default btn-sm" data-toggle="lightbox" data-target="https://demo.litecart.net/someUrl" data-require-window-width="768" data-max-width="980">
<i class="fa fa-search-plus"></i> </button>
</article>
Используя паттерн Component мы создадим class Product, который будет описывать одну карточку продукта на этой странице. Компонент начинается с тега article
(Здесь и дальше примеры кода на TypeScript+ProtractorJS+protractor-element-extend):
import { BaseFragment } from 'protractor-element-extend'
class Product extends BaseFragment {
}
Верстка слегка отличается между продуктом без скидки, и со скидкой. Блок цены для продукта со скидкой:
<div class="price-wrapper">
<del class="regular-price">$20</del>
<strong class="campaign-price">$18</strong>
</div>
Такой же продукт без скидки:
<div class="price-wrapper">
<span class="price">$20</span>
</div>
Какие я вижу варианты чтобы цену можно было взять для всех версий?
-
Вариант 1: Использовать css selector вида -
.price-wrapper .campaign-price,.price
. Запятая в селекторе в этом случае означает ИЛИ. Соответственно будет выбран или элемент с классомcampaign-price
илиprice
- смотря что будет внутри.price-wrapper
-
Вариант 2: Взять цену из атрибута. Если мы посмотрим ближе на контейнер-ссылку в котором находится вся карточка, мы заметим что там есть атрибут
data-price
:
<a class="link" href="https://demo.litecart.net/someLink" title="Yellow Duck" data-id="1" data-sku="RD001" data-name="Yellow Duck" data-price="18.00">
Он содержит цену после применения всех скидок (если они есть).
Тут можно выбрать или первый или второй вариант (возможно найти еще способы). Я хочу использовать первый вариант, чтобы работать с той ценой которую пользователь реально видит в приложении.
Давайте реализуем эту функцию и другие для работы данными компонента:
import { BaseFragment } from 'protractor-element-extend'
class Product extends BaseFragment {
public async price(): Promise<number> {
const price: string = await this.$('.price-wrapper .campaign-price,.price').getText()
return this.parsePrice(price)
}
public async regularPrice(): Promise<number> {
const price: string = await this.$('.price-wrapper .regular-price').getText()
return this.parsePrice(price)
}
/**
* Возвращаем true, если .sale стикер существует в этом компоненте
*/
public isDiscounted(): Promise<boolean> {
// https://stackoverflow.com/questions/30099903/protractor-with-isdisplayed-i-get-nosuchelementerror-no-element-found-using
return this.$('.sticker.sale').isDisplayed().then(null, err => false)
}
public async name(): Promise<string> {
return this.$('.info .name').getText()
}
public async manufacturer(): Promise<string> {
return this.$('.info .manufacturer-name').getText()
}
public async open(): Promise<void> {
await this.click()
}
private parsePrice(price: string): Promise<number> {
// Потенциально небезопасный кусок если мы переключим сайт на другую валюту. Но пока не будем усложнять
price = price.replace('$', '')
// Используем parseFloat потому что цена может быть с копейками
return parseFloat(price)
}
}
Можно расширять наш компонент новыми функциями, но пока достаточно. Давайте теперь перейдем к описанию коллекции результатов. В библиотеке protractor-element-extend
так же доступен BaseArrayFragment
который можно использовать для создания своих коллекции компонентов. Этот объект будет не потомком стандартного Array, а будет наследоваться от протракторовского ElementArrayFinder. А также бонус в том что наша унаследованная коллекция элементов может содержать в себе не сырые ElementFinder, а наши кастомные компоненты.
Объявить свою коллекцию элементов и указать какой тип будет у каждого элемента очень просто:
import { BaseArrayFragment } from 'protractor-element-extend'
class SearchResults extends BaseArrayFragment<Product> {
constructor() {
super($$('#box-search-results article'), Product)
}
}
Сначала посмотрим на наследование от BaseArrayFragment<Product>
. Мы используем Generics чтобы получить подсказки, и проверки типов на этапе компиляции. Это дополнительная, но очень удобная фича, которая будет знакома тем кто пишет на строго типизированных языках типа Java или C#.
Теперь внимательней посмотрим на функцию-конструктор:
constructor() {
super($$('#box-search-results article'), Product)
}
super($$('#box-search-results article')
- если вы наследуетесь от BaseArrayFragment, то первым параметром нужно передать вашу коллекцию элементов которую вы хотите унаследовать. И вторым параметром - class который определяет тип каждого элемента в коллекции.
В результате, наш SearchResults будет наследником ElementArrayFinder и все функции которые доступны у него, доступны и у SearchResults. А также парочку дополнительных функций, которых почему-то нет у ElementArrayFinder. Это .find()
, .some()
и .every()
, спасибо @voropa за помощь!
Добавление в Page Object довольно простое:
class SearchResultsPage {
searchResults: SearchResults = new SearchResults()
}
Что нам это дает? Зачем все эти приседания?
На самом деле довольно многое. Давайте по порядку.
Мы получаем очень мощную возможность фильтрации и работы с нашими Product компонентами. К примеру мы хотим выбрать первый результат со скидкой:
const resultsPage = new SearchResultsPage()
// Обратите внимание что firstDiscounted остается Lazy, поиск на странице не произойдет пока мы не начнем работать с ним
const firstDiscounted = resultsPage.searchResults
// Метод filter, теперь итерируется не по ElementFinder объектам, а по нашим Product компонентам
.filter(result => result.isDiscounted())
.first()
// Или аналогично используя .find()
const firstDiscounted = await resultsPage.searchResults.find(result => result.isDiscounted())
await firstDiscounted.open()
Пример выше можно даже улучшить, если добавить небольшой getter в SearchResults class:
import { BaseArrayFragment } from 'protractor-element-extend'
class SearchResults extends BaseArrayFragment<Product> {
get discounted(): SearchResults {
return this.filter(result => result.isDiscounted())
}
constructor() {
super($$('#box-search-results article'), Product)
}
}
Теперь в тестах будет еще круче:
await resultsPage.searchResults.discounted.first().open()
Можно получать что-то из коллекции, просто добавляя методы которые будут работать как нам нужно:
import { BaseArrayFragment } from 'protractor-element-extend'
class SearchResults extends BaseArrayFragment<Product> {
get discounted(): SearchResults {
return this.filter(result => result.isDiscounted())
}
constructor() {
super($$('#box-search-results article'), Product)
}
getByProductName(neededName: string): Promise<Product> {
return this.find(async product => {
const productName = await product.name()
return productName.includes(neededName)
})
}
}
В тестах:
const yellowDuck = resultsPage.searchResults.getByProductName('Yellow Duck')
console.log(`Yellow duck has price:`, await yellowDuck.price())
console.log(`Yellow duck has manufacturer:`, await yellowDuck.manufacturer())
Если нужно получить информацию по каждому продукту, можно добавить метод в Product, который будет собирать объект с данными(что-то вроде модели):
class Product extends BaseFragment {
// ... rest of the code
getProductDetails(): Promise<ProductDetails> {
return {
name: await this.name(),
manufacturer: await this.manufacturer(),
price: await this.price(),
regularPrice: await this.regularPrice(),
isDiscounted: await this.isDiscounted()
}
}
}
interface ProductDetails {
name: string
manufacturer: string
price: number
regularPrice: number
isDiscounted: boolean
}
А потом достаточно вызвать этот метод внутри .map()
функции в нашем SearchResults :
class SearchResults extends BaseArrayFragment<Product> {
// ... rest of the code
getAllProductDetails(): Promise<ProductDetails[]> {
return this.map(product => product.getProductDetails())
}
}
Теперь в тестах можно получить массив со всеми деталями, и работать с ним:
const res = await resultsPage.searchResults.getAllProductDetails()
console.log('first product discounted?', res[0].isDiscounted)
А так же другие возможности, к примеру - выбор результата у которого цена больше указанной:
class SearchResults extends BaseArrayFragment<Product> {
// ... rest of the code
async openProductWithPriceGreatherThan(minimalNeededPrice: number): Promise<ProductDetails> {
const found = await this.find(async product => await product.price() > minimalNeededPrice)
const details = await found.getProductDetails()
await found.open()
return details
}
А как вам идея по использованию паттерна Builder для фильтрации элементов на странице?
const details = await resultsPage.searchResults
.findProduct()
.withManufacturer('ACME Corp.')
.withPriceGreatherThan(10.00)
.withDiscount(true)
.open();
console.log('Opened product: ', details)