| title | 设计模式和开发策略 |
|---|---|
| linkTitle | 设计策略 |
| weight | 1 |
(原位置: https://github.com/SeleniumHQ/selenium/wiki/Bot-Style-Tests)
随着时间推移, 项目往往会积累大量测试. 随着测试总数的增加, 对代码库进行改动会变得更困难 —— 即便只是一次“简单”的改动, 也可能导致大量测试失败, 而应用本身仍然能正常工作. 有时这种问题不可避免, 但一旦发生, 你希望能尽快恢复运行. 下面介绍的一些设计模式与策略在 WebDriver 场景下被证明可以简化测试编写与维护, 可能也对你有帮助.
[领域驱动设计]({{< ref "encouraged/domain_specific_language.md" >}}): 以最终用户的语言来表达测试.
[页面对象]({{< ref "encouraged/page_object_models.md" >}}): 对 Web 应用 UI 的一种简单抽象.
LoadableComponent: 把页面对象建模为可加载的组件.
BotStyleTests: 使用以命令为导向的自动化测试方式, 而不是页面对象更偏向的对象化方法
LoadableComponent 是一个基类, 目标是让编写页面对象更轻松. 它通过提供一套标准机制来确保页面被正确加载, 并提供在页面加载失败时用于调试的钩子. 你可以利用它来减少测试中的样板代码, 从而降低维护测试的工作量.
目前在 Selenium 中包含了一个 Java 实现, 但该方法足够简单, 可在任何语言中实现.
举个我们想要建模的 UI 的例子, 看看 new issue 页面. 对于测试作者而言, 该页面的作用是提供一个提交新 issue 的功能. 一个基本的页面对象看起来像:
package com.example.webdriver;
import org.openqa.selenium.By;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.WebElement;
public class EditIssue {
private final WebDriver driver;
public EditIssue(WebDriver driver) {
this.driver = driver;
}
public void setTitle(String title) {
WebElement field = driver.findElement(By.id("issue_title")));
clearAndType(field, title);
}
public void setBody(String body) {
WebElement field = driver.findElement(By.id("issue_body"));
clearAndType(field, body);
}
public void setHowToReproduce(String howToReproduce) {
WebElement field = driver.findElement(By.id("issue_form_repro-command"));
clearAndType(field, howToReproduce);
}
public void setLogOutput(String logOutput) {
WebElement field = driver.findElement(By.id("issue_form_logs"));
clearAndType(field, logOutput);
}
public void setOperatingSystem(String operatingSystem) {
WebElement field = driver.findElement(By.id("issue_form_operating-system"));
clearAndType(field, operatingSystem);
}
public void setSeleniumVersion(String seleniumVersion) {
WebElement field = driver.findElement(By.id("issue_form_selenium-version"));
clearAndType(field, logOutput);
}
public void setBrowserVersion(String browserVersion) {
WebElement field = driver.findElement(By.id("issue_form_browser-versions"));
clearAndType(field, browserVersion);
}
public void setDriverVersion(String driverVersion) {
WebElement field = driver.findElement(By.id("issue_form_browser-driver-versions"));
clearAndType(field, driverVersion);
}
public void setUsingGrid(String usingGrid) {
WebElement field = driver.findElement(By.id("issue_form_selenium-grid-version"));
clearAndType(field, usingGrid);
}
public IssueList submit() {
driver.findElement(By.cssSelector("button[type='submit']")).click();
return new IssueList(driver);
}
private void clearAndType(WebElement field, String text) {
field.clear();
field.sendKeys(text);
}
}为了把它变成一个 LoadableComponent, 我们所要做的就是将其设为基类:
public class EditIssue extends LoadableComponent<EditIssue> {
// rest of class ignored for now
}这个签名看起来有些不寻常, 但它的含义很简单: 该类表示一个用于加载 EditIssue 页面 的 LoadableComponent.
通过继承这个基类, 我们需要实现两个新方法:
@Override
protected void load() {
driver.get("https://github.com/SeleniumHQ/selenium/issues/new?assignees=&labels=I-defect%2Cneeds-triaging&projects=&template=bug-report.yml&title=%5B%F0%9F%90%9B+Bug%5D%3A+");
}
@Override
protected void isLoaded() throws Error {
String url = driver.getCurrentUrl();
assertTrue("Not on the issue entry page: " + url, url.endsWith("/new"));
}load 方法用于导航到页面,
而 isLoaded 方法用于判断我们是否位于正确的页面.
虽然该方法看起来像是应该返回 boolean,
但实际上它通过 JUnit 的 Assert 类执行一系列断言.
断言可以多也可以少. 通过这些断言,
可以为类的使用者提供清晰的调试信息,
用于定位测试失败的原因.
稍作改造后, 我们的页面对象如下:
package com.example.webdriver;
import org.openqa.selenium.By;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.support.FindBy;
import org.openqa.selenium.support.PageFactory;
import static junit.framework.Assert.assertTrue;
public class EditIssue extends LoadableComponent<EditIssue> {
private final WebDriver driver;
// By default the PageFactory will locate elements with the same name or id
// as the field. Since the issue_title element has an id attribute of "issue_title"
// we don't need any additional annotations.
private WebElement issue_title;
// But we'd prefer a different name in our code than "issue_body", so we use the
// FindBy annotation to tell the PageFactory how to locate the element.
@FindBy(id = "issue_body") private WebElement body;
public EditIssue(WebDriver driver) {
this.driver = driver;
// This call sets the WebElement fields.
PageFactory.initElements(driver, this);
}
@Override
protected void load() {
driver.get("https://github.com/SeleniumHQ/selenium/issues/new?assignees=&labels=I-defect%2Cneeds-triaging&projects=&template=bug-report.yml&title=%5B%F0%9F%90%9B+Bug%5D%3A+");
}
@Override
protected void isLoaded() throws Error {
String url = driver.getCurrentUrl();
assertTrue("Not on the issue entry page: " + url, url.endsWith("/new"));
}
public void setHowToReproduce(String howToReproduce) {
WebElement field = driver.findElement(By.id("issue_form_repro-command"));
clearAndType(field, howToReproduce);
}
public void setLogOutput(String logOutput) {
WebElement field = driver.findElement(By.id("issue_form_logs"));
clearAndType(field, logOutput);
}
public void setOperatingSystem(String operatingSystem) {
WebElement field = driver.findElement(By.id("issue_form_operating-system"));
clearAndType(field, operatingSystem);
}
public void setSeleniumVersion(String seleniumVersion) {
WebElement field = driver.findElement(By.id("issue_form_selenium-version"));
clearAndType(field, logOutput);
}
public void setBrowserVersion(String browserVersion) {
WebElement field = driver.findElement(By.id("issue_form_browser-versions"));
clearAndType(field, browserVersion);
}
public void setDriverVersion(String driverVersion) {
WebElement field = driver.findElement(By.id("issue_form_browser-driver-versions"));
clearAndType(field, driverVersion);
}
public void setUsingGrid(String usingGrid) {
WebElement field = driver.findElement(By.id("issue_form_selenium-grid-version"));
clearAndType(field, usingGrid);
}
public IssueList submit() {
driver.findElement(By.cssSelector("button[type='submit']")).click();
return new IssueList(driver);
}
private void clearAndType(WebElement field, String text) {
field.clear();
field.sendKeys(text);
}
}看起来似乎没带来太多好处, 对吧? 不过它确实把如何导航到该页面的信息封装进了页面本身, 这意味着这些信息不会散落在代码库各处. 这也意味着我们可以在测试中这样做:
EditIssue page = new EditIssue(driver).get();上述调用会在必要时促使 driver 导航到该页面.
当 LoadableComponent 与其他 LoadableComponent 配合使用时, 会更加有用. 以我们的例子为例, 我们可以把“编辑 issue”页面视为项目网站中的一个组件(毕竟我们是通过该网站的某个选项卡访问它). 并且提交 issue 需要登录. 我们可以把它建模为一棵嵌套组件树:
+ ProjectPage
+---+ SecuredPage
+---+ EditIssue
这在代码中会是什么样子?
首先, 每个逻辑组件都有自己的类.
每个类的 load 方法会调用父组件的 get 方法.
最终效果, 除了上面的 EditIssue 类外, 还包括:
ProjectPage.java:
package com.example.webdriver;
import org.openqa.selenium.WebDriver;
import static org.junit.Assert.assertTrue;
public class ProjectPage extends LoadableComponent<ProjectPage> {
private final WebDriver driver;
private final String projectName;
public ProjectPage(WebDriver driver, String projectName) {
this.driver = driver;
this.projectName = projectName;
}
@Override
protected void load() {
driver.get("http://" + projectName + ".googlecode.com/");
}
@Override
protected void isLoaded() throws Error {
String url = driver.getCurrentUrl();
assertTrue(url.contains(projectName));
}
}以及 SecuredPage.java:
package com.example.webdriver;
import org.openqa.selenium.By;
import org.openqa.selenium.NoSuchElementException;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.WebElement;
import static org.junit.Assert.fail;
public class SecuredPage extends LoadableComponent<SecuredPage> {
private final WebDriver driver;
private final LoadableComponent<?> parent;
private final String username;
private final String password;
public SecuredPage(WebDriver driver, LoadableComponent<?> parent, String username, String password) {
this.driver = driver;
this.parent = parent;
this.username = username;
this.password = password;
}
@Override
protected void load() {
parent.get();
String originalUrl = driver.getCurrentUrl();
// Sign in
driver.get("https://www.google.com/accounts/ServiceLogin?service=code");
driver.findElement(By.name("Email")).sendKeys(username);
WebElement passwordField = driver.findElement(By.name("Passwd"));
passwordField.sendKeys(password);
passwordField.submit();
// Now return to the original URL
driver.get(originalUrl);
}
@Override
protected void isLoaded() throws Error {
// If you're signed in, you have the option of picking a different login.
// Let's check for the presence of that.
try {
WebElement div = driver.findElement(By.id("multilogin-dropdown"));
} catch (NoSuchElementException e) {
fail("Cannot locate user name link");
}
}
}EditIssue 中的 load 方法现在如下:
@Override
protected void load() {
securedPage.get();
driver.get("https://github.com/SeleniumHQ/selenium/issues/new?assignees=&labels=I-defect%2Cneeds-triaging&projects=&template=bug-report.yml&title=%5B%F0%9F%90%9B+Bug%5D%3A+");
}这表明这些组件都是相互 "嵌套" 的.
在 EditIssue 中调用 get() 会导致它的所有依赖组件也被加载.
示例用法:
public class FooTest {
private EditIssue editIssue;
@Before
public void prepareComponents() {
WebDriver driver = new FirefoxDriver();
ProjectPage project = new ProjectPage(driver, "selenium");
SecuredPage securedPage = new SecuredPage(driver, project, "example", "top secret");
editIssue = new EditIssue(driver, securedPage);
}
@Test
public void demonstrateNestedLoadableComponents() {
editIssue.get();
editIssue.title.sendKeys('Title');
editIssue.body.sendKeys('What Happened');
editIssue.setHowToReproduce('How to Reproduce');
editIssue.setLogOutput('Log Output');
editIssue.setOperatingSystem('Operating System');
editIssue.setSeleniumVersion('Selenium Version');
editIssue.setBrowserVersion('Browser Version');
editIssue.setDriverVersion('Driver Version');
editIssue.setUsingGrid('I Am Using Grid');
}
}如果在测试中使用像 Guiceberry 这样的库, 设置页面对象的前置步骤可以省略, 从而使测试更简洁、可读.
(原位置: https://github.com/SeleniumHQ/selenium/wiki/Bot-Style-Tests)
尽管页面对象是减少测试重复的一种有效方式, 但并非所有团队都愿意或适应这种模式. 另一种可选的方式是采用更加 "命令式" 的测试风格.
“bot” 是对底层 Selenium API 的一种面向动作的抽象. 这意味着如果发现某些命令不适合你的应用, 可以轻松修改它们. 例如:
public class ActionBot {
private final WebDriver driver;
public ActionBot(WebDriver driver) {
this.driver = driver;
}
public void click(By locator) {
driver.findElement(locator).click();
}
public void submit(By locator) {
driver.findElement(locator).submit();
}
/**
* Type something into an input field. WebDriver doesn't normally clear these
* before typing, so this method does that first. It also sends a return key
* to move the focus out of the element.
*/
public void type(By locator, String text) {
WebElement element = driver.findElement(locator);
element.clear();
element.sendKeys(text + "\n");
}
}一旦构建了这些抽象并找出测试中的重复, 就可以在 bot 之上再封装 页面对象.
{{< tabpane text=true >}}
{{< tab header="Python" >}}
一个用例 使用 python + pytest + selenium 实现了设计策略 "Action Bot, Loadable Component 和 页面对象".
A pytest fixture chrome_driver.
{{< gh-codeblock path="/examples/python/tests/design_strategy/using_best_practice.py#L6-L26" >}} {{< /tab >}} {{< tab header="Java" >}} {{< badge-code >}} {{< /tab >}} {{< tab header="CSharp" >}} {{< badge-code >}} {{< /tab >}} {{< tab header="Ruby" >}} {{< badge-code >}} {{< /tab >}} {{< tab header="JavaScript" >}} {{< badge-code >}} {{< /tab >}} {{< tab header="Kotlin" >}} {{< badge-code >}} {{< /tab >}} {{< /tabpane >}}
"Action Bot" 实现.
{{< tabpane text=true >}} {{< tab header="Python" >}} {{< gh-codeblock path="/examples/python/tests/design_strategy/using_best_practice.py#L28-L65" >}} {{< /tab >}} {{< tab header="Java" >}} {{< badge-code >}} {{< /tab >}} {{< tab header="CSharp" >}} {{< badge-code >}} {{< /tab >}} {{< tab header="Ruby" >}} {{< badge-code >}} {{< /tab >}} {{< tab header="JavaScript" >}} {{< badge-code >}} {{< /tab >}} {{< tab header="Kotlin" >}} {{< badge-code >}} {{< /tab >}} {{< /tabpane >}}
"Loadable Component 定义.
{{< tabpane text=true >}} {{< tab header="Python" >}} {{< gh-codeblock path="/examples/python/tests/design_strategy/using_best_practice.py#L67-L80" >}} {{< /tab >}} {{< tab header="Java" >}} {{< badge-code >}} {{< /tab >}} {{< tab header="CSharp" >}} {{< badge-code >}} {{< /tab >}} {{< tab header="Ruby" >}} {{< badge-code >}} {{< /tab >}} {{< tab header="JavaScript" >}} {{< badge-code >}} {{< /tab >}} {{< tab header="Kotlin" >}} {{< badge-code >}} {{< /tab >}} {{< /tabpane >}}
"Loadable Component 以及 页面对象" 实现.
{{< tabpane text=true >}} {{< tab header="Python" >}} {{< gh-codeblock path="/examples/python/tests/design_strategy/using_best_practice.py#L82-L172" >}} {{< /tab >}} {{< tab header="Java" >}} {{< badge-code >}} {{< /tab >}} {{< tab header="CSharp" >}} {{< badge-code >}} {{< /tab >}} {{< tab header="Ruby" >}} {{< badge-code >}} {{< /tab >}} {{< tab header="JavaScript" >}} {{< badge-code >}} {{< /tab >}} {{< tab header="Kotlin" >}} {{< badge-code >}} {{< /tab >}} {{< /tabpane >}}
测试用例实现.
{{< tabpane text=true >}}
{{< tab header="Python" >}}
Test cases implementation with pytest.
{{< gh-codeblock path="/examples/python/tests/design_strategy/using_best_practice.py#L174-L240" >}} {{< /tab >}} {{< tab header="Java" >}} {{< badge-code >}} {{< /tab >}} {{< tab header="CSharp" >}} {{< badge-code >}} {{< /tab >}} {{< tab header="Ruby" >}} {{< badge-code >}} {{< /tab >}} {{< tab header="JavaScript" >}} {{< badge-code >}} {{< /tab >}} {{< tab header="Kotlin" >}} {{< badge-code >}} {{< /tab >}} {{< /tabpane >}}