JUnit 5和Selenium - 使用Selenium内置的`PageFactory'来实现页面对象模式

752 阅读7分钟

Selenium是一套支持浏览器自动化的工具和库,它主要用于网络应用测试。Selenium的组件之一是Selenium WebDriver,它提供客户端库、JSON线协议(与浏览器驱动程序通信的协议)和浏览器驱动程序。Selenium WebDriver的主要优势之一是它被所有主要的编程语言所支持,并且可以在所有主要的操作系统上运行。

JUnit 5与Selenium WebDriver教程的这一部分,我将通过Selenium内置的PageFactory支持类来实现页面对象模式。PageFactory ,提供了初始化任何声明有WebElementList<WebElement> 字段注释的页面对象的机制,@FindBy 注释。

页面对象模式介绍

我们将为基于JavaScript的Todo应用程序创建测试,这里有:http://todomvc.com/examples/vanillajs。该应用程序被创建为一个单页应用程序(SPA),并使用本地存储作为任务库。要实现的可能场景包括添加和编辑todo,删除todo,将单个或多个todos标记为已完成。该实现将使用页面对象模式完成。

页面对象模式的目标是将应用程序的页面和功能从实际测试中抽象出来。页面对象模式提高了代码在测试和固定程序中的可重用性,同时也使代码更容易维护。

页面API又称页面对象

我们将从将TodoMVC页面建模为页面对象开始项目。这个对象将代表将在测试中使用的页面API。API本身可以使用一个接口进行建模。如果你看一下下面接口的方法,你会发现这些方法只是页面上可用的用户功能。用户可以创建todo,用户可以重命名todo或者他可以删除todo

public interface TodoMvc {
    void navigateTo();
    void createTodo(String todoName);
    void createTodos(String... todoNames);
    int getTodosLeft();
    boolean todoExists(String todoName);
    int getTodoCount();
    List<String> getTodos();
    void renameTodo(String todoName, String newTodoName);
    void removeTodo(String todoName);
    void completeTodo(String todoName);
    void completeAllTodos();
    void showActive();
    void showCompleted();
    void clearCompleted();
}

上述接口(显然)隐藏了所有的实现细节,但它也没有向潜在的客户(在我们的例子中,客户=测试方法)暴露任何Selenium WebDriver的细节。事实上,它与Selenium WebDriver没有任何关系。所以从理论上讲,我们可以为不同的设备(如移动本地应用程序、桌面应用程序和Web应用程序)提供不同的页面实现。

创建测试

随着页面API的定义,我们可以直接跳到创建测试方法。在我们确认API可用于创建测试后,我们将致力于页面的实现。这种设计技术允许我们关注应用程序的实际使用情况,而不是过早地跳到实现细节。

以下是创建的测试:

@ExtendWith(SeleniumExtension.class)
@DisplayName("Managing Todos")
class TodoMvcTests {

    private TodoMvc todoMvc;

    private final String buyTheMilk = "Buy the milk";
    private final String cleanupTheRoom = "Clean up the room";
    private final String readTheBook = "Read the book";

    @BeforeEach
    void beforeEach(ChromeDriver driver) {
        this.todoMvc = null;
        this.todoMvc.navigateTo();
    }

    @Test
    @DisplayName("Creates Todo with given name")
    void createsTodo() {

        todoMvc.createTodo(buyTheMilk);

        assertAll(
                () -> assertEquals(1, todoMvc.getTodosLeft()),
                () -> assertTrue(todoMvc.todoExists(buyTheMilk))
        );
    }

    @Test
    @DisplayName("Creates Todos all with the same name")
    void createsTodosWithSameName() {

        todoMvc.createTodos(buyTheMilk, buyTheMilk, buyTheMilk);

        assertEquals(3, todoMvc.getTodosLeft());


        todoMvc.showActive();

        assertEquals(3, todoMvc.getTodoCount());
    }

    @Test
    @DisplayName("Edits inline double-clicked Todo")
    void editsTodo() {

        todoMvc.createTodos(buyTheMilk, cleanupTheRoom);

        todoMvc.renameTodo(buyTheMilk, readTheBook);

        assertAll(
                () -> assertFalse(todoMvc.todoExists(buyTheMilk)),
                () -> assertTrue(todoMvc.todoExists(readTheBook)),
                () -> assertTrue(todoMvc.todoExists(cleanupTheRoom))
        );
    }

    @Test
    @DisplayName("Removes selected Todo")
    void removesTodo() {

        todoMvc.createTodos(buyTheMilk, cleanupTheRoom, readTheBook);

        todoMvc.removeTodo(buyTheMilk);

        assertAll(
                () -> assertFalse(todoMvc.todoExists(buyTheMilk)),
                () -> assertTrue(todoMvc.todoExists(cleanupTheRoom)),
                () -> assertTrue(todoMvc.todoExists(readTheBook))
        );
    }

    @Test
    @DisplayName("Toggles selected Todo as completed")
    void togglesTodoCompleted() {
        todoMvc.createTodos(buyTheMilk, cleanupTheRoom, readTheBook);

        todoMvc.completeTodo(buyTheMilk);
        assertEquals(2, todoMvc.getTodosLeft());

        todoMvc.showCompleted();
        assertEquals(1, todoMvc.getTodoCount());

        todoMvc.showActive();
        assertEquals(2, todoMvc.getTodoCount());
    }

    @Test
    @DisplayName("Toggles all Todos as completed")
    void togglesAllTodosCompleted() {
        todoMvc.createTodos(buyTheMilk, cleanupTheRoom, readTheBook);

        todoMvc.completeAllTodos();
        assertEquals(0, todoMvc.getTodosLeft());

        todoMvc.showCompleted();
        assertEquals(3, todoMvc.getTodoCount());

        todoMvc.showActive();
        assertEquals(0, todoMvc.getTodoCount());
    }

    @Test
    @DisplayName("Clears all completed Todos")
    void clearsCompletedTodos() {
        todoMvc.createTodos(buyTheMilk, cleanupTheRoom);
        todoMvc.completeAllTodos();
        todoMvc.createTodo(readTheBook);

        todoMvc.clearCompleted();
        assertEquals(1, todoMvc.getTodosLeft());

        todoMvc.showCompleted();
        assertEquals(0, todoMvc.getTodoCount());

        todoMvc.showActive();
        assertEquals(1, todoMvc.getTodoCount());
    }
}

在上面的测试类中,我们看到在每次测试之前,ChromeDriver都会被初始化,并由Selenium Jupiter扩展注入到setup方法中(@BeforeEach)(因此有@ExtendWith(SeleniumExtension.class) )。驱动程序对象将被用于初始化页面对象。

有不同的页面对象建模技术,这在很大程度上取决于你所做的项目的特点。你可能想使用接口,但这并不是必须的。你可能想考虑在一个较低的抽象层次上建模,在这里API暴露了更多的细节方法,例如setTodoInput(String value),clickSubmitButton()

使用Selenium内置的PageFactory来实现页面对象模式

目前,我们有一个接口来模拟TodoMVC页面的行为,我们有使用API的失败测试。下一步是实际实现页面对象。为了做到这一点,我们将使用Selenium内置的PageFactory 类和它的实用工具。

PageFactory 该类简化了页面对象模式的实现。该类提供了初始化任何声明了 或 字段并带有 注释的页面对象的机制。 和所有其他支持实现Page Object模式的注解都可以在 包中找到。WebElement List<WebElement> @FindBy PageFactory org.openqa.selenium.support

下面的TodoMvcPage 类实现了我们之前创建的接口。它声明了几个用@FindBy 注释的字段。它还声明了一个构造函数,接受工厂用来初始化字段的WebDriver 参数:

public class TodoMvcPage implements TodoMvc {

    private final WebDriver driver;

    private static final By byTodoEdit = By.cssSelector("input.edit");
    private static final By byTodoRemove = By.cssSelector("button.destroy");
    private static final By byTodoComplete = By.cssSelector("input.toggle");

    @FindBy(className = "new-todo")
    private WebElement newTodoInput;

    @FindBy(css = ".todo-count > strong")
    private WebElement todoCount;

    @FindBy(css = ".todo-list li")
    private List<WebElement> todos;

    @FindBy(className = "toggle-all")
    private WebElement toggleAll;

    @FindBy(css = "a[href='#/active']")
    private WebElement showActive;

    @FindBy(css = "a[href='#/completed']")
    private WebElement showCompleted;

    @FindBy(className = "clear-completed")
    private WebElement clearCompleted;

    public TodoMvcPage(WebDriver driver) {
        this.driver = driver;
    }

    @Override
    public void navigateTo() {
        driver.get("http://todomvc.com/examples/vanillajs");
    }

    public void createTodo(String todoName) {
        newTodoInput.sendKeys(todoName + Keys.ENTER);
    }

    public void createTodos(String... todoNames) {
        for (String todoName : todoNames) {
            createTodo(todoName);
        }
    }

    public int getTodosLeft() {
        return Integer.parseInt(todoCount.getText());
    }

    public boolean todoExists(String todoName) {
        return getTodos().stream().anyMatch(todoName::equals);
    }

    public int getTodoCount() {
        return todos.size();
    }

    public List<String> getTodos() {
        return todos
                .stream()
                .map(WebElement::getText)
                .collect(Collectors.toList());
    }

    public void renameTodo(String todoName, String newTodoName) {
        WebElement todoToEdit = getTodoElementByName(todoName);
        doubleClick(todoToEdit);

        WebElement todoEditInput = find(byTodoEdit, todoToEdit);
        executeScript("arguments[0].value = ''", todoEditInput);

        todoEditInput.sendKeys(newTodoName + Keys.ENTER);
    }

    public void removeTodo(String todoName) {
        WebElement todoToRemove = getTodoElementByName(todoName);
        moveToElement(todoToRemove);
        click(byTodoRemove, todoToRemove);
    }

    public void completeTodo(String todoName) {
        WebElement todoToComplete = getTodoElementByName(todoName);
        click(byTodoComplete, todoToComplete);
    }

    public void completeAllTodos() {
        toggleAll.click();
    }

    public void showActive() {
        showActive.click();
    }

    public void showCompleted() {
        showCompleted.click();
    }

    public void clearCompleted() {
        clearCompleted.click();
    }

    private WebElement getTodoElementByName(String todoName) {
        return todos
                .stream()
                .filter(el -> todoName.equals(el.getText()))
                .findFirst()
                .orElseThrow(() -> new RuntimeException("Todo with name " + todoName + " not found!"));
    }

    private WebElement find(By by, SearchContext searchContext) {
        return searchContext.findElement(by);
    }

    private void click(By by, SearchContext searchContext) {
        WebElement element = searchContext.findElement(by);
        element.click();
    }

    private void moveToElement(WebElement element) {
        new Actions(driver).moveToElement(element).perform();
    }

    private void doubleClick(WebElement element) {
        new Actions(driver).doubleClick(element).perform();
    }

    private void executeScript(String script, Object... arguments) {
        ((JavascriptExecutor) driver).executeScript(script, arguments);
    }
}

@FindBy 并非唯一用于查找页面对象中的元素的注解。还有 和 。@FindBys @FindAll

@FindBys

@FindBys 注释用于标记页面对象上的一个字段,以表明查询应该使用一系列的 标签。在这个例子中,Selenium将搜索带有 的元素,该元素@FindBy class = "button" 带有 :id = "menu"

@FindBys({
  @FindBy(id = "menu"),
  @FindBy(className = "button")
})
private WebElement element;

@FindAll

@FindAll 注释用于标记页面对象上的一个字段,表明查找应该使用一系列的@FindBy标签。在这个例子中,Selenium将搜索所有带有 的元素class = "button" 所有带有 的元素。不保证元素是按文档顺序排列的。id = "menu"

@FindAll({
  @FindBy(id = "menu"),
  @FindBy(className = "button")
})
private List<WebElement> webElements;

PageFactory - 初始化页面对象

PageFactory 提供了几个静态方法来初始化Page对象。在我们的测试中,在 方法中我们需要初始化 对象。beforeEach() TodoMvcPage

@BeforeEach
void beforeEach(ChromeDriver driver) {
    this.todoMvc = PageFactory.initElements(driver, TodoMvcPage.class);
    this.todoMvc.navigateTo();
}

PageFactory 使用反射来初始化对象,然后初始化所有标有@FindBy 注释的WebElementList<WebElement> 字段(在这个时刻不做查找,字段是代理的)。使用这种方法需要页面对象有一个接受WebDriver 对象的单参数构造器。

定位元素

那么,元素何时被定位呢?查找是在每次访问字段时进行的。因此,例如,当我们在createTodo() 方法中执行代码:newTodoInput.sendKeys(todoName + Keys.ENTER); ,实际执行的指令是:driver.findElement(By.className('new-todo')).sendKeys(todoName + Keys.ENTER) 。我们可以预期,没有找到元素的潜在异常不是在对象初始化时抛出的,而是在第一次元素查找时抛出的。

Selenium使用Proxy模式来实现上述行为。

@CacheLookup

在有些情况下,没有必要在每次访问注释字段时都去查找元素。在这种情况下,我们可以使用@CacheLookup 注释。在我们的例子中,输入字段在页面上没有变化,所以它的查找可以被缓存:

@FindBy(className = "new-todo")
@CacheLookup
private WebElement newTodoInput;

运行测试

现在是执行测试的时候了,它可以从IDE或使用终端完成:

./gradlew clean test --tests *TodoMvcTests

构建成功,所有测试都通过了:

> Task :test

pl.codeleak.demos.selenium.todomvc.TodoMvcTests > editsTodo() PASSED

pl.codeleak.demos.selenium.todomvc.TodoMvcTests > togglesTodoCompleted() PASSED

pl.codeleak.demos.selenium.todomvc.TodoMvcTests > createsTodo() PASSED

pl.codeleak.demos.selenium.todomvc.TodoMvcTests > removesTodo() PASSED

pl.codeleak.demos.selenium.todomvc.TodoMvcTests > togglesAllTodosCompleted() PASSED

pl.codeleak.demos.selenium.todomvc.TodoMvcTests > createsTodosWithSameName() PASSED

pl.codeleak.demos.selenium.todomvc.TodoMvcTests > clearsCompletedTodos() PASSED

BUILD SUCCESSFUL in 27s
3 actionable tasks: 3 executed

接下来的步骤

在第三部分--改进项目配置--并行执行测试、测试执行顺序、参数化测试、AssertJ等等--你将学习如何利用JUnit 5的内置功能,在执行速度方面改进你的项目配置,但不仅如此。你还将学习通过利用某些Selenium Jupiter功能来改进项目。