0%

从一个3000行的登录页面聊PageObject

最近帮助一个客户项目改进它们的自动化测试,SUT是一个分别基于iOS和Android进行原生开发的APP,客户团队的即有测试代码使用Cucumber、Appium、WebDriver构建,属于非常典型的UI自动化测试套装。代码的层级结构大致如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
└── Tests/
├── features/
│ ├── login.feature
│ ├── shopping.feature
│ ├── register.feature
│ └── ...
├── glues/
│ ├── login.glue
│ ├── shopping.glue
│ ├── register.glue
│ └── ...
├── pageObjects/
│ ├── loginPage.java
│ ├── homePage.java
│ ├── paymentPage.java
│ └── ...
└── utils/
├── fileHandling.java
├── environmentConfig.java
└── ...

整个测试代码粗看起来有板有眼,feature文件中合理的使用了数据驱动,glue文件中对step的定义和参数化都还不错。但翻看到PageObject时就傻眼了,一个登录页面的PageObject竟然有3000行代码!是的,你没看错,足足3000行,是QA测试登录页面的PageObject代码,而不是Dev开发登录页面的代码。这个页面本身没有复杂的交互与功能设计,比如Social Login、单页注册等,就是一个非常简单、传统的登录页面,类似下面这样:

loginPage

但就是这样一个简单得不能再简单的登录页面,却被写出了3000行代码的PageObject,着实让人惊讶不已。

3000行的登录页面PageObject

基于众所周知的原因,这里无法展现该PageObject的源代码,但为了便于描述,我大概模拟了其中的两个很有个性的函数代码,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
public class LoginPage {

public String usernameField = "#usearname-field";
public String passwordField = "#password-field";
public String loginButton = "#login-button";

...

public boolean loginSuccessfully(String username, String password) {
boolean isTrue = true;

try {
driver.get("https://www.example.com/login-page");
logger.info("get login page");
isTrue = true;

} catch (Exception e) {
logger.info("get login page failed");
isTrue = false;
}

try {
logger.info("start login");
WebElement usernameInput = driver.findElement(By.id(usernameField));
usernameInput.sendKeys(username);

WebElement passwordInput = driver.findElement(By.id(passwordField));
passwordInput.sendKeys(password);

WebElement loginButton = driver.findElement(By.id(this.loginButton));
loginButton.click();

isTrue = true;

} catch (Exception e) {
logger.info("login failed");
isTrue = false;
}

return isTrue;
}

public boolean loginFailed(String username, String password) {
boolean isTrue = false;

try {
driver.get("https://www.example.com/login-page");
logger.info("get login page");
isTrue = true;

} catch (Exception e) {
logger.info("get login page failed");
isTrue = false;
}

try {
logger.info("start login");
WebElement usernameInput = driver.findElement(By.id(usernameField));
usernameInput.sendKeys(username);

WebElement passwordInput = driver.findElement(By.id(passwordField));
passwordInput.sendKeys(password);

WebElement loginButton = driver.findElement(By.id(this.loginButton));
loginButton.click();

isTrue = true;

} catch (Exception e) {
logger.info("login failed");
isTrue = false;
}

return isTrue;
}

...
}

这段代码的问题,相信很多同学一眼就能发现:

  • loginSuccessfully方法与loginFailed方法的实现代码基本就是完全重复的,非常冗余。后面我们会聊到,这种基于测试场景的方法,是不应该写到PageObject里面的。正是这种场景化的PageObject方法给这个登录页面”贡献”了大量的代码行数;
  • 那个遍地开花的isTrue就是个”呵呵”。作者的意图大概是期望在测试执行的不同节点上收集测试步骤的执行结果(成功或者失败),然后给上层的glue文件提供这些结果数据。这些isTrue的遍地赋值在降低代码阅读流畅度的同时,还增加了代码的复杂度,从而增加了发生错误的几率,比如在一些复杂的操作中,例如在需要循环或者重复执行的操作中,就发现了多处的赋值错误。而更可悲的是,这些返回给glue层的isTrue几乎全部都没有被使用;
  • try-catch的目的就是为了打印日志吗? 这个pageObject里面充斥了大量这样的try-catch,除了给isTrue赋值,唯一干的活儿就是打印日志。try-catch的使用会大大的增加代码的复杂度,基本都不建议在PageObject中直接使用,即便真的需要使用try-catch来处理一些特殊的事件,也最好把它放到basePage或者utils里面去(这个后文会聊到);
  • 测试中的失效和异常被生吞了,这是滥用try-catch带来的最严重的后果,像上面代码那样,捕获失效异常后不做任何实质性处理,测试压根儿就不会挂,除非查看日志,不然可能都不知道测试找不到元素了;

除此之外,原始代码中的问题还有很多,这里无法一一列举。当然,写作这篇文章的初衷并不是要去怎么批判别人的代码,而是希望借此机会分享一些我个人围绕PageObject的实操心得。

下面的内容不包括对PageObject的基础介绍,相关的内容可以在网上找到很多,需要了解的同学可以自行查阅,或者直接调戏ChatGPT也是不错的选择。

PageObject文件中的命名

PageObject文件中会定义大量的变量和方法,对这些属性的合理命名能让你在编写测试案例时得心应手,相反的,如果命名不当,则会让你的测试案例晦涩难懂。比如,页面上有一个按钮叫Submit,如果你这样命名:

1
2
3
4
5
6
7
8
9
10
public class OrderPage {
...
public String submit = "#submit";

...
public void submit() {}

public void submitSucceed() {}
...
}

那你写出的测试案例就可能存在歧义,比如order.submit到底是在引用元素还是在点击按钮?又或者order.submitSucceed到底是在点击按钮还是在判断submit成功?请不要鄙视我说”.submit没带括号就是引用元素,.submit()带了括号就是点击按钮”,又或者”.submitSucceed返回void就是单纯点击按钮,.submitSucceed返回boolean就是判断成功”,要知道在阅读评审测试案例时,更加准确表意的命名能帮助我们快速理解测试案例,而基于语法的逻辑判断在这里是显然是低效且无意义的。所以,像下面这样更加表意的命名能让使用它们的测试案例阅读起来更加的顺畅。

1
2
3
4
5
6
7
8
9
10
public class OrderPage {
...
public String submitButton = "#submit";

...
public void clickSubmit() {}

public boolean isSubmitSucceed() {}
...
}

总的来说,对于PageObject中属性的命名,我们可以参考这些原则:

  • 对元素的命名要以组件名结尾,比如loginButton, usernameField, acceptOption等等;
  • 对操作方法的命名要以动词开头,比如clickLoginButtoninputPasswordscrollUp等等;
  • 对判断或者断言方法的命名以连系动词或者情态动词开头,比如isLoading, isNotClickable, shouldDisplayWelcomeMessageWith等等;

PageObject的EAC & EACP模式

无论SUT是web APP或mobile APP,我们在PageObject中只会做三种事情:定位元素、定义方法,以及断言结果(对于”要不要在PageObject中进行断言”的讨论会在下面细聊)。通常情况下,我们可能会在PageObject里面直接创建这些内容,类似下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33

public class LoginPage {

public String usernameField = "#usearname-field";
public String passwordField = "#password-field";
public String loginButton = "#login-button";
...

public void inputUsername(String username) {}
public void inputPassword(String password) {}
public void clickLoginButton() {}
...

public boolean isLogoDisplayed() {}
public boolean isLoginButtonClickable() {}
...
}

public class LoginPageTests {
...

@Test
void testLoginPage() {
loginPage.isLogoBeDisplayed();

loginPage.inputUsername("username");
loginPage.inputPassword("password");

loginPage.isLoginButtonBeClickable();
loginPage.clickLoginButton();
}
}

这样的写法在被测页面内容简单的情况下没有任何问题,但当页面内容复杂、交互操作多样化时,PageObject里面的元素与方法可能就会有几十甚至上百之多,这时如果又是多人协作编写这个文件,就可能出现各种元素、操作与断言的交错混杂,代码的清晰度会降低,有时甚至会出现重复定义的方法,这在真实的项目中我已经多次遇到过了。所以,出于个人习惯,我一般更喜欢这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
public class LoginPage {

private static class Elements {
private static final String usernameField = "#usearname-field";
private static final String passwordField = "#password-field";
private static final String loginButton = "#login-button";
...
}

public static class Actions {
public static void inputUsername(String username) {}
public static void inputPassword(String password) {}
public static void clickLoginButton() {}
...
}

public static class Checks {
public static void shouldLoginButtonBeClickable() {}
public static void shouldLogoBeDisplayed() {}
...
}
}

public class LoginPageTests {
...

@Test
void testLoginPage() {
LoginPage.Checks.shouldLogoBeDisplayed();

LoginPage.Actions.inputUsername("username");
LoginPage.Actions.inputPassword("password");

LoginPage.Checks.shouldLoginButtonBeClickable();
LoginPage.Actions.clickLoginButton();
}
}

也就是把元素、操作、断言这三类属性进行归类,这样的好处是无论页面元素再复杂、协同编写这个文件的人再多,只要按照上述归类添加页面属性,整个PageObject的代码都会显得相对归整,即便有人命名了一个非常不表意的方法,只要这个方法在Actions里面,使用者也不会把它当做元素或者断言来使用,这就是EAC(Elements, Actions, Checks)模式

另外,除了PageObject里面定义的单步方法(下面会讲到定义方法的最小粒度原则),我们的测试案例中还经常会用到一些复合的方法。比如,如果我的目的是测试登录,那我的测试案例可以这么写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class LoginPageTests {
...

@Test
void testLoginPage() {
LoginPage.Checks.shouldLogoBeDisplayed();

LoginPage.Actions.inputUsername("username");
LoginPage.Actions.inputPassword("password");

LoginPage.Checks.shouldLoginButtonBeClickable();
LoginPage.Actions.clickLoginButton();
...
}
}

这是没有问题的。而当我要测试HomePage时,第一步也需要登录,那么可能会这么写:

1
2
3
4
5
6
7
8
9
10
11
12
13
public class HomePageTests {
...

@Test
void testHomePage() {
LoginPage.Actions.inputUsername("username");
LoginPage.Actions.inputPassword("password");
LoginPage.Actions.clickLoginButton();

HomePage.Checks.shouldWelcomeMessageDisplayed();
...
}
}

但很显然,登录对HomePage的测试来说只是一个前置步骤,并不是测试目标,所以没必要调用三个方法来做登录,这里更需要的是一个能一键登录的方法,所以我们就可以这么写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
public class LoginPage {

private static class Elements {
private static final String usernameField = "#usearname-field";
private static final String passwordField = "#password-field";
private static final String loginButton = "#login-button";
...
}

public static class Actions {
public static void inputUsername(String username) {}
public static void inputPassword(String password) {}
public static void clickLoginButton() {}
...
}

public static class Checks {
public static void shouldLoginButtonBeClickable() {}
public static void shouldLogoBeDisplayed() {}
...
}

public static class Procedure {
public static void login(String username, String password) {
Actions.inputUsername(username);
Actions.inputPassword(password);
Actions.clickLoginButton();
}
...
}
}

public class HomePageTests {
...

@Test
void testHomePage() {
LoginPage.Procedure.login("username", "password");

HomePage.Checks.shouldWelcomeMessageDisplayed();
...
}
}

也就是在EAC的基础上添加一个Procedure的归类,构成EACP模式。Procedure中定义各种复合方法,从而使调用相应方法的测试案例更加简洁。类似的思路在有些网上资料中也被叫做Service,因为它表征的是当前页面提供的业务功能(即服务),而不再是单纯的操作步骤。

当然,除了创建复合方法之外,我们还可以创建支持链式调用的PageObject来达到”一行代码登录”的目的,比如loginPage.inputUsername("username").inputPassword("password").clickLoginButton(),但从代码的可读性与调试的便利性上来说,我个人还是更倾向于复合方法。

是否需要在PageObject内部进行断言?

“是否在PageObject内部进行断言?”这是一个聊到PageObject就绕不过去的问题(面试必考题<-_<-)。在Selenium的官网上使用了should never来告诫众生:

assertionInPO

MartinFowler的博客中也阐述了类似的倾向。所以,通常情况下,我们可能会这么使用断言:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class ShoppingPage {
...
public void clickSubmitButton() {
WebElement submitButton = driver.findElement(By.id(this.submitButton));
submitButton.click();
}
}

public class ShoppingPageTests {
...
@Test
void testShoppingPage() {
...
shoppingPage.clickSubmitButton();

WebElement submitResultBanner = driver.findElement(By.id(this.submitResultBanner));
assertTrue(submitResultBanner.isDisplayed());
}
}

这样的写法,就我个人的喜好而言,有两处槽点:

  • 首先,我不喜欢在测试文件中暴露driver对象。在我看来,测试文件应该偏向于更加流畅的描述性写法,通过合理的函数与对象命令,让阅读测试方法达到近似阅读测试案例(文本)的效果是最好的,而靠近这个目标的重要方式之一就是把技术性的API调用封装到测试文件的下一层,所以,我更喜欢把driver封装到PageObject里面去;
  • 其次,我们在测试中很多时候会复用断言,比如,上面的示例中,模拟了一个点击submit按钮后、检查提交结果的断言,这样的断言在当前页面的测试中很可能被多次用到,比如测试不同的参数组合与提交操作,这时在每个测试案例中都去写这么两行断言显然有些冗余,所以我们可能会想到创建一个单独的方法来做这个断言:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    public class ShoppingPageTests {
    ...
    @Test
    void testShoppingPage() {
    ...
    shoppingPage.clickSubmitButton();
    assertSubmitResultBanner();
    }

    private void assertSubmitResultBanner() {
    WebElement submitResultBanner = driver.findElement(By.id(this.submitResultBanner));
    assertTrue(submitResultBanner.isDisplayed());
    }
    }
    那么既然我们为了复用已经把断言进行了封装,那为什么不封装到PageObject里面去呢?基于测试文件专注呈现测试案例的原则,这种为了优化编码的写法就最好放到测试文件的下一层去;

所以,我会在PageObject里面去对断言的代码进行封装,例如这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public class ShoppingPage {
...

public static class Actions {
...

public static void clickSubmitButton() {
WebElement submitButton = driver.findElement(By.id(Elements.submitButton));
submitButton.click();
}
}

public static class Checks {
...

public static void shouldDisplaySubmitSucceedBanner() {
WebElement submitResultBanner = driver.findElement(By.id(Elements.submitResultBanner));
assertTrue(submitResultBanner.isDisplayed());
}
}
}

public class ShoppingPageTests {
...

@Test
void testShoppingPage() {
...

ShoppingPage.Actions.clickSubmitButton();
ShoppingPage.Checks.shouldDisplaySubmitSucceedBanner();
}
}

至于为什么各路大佬都不提倡在PageObject里面写断言,而要在测试文件里面去写,原因其实很简单:“断言”是测试的行为,不是页面的行为,PageObject只应该是对页面的抽象,不对测试负责,所以提倡仅在测试里面才进行断言。而我之所以仍然乐于在PageObject里面写Checks,还在于我对断言进行了进一步的区分,即我仅仅在PageObject里面定义断言,但不会在PageObject里面执行断言,断言只应该在上层的测试文件中才被执行。比如,像下面这样的代码在我看来就是不适宜的,它既破坏了操作方法的单一性原则,又对上一层的测试文件隐藏了断言检查,是我们应该尽量避免的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class ShoppingPage {
...

public static class Actions {
...

public static void clickSubmitButton() {
WebElement submitButton = driver.findElement(By.id(Elements.submitButton));
submitButton.click();
Checks.shouldDisplaySubmitSucceedBanner();
}
}

public static class Checks {
...

public static void shouldDisplaySubmitSucceedBanner() {
WebElement submitResultBanner = driver.findElement(By.id(Elements.submitResultBanner));
assertTrue(submitResultBanner.isDisplayed());
}
}
}

最后想提一下,其实MartinFowler的那篇博客也承认”是否在PageObject中包含断言”是一个开放的问题,没有标准答案,它还描述了一些在PageObject中包含断言的优势。所以,对于这个问题,YES也好NO也罢,大家可根据自己的理解来选择,只要在一套测试中按照相同的原则、做到代码的整洁划一就能写出很好的测试。

适当定义PageComponentObject

pageComponent
有时我们会遇到一些内容比较复杂的页面包含很多相同类型的UI组件,又或者一些相同的UI组件经常出现在不同的页面当中,比如list、card、table、popup等等。这些组件通常都由固定的内容和布局组成,比如card一般都会包含title、image、description、button。对于这些UI组件,如果我们要在一个PageObject里面平铺的创建他们的全部测试对象,那代码可能非常冗余,类似这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class AdminPage {
...
public String adminUserCardTitle = "#userCard-title-0";
public String adminUserCardImage = "#userCard-image-0";
public String adminUserCardDescription = "#userCard-desc-0";
public String adminUserCardButton = "#userCard-button-0";

public String leaderUserCardTitle = "#userCard-title-1";
public String leaderUserCardImage = "#userCard-image-1";
public String leaderUserCardDescription = "#userCard-desc-1";
public String leaderUserCardButton = "#userCard-button-1";

public String operatorUserCardTitle = "#userCard-title-2";
public String operatorUserCardImage = "#userCard-image-2";
public String operatorUserCardDescription = "#userCard-desc-2";
public String operatorUserCardButton = "#userCard-button-2";
...
}

这时,我们可以定义一些组件对象来简化代码,比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class AdminPage {
...
public UserCard adminUserCard = new UserCard("title-0", "image-0", "description-0", "button-0");
public UserCard leaderUserCard = new UserCard("title-1", "image-1", "description-1", "button-1");
public UserCard operatorUserCard = new UserCard("title-2", "image-2", "description-2", "button-2");

@AllArgsConstructor
public class UserCard {
public String title;
public String image;
public String description;
public String button;
...
}
...
}

如果需要复用的UI组件仅仅出现在当前页面,那么可以把这些组件对象定义为当前PageObject的innerClass,如果这些UI组件是跨页面出现的,那么可以单独创建ComponentsPage来收集各种可以复用的组件。

不要在PageObject里面包含测试场景

还记得我们开篇说的那个3000行代码的登录页面PageObject吗,它里面一个非常不适宜的写法就是创建了很多包含测试场景的方法,比如loginSuccessFullyloginFailed就是很明显的包含了测试场景。那什么样的方法是包含测试场景的方法呢,一般来说主要有以下两类:

  • 包含对执行结果期望的方法,这种很好辨识,像刚刚说的xxxxSuceedxxxxFailed什么的,都是包含期望的方法。注意,这里说的是”期望”,不是”断言”,即便在方法中没有使用断言,只要方法有非常明确的执行期望就都算数;
  • 包含了'明确以测试为目的'的执行步骤的方法,这类方法一般会在方法内部按照设测试案例计好的场景、完整地或者部分地执行步骤,比如:
    1
    2
    3
    4
    5
    6
    7
    public class SamplePage {
    public void loginAsAdminUser();
    public void loginWithIncorrectPasswordFirstAndCorrectPasswordNextTime();
    public void clickSubmitButton3Times();
    public void refreshPageThenClickSubmitButton();
    ...
    }
    之所以不要在PageObject里面包含测试场景,原因和”不在PageObject里面包含断言”是相同的,即PageObject是对页面的抽象,不应该包含测试的类容。测试场景应该仅仅出现在测试文件的测试案例当中,这样才能保证我们在评审测试案例时不会遗漏测试场景中的缺陷,毕竟绝大多数时候我们评审测试案例(特别是考量覆盖率的时候)都是只看测试文件,很少去细看下层的PageObject。一旦开始创建包含测试场景的方法,就会让PageObject产生非常多旁枝末节的方法,极大的扩充PageObject的体量,而那些包含测试场景的方法往往只会在特定的测试案例中被调用一次,复用率很低。所以,尽量避免在PageObject中创建包含测试场景的方法。

喜欢挑刺儿的同学可能会问:我在Procedure里面定义的方法难道不是包含测试场景的吗?的确,我在Procedure中定义的复合方法都有明确的步骤,甚至可以说是符合某些场景的,但这些方法只会包含基本的Happy Path步骤,所以我把它们定义为”流程”,这些流程都应该是没有明确的测试目的的。比如,login就是一个基本的流程,既没有结果期望,也没有特定的测试场景,可以放在Procedure里面,但loginSucceed就包含了期望,而类似loginAsAdminloginWithExpiredPassword则是包含了明确的测试场景,就都不应该放在PageObject里面。

所以,对于Selenium官网给出的下面这个例子,我个人还是不予推荐的。

1
2
3
4
5
6
7
8
9
public class LoginPage {
public HomePage loginAs(String username, String password) {
// ... clever magic happens here
}

public LoginPage loginAsExpectingError(String username, String password) {
// ... failed login here, maybe because one or both of the username and password are wrong
}
}

执行方法的最小粒度原则

PageObject里的页面执行方法尽量按照最小粒度原则来定义。比如,对于登录页面,不要只定义login方法,而要把login拆开,分别定义inputUsername, inputPassword,clickLoginButton三个方法,其目的是可以让测试文件灵活的去根据测试场景来组装测试案例,比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
public class LoginPage {
...
public void inputUsername(String username) {}
public void inputPassword(String password) {}
public void clickLoginButton() {}
}

public class LoginPageTest {

@Test
void testLoginSucceed() {
loginPage.inputUsername("username");
loginPage.inputPassword("correct-password");
loginPage.clickLoginButton();
...
}

@Test
void testLoginFailed() {
loginPage.inputUsername("username");
loginPage.inputPassword("incorrect-password");
loginPage.clickLoginButton();
...
}

@Test
void testIncorrectLoginMultipleTimes() {
loginPage.inputUsername("username");
loginPage.inputPassword("incorrect-password-1");
loginPage.clickLoginButton();

loginPage.inputPassword("incorrect-password-2");
loginPage.clickLoginButton();

loginPage.inputPassword("incorrect-password-3");
loginPage.clickLoginButton();
...
}

@Test
void testClickLoginButtonRepeatedly() {
loginPage.inputUsername("username");
loginPage.inputPassword("correct-password");
loginPage.clickLoginButton();
loginPage.clickLoginButton();
loginPage.clickLoginButton();
...
}
}

页面继承与页面交叉

在BasePage里面封装所有页面可以共用的方法,比如refresh,takeScreenshot,scroll等,然后使用XxxPage继承BasePage来得到某个具体的页面,相信已经是大家的基操了。那么关于BasePage,有以下几点想分享一下:

  • BasePage里面只封装操作方法,不要封装页面元素及其操作方法,比如封装上面说的refresh、takeScreenshot、scroll等操作,但不要封装页面的header、footer等跨页面存在的对象及其方法。这类跨页面的对象及其方法可以封装在AnyPage或者CrossPage中;
  • BasePage里面可以封装一些增强版的操作方法,比如在期望一些不稳定的元素,或者执行一些不稳定的操作时,可能会使用到retry、sleep、甚至异常捕获等操作,如果把这些”骚操作”放到PageObject里面会让原本简洁的代码瞬间变得臃肿起来,所以我们可以在BasePage里面创建这些增强版的方法来实现这些骚操作,比如:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    public class BasePage {
    ...
    public void doClick(WebElement element) {}
    public void refreshPageIfElementNotFound(WebElement element) {}
    }

    public class SamplePage extends BasePage {
    ...
    public void doSomething() {
    ...
    this.doClick(this.submitButton);
    this.refreshPageIfElementNotFound(this.submitResultText);
    };
    }
    当然,有时如果根据具体情况觉得这些增强版操作放在BasePage不合适,也可以创建utils类来定义这些增强版方法;

方法内部尽量避免复杂的逻辑

在PageObject里面定义方法时,尽量避免写复杂的if-else、switch等逻辑控制,特别是嵌套的逻辑。基于最小粒度原则定义的方法一般来说都不会包含复杂的内部逻辑,但有时候可能会有一些特例,比如,有些页面功能在不同平台上操作步骤可能有区别,又或者在不同环境中执行起来有差异,而我们又期望使用同一个PageObject的方法来表征这个功能从而达到复用的目的,那么可能会这么写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class SamplePage {
...
public void doSomething() {
if (config.PLATFORM.equals("iOS")) {
if (config.ENV.equals("simulator")) {
// do some steps
} else if (config.ENV.equals("device")) {
// do some steps
}

} else if (config.PLATFORM.equals("Android")) {
if (config.ENV.equals("emulator")) {
// do some steps
} else if (config.ENV.equals("device")) {
// do some steps
}
}
}
}

如此定义的方法是非常臃肿的,这类方法如果多了,整个PageObject就会惨不忍睹。遇到这种情况时,可以尝试对测试案例进行归类,通过tag的方式,将特定环境下执行的测试案例放在一起执行,这时PageObject就可以做如下的相应修改:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class SamplePage {
...
public void doSomethingOnIOSWithSimulator() {}
public void doSomethingOnIOSWithDevice() {}
public void doSomethingOnAndroidWithEmulator() {}
public void doSomethingOnAndroidWithDevice() {}
}

public class SamplePageTest {

@Test
@Tag("iOS")
@Tag("Simulator")
void testSample() {
...
samplePage.doSomethingOnIOSWithSimulator();
}
}

然后通过执行测试的命令来控制不同的测试案例在不同的环境下执行,这样可以让测试案例和PageObject都保持简洁的代码结构。也许有人会质疑”那如果这样的差异化操作多了,岂不是要定义非常多的操作方法?”,是的,的确如此。但即便我们定义了更多的操作方法也是可以接受的,因为我们只是把原本揉捏在一个方法中的不同代码块分布到了各自的方法中去,在不增加代码总量的情况下获得了更好的可读性,便于后期的维护与扩展。而如果你觉得这样的操作真的多到大大增加工作量的程度,那么就应该回过头来审视一下是否还有必要为了”复用”而使用同一套测试案例去测试不同平台环境上的SUT了。自动化测试有一条有益的原则:复用测试实现但不要复用测试案例。

方法是否需要返回otherPages?

在很多网上资料中,包括Selenium的官网都有介绍说”methods on the PageObject should return other PageObjects”,比如,正确执行了某个登录方法后,这个方法就应该返回登录后进入的页面,例如下面这样:

1
2
3
4
5
public class LoginPage {
public HomePage loginAs(String username, String password) {
// ... clever magic happens here
}
}

对于这样的考量,我个人是不推荐的。因为当LoginPage的login方法固定返回HomePage其实就是给这个login方法预置了期望,而且这个期望还是只在登录成功的情况下才能达成,这就进一步的限定了场景是”测试成功”,这种包含期望和场景的方法是我们在写PageObject时应该尽量避免的,这一点上面已经提过了。

日志与异常处理

日志与异常处理属于执行代码的技术事件,在测试案例与PageObject里面都不应该出现。除了调试,PageObject里面一般都不需要使用打印日志的代码,像下面这样的日志打印就纯属画蛇添足:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class LoginPage() {
...
public void login(String username, String password) {
inputUsername(username);
logger.info("input username: " + username);

inputPassword(password);
logger.info("input password: " + password);

WebElement loginButton = driver.findElement(By.id(loginButton));
loginButton.click();

logger.info("logged in");
}
}

如果需要收集日志,应该交给执行测试的runner或者运行测试的命令行工具去控制。对于日志,值得多提几句的是,同样是写代码,开发代码与测试代码对日志的需求是完全不同的。以后端服务为例,Service的运行代码中包含大量的日志打印是为了在出现异常情况时留下线索帮助事后分析问题。服务处理的事件很多情况都是无序、无状态、甚至有些是异步或难以复现的,特别是微服务系统,为了还原一个异常事件的前因后果,往往可能需要在多个系统的日志信息中去寻找蛛丝马迹,所以服务往往需要大量精准的日志打印工作。相对的,测试案例的场景和参数往往都是固定的(当然也有少数的动态参数),所有的操作步骤也都是有序执行的(这里仅考虑同一个测试案例的执行,不考虑并行执行、甚至竞争执行的情况),出现问题时可以通过异常发生的代码行数快速摸清整个场景的前后流程,所以日志打印对测试代码来说绝大多数情况下都是不必要的。

至于异常处理,对自动化测试来说,除了一些需要特别关照的”骚操作”外,尽量不要去主动捕获异常,测试执行遇到异常该挂就挂,该中断就中断。开发代码使用try-catch是为了让应用在遇到异常时仍然可以继续工作,而测试代码的目的则是发现错误。错误是SUT的就让开发去修,错误是测试的就让QA去修,修不好就让它挂着,挂到修好为止。千万不要为了让测试能跑起来就去盲目的添加各种try-catch,否则一旦处理不当就能坑死人。开篇说的那个3000行PageObject的自动化测试项目,就在处理配置文件时错误的使用了try-catch,致使测试在缺少一些关键配置项的情况下任然启动执行,最终导致部分测试案例出现了匪夷所思的行为,给调试工作带来了巨大的麻烦。

最后

作为免责声明,最后还是高亮一下:以上内容既不是什么金科玉律,也谈不上什么最佳实践,只是基于我个人的实操、观察与思考得来的心得体会,纯属一家之言,还望观者自己思辨。