selenium架构
在此博客文章中,我想介绍一种具有最佳设计模式的Selenium测试的简洁架构:页面对象,页面元素(通常称为HTML包装器)以及自行开发的非常小巧的框架。 该体系结构不限于示例中使用的Java,也可以以任何其他语言应用于Selenium测试。
定义和关系。
页面对象 。 页面对象封装了网页的行为。 每个网页有一个页面对象,将页面的逻辑抽象到外部。 这意味着,与网页的交互被封装在page对象中。 Selenium的“ 通过”定位器来查找页面上的元素也没有对外公开。 页面对象的调用者不应忙于By定位符,例如By.id , By.tageName , By.cssSelector等。Selenium测试类对页面对象进行操作。 从网上商店举个例子:页面对象类可以称为例如ProductPage , ShoppingCartPage , PaymentPage等。这些始终是带有自己的URL的整个网页的类。
页面元素 (又名HTML包装器 )。 页面元素是网页的另一个细分。 它表示一个HTML元素,并封装了与此元素进行交互的逻辑。 我将页面元素称为HTML包装器。 HTML包装器是可重用的,因为多个页面可以包含相同的元素。 例如,用于DatepickerHTML包装器可以提供以下方法(API):“在输入字段中设置日期”,“打开日历弹出窗口”,“在日历弹出窗口中选择给定的日期”等。其他HTML包装可以例如,自动完成,面包屑,复选框,单选按钮,MultiSelect,消息等。HTML包装器可以是复合的。 这意味着它可以包含多个小元素。 例如,产品目录由产品组成,购物车由项目组成,等等。内部元素的Selenium的“ 按”定位符封装在复合页面元素中。
Martin Fowler描述了页面对象和HTML包装器作为设计模式。
Selenium测试类的骨骼结构。
测试类的结构良好。 它以单个过程步骤的形式定义测试顺序。 我建议采用以下结构:
public class MyTestIT extends AbstractSeleniumTest {
@FlowOnPage(step = 1, desc = "Description for this method")
void flowSomePage(SomePage somePage) {
...
}
@FlowOnPage(step = 2, desc = "Description for this method")
void flowAnotherPage(AnotherPage anotherPage) {
...
}
@FlowOnPage(step = 3, desc = "Description for this method")
void flowYetAnotherPage(YetAnotherPage yetAnotherPage) {
...
}
...
}
MyTestIT类是用于集成测试的JUnit测试类。 @FlowOnPage是网页上测试逻辑的方法注释。 step参数定义测试序列中的序列号。 计数从1开始。这意味着,将在步骤= 2的方法之前处理步骤= 1的带注释的方法。第二个参数desc表示描述该方法的作用。
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface FlowOnPage {
int step() default 1;
String desc();
}
带页面对象作为方法参数调用带注释的方法。 通常,每次单击按钮或链接都会切换到下一页。 开发的框架应确保在调用带有下一步的带注释的方法之前,已完全加载下一页。 下图说明了测试类,页面对象和HTML包装器之间的关系。
别这样 用@Test注释的JUnit方法在哪里,解析@FlowOnPage注释的逻辑在哪里 ? 该代码隐藏在超类AbstractSeleniumTest中 。
public abstract class AbstractSeleniumTest {
// configurable base URL
private final String baseUrl = System.getProperty("selenium.baseUrl", "http://localhost:8080/contextRoot/");
private final WebDriver driver;
public AbstractSeleniumTest() {
// create desired WebDriver
driver = new ChromeDriver();
// you can also set here desired capabilities and so on
...
}
/**
* The single entry point to prepare and run test flow.
*/
@Test
public void testIt() throws Exception {
LoadablePage lastPageInFlow = null;
List <Method> methods = new ArrayList<>();
// Seach methods annotated with FlowOnPage in this and all super classes
Class c = this.getClass();
while (c != null) {
for (Method method: c.getDeclaredMethods()) {
if (method.isAnnotationPresent(FlowOnPage.class)) {
FlowOnPage flowOnPage = method.getAnnotation(FlowOnPage.class);
// add the method at the right position
methods.add(flowOnPage.step() - 1, method);
}
}
c = c.getSuperclass();
}
for (Method m: methods) {
Class<?>[] pTypes = m.getParameterTypes();
LoadablePage loadablePage = null;
if (pTypes != null && pTypes.length > 0) {
loadablePage = (LoadablePage) pTypes[0].newInstance();
}
if (loadablePage == null) {
throw new IllegalArgumentException("No Page Object as parameter has been found for the method " +
m.getName() + ", in the class " + this.getClass().getName());
}
// initialize Page Objects Page-Objekte and set parent-child relationship
loadablePage.init(this, m, lastPageInFlow);
lastPageInFlow = loadablePage;
}
if (lastPageInFlow == null) {
throw new IllegalStateException("Page Object to start the test was not found");
}
// start test
lastPageInFlow.get();
}
/**
* Executes the test flow logic on a given page.
*
* @throws AssertionError can be thrown by JUnit assertions
*/
public void executeFlowOnPage(LoadablePage page) {
Method m = page.getMethod();
if (m != null) {
// execute the method annotated with FlowOnPage
try {
m.setAccessible(true);
m.invoke(this, page);
} catch (Exception e) {
throw new AssertionError("Method invocation " + m.getName() +
", in the class " + page.getClass().getName() + ", failed", e);
}
}
}
@After
public void tearDown() {
// close browser
driver.quit();
}
/**
* This method is invoked by LoadablePage.
*/
public String getUrlToGo(String path) {
return baseUrl + path;
}
public WebDriver getDriver() {
return driver;
}
}
如您所见,只有一种测试方法testIt可以解析批注,创建具有关系的页面对象并启动测试流程。
页面对象的结构。
每个页面对象类均从LoadablePage类继承,而该类又从Selenium的LoadableComponent类继承。 这篇写得很好的文章对L oadableComponent进行了很好的解释: LoadableComponent的简单和高级用法 。 LoadablePage是我们自己的类,实现如下:
import org.openqa.selenium.support.ui.WebDriverWait;
import org.junit.Assert;
import org.openqa.selenium.By;
import org.openqa.selenium.Keys;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.interactions.Actions;
import org.openqa.selenium.support.PageFactory;
import org.openqa.selenium.support.ui.ExpectedCondition;
import org.openqa.selenium.support.ui.LoadableComponent;
import org.openqa.selenium.support.ui.WebDriverWait;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.lang.reflect.Method;
import java.util.List;
public abstract class LoadablePage<T extends LoadableComponent<T>> extends LoadableComponent<T> {
private final static Logger LOGGER = LoggerFactory.getLogger(LoadablePage.class);
private AbstractSeleniumTest seleniumTest;
private String pageUrl;
private Method method;
private LoadablePage parent;
/**
* Init method (invoked by the framework).
*
* @param seleniumTest instance of type AbstractSeleniumTest
* @param method to be invoked method annotated with @FlowOnPage
* @param parent parent page of type LoadablePage
*/
void init(AbstractSeleniumTest seleniumTest, Method method, LoadablePage parent) {
this.seleniumTest = seleniumTest;
this.pageUrl = seleniumTest.getUrlToGo(getUrlPath());
this.method = method;
this.parent = parent;
PageFactory.initElements(getDriver(), this);
}
/**
* Path of the URL without the context root for this page.
*
* @return String path of the URL
*/
protected abstract String getUrlPath();
/***
* Specific check which has to be implemented by every page object.
* A rudimentary check on the basis of URL is undertaken by this class.
* This method is doing an extra check if the page has been proper loaded.
*
* @throws Error thrown when the check fails
*/
protected abstract void isPageLoaded() throws Error;
@Override
protected void isLoaded() throws Error {
// min. check against the page URL
String url = getDriver().getCurrentUrl();
Assert.assertTrue("You are not on the right page.", url.equals(pageUrl));
// call specific check which has to be implemented on every page
isPageLoaded();
}
@Override
protected void load() {
if (parent != null) {
// call the logic in the parent page
parent.get();
// parent page has navigated to this page (via click on button or link).
// wait until this page has been loaded.
WebDriverWait wait = new WebDriverWait(getDriver(), 20, 250);
wait.until(new ExpectedCondition<Boolean> () {
@Override
public Boolean apply(WebDriver d) {
try {
isLoaded();
return true;
} catch (AssertionError e) {
return false;
}
}
});
} else {
// Is there no parent page, the page should be navigated directly
LOGGER.info("Browser: {}, GET {}", getDriver(), getPageUrl());
getDriver().get(getPageUrl());
}
}
/**
* Ensure that this page has been loaded and execute the test code on the this page.
*
* @return T LoadablePage
*/
public T get() {
T loadablePage = super.get();
// execute flow logic
seleniumTest.executeFlowOnPage(this);
return loadablePage;
}
/**
* See {@link WebDriver#findElement(By)}
*/
public WebElement findElement(By by) {
return getDriver().findElement(by);
}
/**
* See {@link WebDriver#findElements(By)}
*/
public List<WebElement> findElements(By by) {
return getDriver().findElements(by);
}
public WebDriver getDriver() {
return seleniumTest.getDriver();
}
protected String getPageUrl() {
return pageUrl;
}
Method getMethod() {
return method;
}
}
如您所见,每个页面对象类都需要实现两个抽象方法:
/**
* Path of the URL without the context root for this page.
*
* @return String path of the URL
*/
protected abstract String getUrlPath();
/***
* Specific check which has to be implemented by every page object.
* A rudimentary check on the basis of URL is undertaken by the super class.
* This method is doing an extra check if the page has been proper loaded.
*
* @throws Error thrown when the check fails
*/
protected abstract void isPageLoaded() throws Error;
现在,我想显示一个具体页面对象的代码和一个测试SBB Ticket Shop的测试类,以便读者可以对页面对象进行测试。 页面对象TimetablePage包含基本元素HTML包装器。
public class TimetablePage extends LoadablePage<TimetablePage> {
@FindBy(id = "...")
private Autocomplete from;
@FindBy(id = "...")
private Autocomplete to;
@FindBy(id = "...")
private Datepicker date;
@FindBy(id = "...")
private TimeInput time;
@FindBy(id = "...")
private Button search;
@Override
protected String getUrlPath() {
return "pages/fahrplan/fahrplan.xhtml";
}
@Override
protected void isPageLoaded() throws Error {
try {
assertTrue(findElement(By.id("shopForm_searchfields")).isDisplayed());
} catch (NoSuchElementException ex) {
throw new AssertionError();
}
}
public TimetablePage typeFrom(String text) {
from.setValue(text);
return this;
}
public TimetablePage typeTo(String text) {
to.setValue(text);
return this;
}
public TimetablePage typeTime(Date date) {
time.setValue(date);
return this;
}
public TimetablePage typeDate(Date date) {
date.setValue(date);
return this;
}
public TimetablePage search() {
search.clickAndWaitUntil().ajaxCompleted().elementVisible(By.cssSelector("..."));
return this;
}
public TimetableTable getTimetableTable() {
List<WebElement> element = findElements(By.id("..."));
if (element.size() == 1) {
return TimetableTable.create(element.get(0));
}
return null;
}
}
在页面对象中,可以通过@FindBy , @FindBys , @FindAll批注或按需动态创建HTML包装器(简单或复合),例如,如TimetableTable.create(element) ,其中element是基础WebElement 。 通常,注释不适用于自定义元素。 默认情况下,它们仅与Selenium的WebElement一起使用。 但是让他们也使用自定义元素并不难。 您必须实现一个扩展DefaultFieldDecorator的自定义FieldDecorator 。 自定义FieldDecorator允许对自定义HTML包装程序使用@FindBy , @FindBys或@FindAll批注。 此处提供了一个示例项目,其中提供了实现细节和自定义元素的示例。 您还可以赶上Selenium的臭名昭著的StaleElementReferenceException在您的自定义FieldDecorator并重新创建由最初定位的基础WebElement。 框架用户看不到StaleElementReferenceException,并且即使在此期间更新了引用的DOM元素(从DOM删除并再次添加新内容)时,也可以在WebElement上调用方法。 此处提供了带有代码段的想法。
好吧,让我展示测试课程。 在测试班中,我们要测试当16岁以下的儿童没有父母旅行时购物车中是否出现提示。 首先,我们必须输入“从”和“到”的车站,在时间表中单击所需的连接,然后在下一页添加一个子项,该子项显示所选连接的旅行报价。
public class HintTravelerIT extends AbstractSeleniumTest {
@FlowOnPage(step = 1, desc = "Seach a connection from Bern to Zürich and click on the first 'Buy' button")
void flowTimetable(TimetablePage timetablePage) {
// Type from, to, date and time
timetablePage.typeFrom("Bern").typeTo("Zürich");
Date date = DateUtils.addDays(new Date(), 2);
timetablePage.typeDate(date);
timetablePage.typeTime(date);
// search for connections
timetablePage.search();
// click on the first 'Buy' button
TimetableTable table = timetablePage.getTimetableTable();
table.clickFirstBuyButton();
}
@FlowOnPage(step = 2, desc = "Add a child as traveler and test the hint in the shopping cart")
void flowOffers(OffersPage offersPage) {
// Add a child
DateFormat df = new SimpleDateFormat("dd.MM.yyyy", Locale.GERMAN);
String birthDay = df.format(DateUtils.addYears(new Date(), -10));
offersPage.addTraveler(0, "Max", "Mustermann", birthDay);
offersPage.saveTraveler();
// Get hints
List<String> hints = offersPage.getShoppingCart().getHints();
assertNotNull(hints);
assertTrue(hints.size() == 1);
assertEquals("A child can only travel under adult supervision", hints.get(0));
}
}
HTML包装器的结构。
我建议为所有HTML包装器创建一个抽象基类。 我们称之为HtmlWrapper 。 此类可以提供一些常用方法,例如click , clickAndWaitUntil , findElement(s) , getParentElement , getAttribute , isDisplayed ……对于可编辑元素,您可以创建一个继承自HtmlWrapper的类EditableWrapper 。 此类可以为可编辑元素提供一些常用方法,例如: clear (清除输入), enter (按下Enter键), isEnabled (检查元素是否已启用),…。所有可编辑元素都应继承自EditableWrapper 。 此外,您可以分别为单值和多值元素提供两个接口EditableSingleValue和EditableMultipleValue 。 下图展示了这个想法。 它显示了三个基本HTML包装的类层次结构:
- 日期选择器 。 它继承自EditableWrapper并实现EditableSingleValue接口。
- MultiSelect 。 它继承自EditableWrapper并实现EditableMultiValue接口。
- 留言 。 它直接扩展HtmlWrapper,因为消息不可编辑。
您是否需要更多有关HTML包装程序的实现细节? jQuery Datepicker的详细信息可以在这篇出色的文章中找到 。 MultiSelect是著名的Select2小部件的包装。 我已经通过以下方式在项目中实现了包装器:
public class MultiSelect extends EditableWrapper implements EditableMultiValue<String> {
protected MultiSelect(WebElement element) {
super(element);
}
public static MultiSelect create(WebElement element) {
assertNotNull(element);
return new MultiSelect(element);
}
@Override
public void clear() {
JavascriptExecutor js = (JavascriptExecutor) getDriver();
js.executeScript("jQuery(arguments[0]).val(null).trigger('change')", element);
}
public void removeValue(String...value) {
if (value == null || value.length == 0) {
return;
}
JavascriptExecutor js = (JavascriptExecutor) getDriver();
Object selectedValues = js.executeScript("return jQuery(arguments[0]).val()", element);
String[] curValue = convertValues(selectedValues);
String[] newValue = ArrayUtils.removeElements(curValue, value);
if (newValue == null || newValue.length == 0) {
clear();
} else {
changeValue(newValue);
}
}
public void addValue(String...value) {
if (value == null || value.length == 0) {
return;
}
JavascriptExecutor js = (JavascriptExecutor) getDriver();
Object selectedValues = js.executeScript("return jQuery(arguments[0]).val()", element);
String[] curValue = convertValues(selectedValues);
String[] newValue = ArrayUtils.addAll(curValue, value);
changeValue(newValue);
}
@Override
public void setValue(String...value) {
clear();
if (value == null || value.length == 0) {
return;
}
changeValue(value);
}
@Override
public String[] getValue() {
JavascriptExecutor js = (JavascriptExecutor) getDriver();
Object values = js.executeScript("return jQuery(arguments[0]).val()", element);
return convertValues(values);
}
private void changeValue(String...value) {
Gson gson = new Gson();
String jsonArray = gson.toJson(value);
String jsCode = String.format("jQuery(arguments[0]).val(%s).trigger('change')", jsonArray);
JavascriptExecutor js = (JavascriptExecutor) getDriver();
js.executeScript(jsCode, element);
}
@SuppressWarnings("unchecked")
private String[] convertValues(Object values) {
if (values == null) {
return null;
}
if (values.getClass().isArray()) {
return (String[]) values;
} else if (values instanceof List) {
List<String> list = (List<String> ) values;
return list.toArray(new String[list.size()]);
} else {
throw new WebDriverException("Unsupported value for MultiSelect: " + values.getClass());
}
}
}
为了完整起见,还有一个Message实现的示例:
public class Message extends HtmlWrapper {
public enum Severity {
INFO("info"),
WARNING("warn"),
ERROR("error");
Severity(String severity) {
this.severity = severity;
}
private final String severity;
public String getSeverity() {
return severity;
}
}
protected Message(WebElement element) {
super(element);
}
public static Message create(WebElement element) {
assertNotNull(element);
return new Message(element);
}
public boolean isAnyMessageExist(Severity severity) {
List<WebElement> messages = findElements(
By.cssSelector(".ui-messages .ui-messages-" + severity.getSeverity()));
return messages.size() > 0;
}
public boolean isAnyMessageExist() {
for (Severity severity: Severity.values()) {
List<WebElement> messages = findElements(
By.cssSelector(".ui-messages .ui-messages-" + severity.getSeverity()));
if (messages.size() > 0) {
return true;
}
}
return false;
}
public List<String> getMessages(Severity severity) {
List<WebElement> messages = findElements(
By.cssSelector(".ui-messages .ui-messages-" + severity.getSeverity() + "-summary"));
if (messages.isEmpty()) {
return null;
}
List<String> text = new ArrayList<> ();
for (WebElement element: messages) {
text.add(element.getText());
}
return text;
}
}
消息将消息组件包装在PrimeFaces中 。
结论
完成页面对象和HTML包装程序的编写后,您可以安心并专注于舒适地编写Selenium测试。 随时分享您的想法。
翻译自: https://www.javacodegeeks.com/2016/04/clean-architecture-selenium-tests.html
selenium架构