Home 使用 Page Object Model 来写 UI Test
Post
Cancel
Preview Image

使用 Page Object Model 来写 UI Test

随着 SwiftUI 的使用,使得现在写 UI Test 也更为容易了一些,一般的视图都能够被分离出来,单独拿出来进行测试。如果再加上 Page Object Model,那么将能够给写 UI Test 锦上添花。下面就来一探究竟吧。

本文的示例代码来自于 UI Testing using Page Object pattern in Swift

例子使用一个简单的登录界面,大家可以把它简单地想象成这样子:

基本的测试写法

我们希望测试这个界面的界面元素和登录交互,代码可以长这个样子:

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
final class LoginTests: XCTestCase {
    var app: XCUIApplication!

    override func setUp() {
        continueAfterFailure = false
        app = XCUIApplication()
        app.launchArguments = ["testing"]
        app.launch()
    }

    func testLoginFlow() {
        let email = app.textFields["email"]
        email.tap()
        email.typeText("cmecid@gmail.com")

        let pwd = app.secureTextFields["password"]
        pwd.tap()
        pwd.typeText("pwd")

        app.buttons["login"].tap()

        let message = app.staticTexts["Hello World!"]
        XCTAssertTrue(message.waitForExistence(timeout: 5))
    }
}

在测试 case 执行前,获取到 app 实例,然后启动 app。在测试方法中,通过查找界面元素,获取到输入 email 和 password 的 TextField,并且点击他们输入测试文本,然后找到 login 的 Button 点击它,期望是在按钮点击之后,切换到一个新的界面,显示 Hello World! 的文本。这是一个基本的登录操作的流程。

上面的测试代码,是有可优化的点的。比如 setUp 方法中的 app 环境,我们几乎在每个 UI 测试中都会用到它,因此它是可以被抽取的;还有假使这个登录流程需要作为某一个 UI 测试的前置操作,会被复用到,那么势必会出现重复代码。下面就通过两次优化,来引出 Page Object Model。

抽取 UI Test 的基类

通过把 setUptearDown 的代码抽取出来,让子类继承,来简化每次测试前后的代码书写。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class UITestCase: XCTestCase {
    var app: XCUIApplication!

    override func setUp() {
        continueAfterFailure = false
        app = XCUIApplication()
        app.launchArguments = ["testing"]
        app.launch()
    }

    override func tearDown() {
        let screenshot = XCUIScreen.main.screenshot()
        let attachment = XCTAttachment(screenshot: screenshot)
        attachment.lifetime = .deleteOnSuccess
        add(attachment)
        app.terminate()
    }
}

setUp 阶段启动 app,在 tearDown 阶段,如果有测试失败的话,保存失败场景的截图,便于 Debug。

通过继承 UITestCaseLoginTests 可以简化为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
final class LoginTests: UITestCase {
    func testLoginFlow() {
        let email = app.textFields["email"]
        email.tap()
        email.typeText("cmecid@gmail.com")

        let pwd = app.secureTextFields["password"]
        pwd.tap()
        pwd.typeText("pwd")

        app.switches["rememberMe"].tap()
        app.buttons["login"].doubleTap()
        app.buttons["login"].twoFingerTap()

        let message = app.staticTexts["Hello World!"]
        XCTAssertTrue(message.waitForExistence(timeout: 5))
    }
}

使用 Page Object Model

Page Object 是按照界面来进行封装,封装的类只提供当前界面的一些元素和交互方法,而测试用例来复杂消费他们,然后构建整个交互流程。

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
protocol Screen {
    var app: XCUIApplication { get }
}

struct LoginScreen: Screen {
    let app: XCUIApplication

    private enum Identifiers {
        static let email = "email"
        static let password = "password"
        static let login = "login"
        static let error = "error"
    }

    func typeEmail(_ email1: String) -> Self {
        let email = app.textFields[Identifiers.email]
        email.tap()
        email.typeText(email1)
        return self
    }

    func tapLoginExpectingError() -> Self {
        app.buttons[Identifiers.login].tap()
        let error = app.staticTexts[Identifiers.error]
        XCTAssertTrue(error.waitForExistence(timeout: 5))
        return self
    }

    func tapLogin() -> MessageScreen {
        app.buttons[Identifiers.login].tap()
        return MessageScreen(app: app)
    }

    func typePassword(_ password: String) -> Self {
        let pwd = app.secureTextFields[Identifiers.password]
        pwd.tap()
        pwd.typeText(password)
        return self
    }
}

LoginScreen 这个结构体,只包含登录界面的元素和一些交互方法,它把每个交互都单独定义了方法,这样不论测试用例想如何进行验证,都是比较灵活的。然后我们还有一个登录成功之后的 MessageScreen

1
2
3
4
5
6
7
8
9
struct MessageScreen: Screen {
    let app: XCUIApplication

    func verifyMessage(_ message: String) -> Self {
        let message = app.staticTexts[message]
        XCTAssertTrue(message.waitForExistence(timeout: 5))
        return self
    }
}

以上所有的方法,都返回了自己或者其他的 Screen,这样方便我们进行链式调用来组合一些元素和交互,例如 tapLogin 方法返回了 MessageScreen,这也是符合交互逻辑的。正常情况下,点击完登录应该跳转到 Message 界面。

最终我们的 UI Test 就变成了这样:

1
2
3
4
5
6
7
8
9
10
11
12
final class LoginTests: UITestCase {
    func testLoginFlow() {
        LoginScreen(app: app)
            .typeEmail("Cmecid@gmail.com")
            .typePassword("password")
            .tapLoginExpectingError()
            .typeEmail("Cmecid@gmail.com")
            .typePassword("pwd")
            .tapLogin()
            .verifyMessage("Hello World!")
    }
}

这样的调用逻辑是非常清晰的,并且各个页面都能被复用,每个页面里面的具体细节都被隐藏了起来。

例子中使用的是 Screen 而非 Page,这是因为 Page Object 这个概念是从 Web 来的,所以示例的作者使用了 Screen 代替了 Page,其实在项目实践中,并不一定每一个类都要代表一个 Screen,它可以是对一个 UI Component 的测试,因为我们往往会把一些可复用组件进行拆分,所以对小组件单独进行测试也是不错的实践。

是否在 Page 中进行断言

大家会发现,在文中的示例其实是在 Page 的方法中加入了断言语句,而在测试用例中只是调用了相应的方法。还有一种观点是 Page 应该只负责提供界面元素和交互方法,而不加断言,让测试方法自己去决定想要怎么断言。Martin Fowler 在他的文章中指出了这两种方法的优缺点:

  • 在 Page 中写断言,优点是可以避免重复的断言语句,提供更符合上下文的错误信息,以及更符合 TellDontAsk 风格的 API。
  • 而缺点则是,使得 Page 的职责不再单一,混杂了断言语句的逻辑,会使得 Page 变得更大更复杂。

因为示例的场景比较简单,因此看起来在 Page 中写断言还是挺清晰的,但是随着页面和逻辑变得更复杂,它的弊端会不会展现出来呢?

Martin Fowler 个人更偏向于不在 Page 中写断言,大家可以根据自己的想法和项目情况,来进行选择。目前个人觉得在页面不复杂的情况下,在 Page 中写断言是更清晰的。

参考

This post is licensed under CC BY 4.0 by the author.

Desperate Housewives S01E02

Desperate Housewives S01E03

Comments powered by Disqus.