5. 页面对象 Pages
注:阅读本章前,请确保你已经阅读了前面章节中关于 Brower.drive() 方法的内容。
5.1 页面对象模式
Browser.drive {
go "search"
$("input[name='q']").value "Chuck Norris"
$("input[value='Search']").click()
assert $("li", 0).text().contains("Chuck")
}
这是一段合法的 Geb 代码,作为一次性的脚本来说,它可以很好的工作,但是这种使用方式有两个主要问题。假设你有许多测试用例中都涉及到搜索并检查结果,那么实现搜索并检查结果的实现代码将会在每个测试用例里重复书写,更有甚者可能在一个测试用例里重复出现多次。此外,一旦关于搜索的实现细节有一些改动,例如,搜素框的名称变了,这意味着你要修改好多处代码。而页面对象设计模式允许我们使用与编程语言中其他各领域广泛使用的模块化、重用和封装相同的原则,在浏览器自动化代码中避免上述问题。
下面是使用页面对象实现上面搜索脚本中功能的代码示例:
import geb.Page
class SearchPage extends Page {
static url = "search"
static at = { title == "Search engine" }
static content = {
searchField { $("input[name=q]") }
searchButton(to: ResultsPage) { $("input[value='Search']") }
}
void search(String searchTerm) {
searchField.value searchTerm
searchButton.click()
}
}
class ResultsPage extends Page {
static at = { title == "Results" }
static content = {
results { $("li") }
result { index -> results[index] }
}
}
Browser.drive {
to SearchPage
search "Chuck Norris"
assert result(0).text().contains("Chuck")
}
现在你使用了可重用的方式封装了每个页面的信息以及与他们的交互方式。众所周知,维护一大套关于一个不断变化的应用的 Web 功能测试用例集的代价是多么大,过程是多么无趣。而 Geb 通过支持页面对象模式解决了这个头疼的问题。
5.2 所有页面的超类 Page
所有页面对象都必须继承 Page 类。
5.3 页面内容定义领域专用语言(DSL)
Geb 构建了一套模版风格的用于定义页面内容的领域专用语言,支持非常简洁而又灵活的页面内容定义。Page 对象定义了一个叫做 content 的静态闭包属性来表示页面内容。
给定下面的 HTML:
<div id="a">a</div>
我们可以像下面这样来定义这块页面内容:
class PageWithDiv extends Page {
static content = {
theDiv { $('div', id: 'a') }
}
}
可以看出,定义页面内容的 DSL 的通用格式如下:
«name» { «definition» }
其中 <<definition>> 表示一段 Groovy 代码,它会在页面实例上执行(即其中的未定义的方法调用或属性访问都会到页面对象上去解析)。下面代码展示了如何使用定义好的的页面内容:
Browser.drive {
to PageWithDiv
assert theDiv.text() == "a"
}
那么这究竟是如何工作的呢?首先,请记住 Browser 实例会将它不能处理的任何方法调用或属性访问委派给实例中维护的当前页面对象 page。所以上面的代码就等价于:
Browser.drive {
to PageWithDiv
assert page.theDiv.text() == "a"
}
其次,定义后的页面内容将变成页面实例中可访问的属性或方法:
Browser.drive {
to PageWithDiv
// 下面这两行是等价的
assert theDiv.text() == "a"
assert theDiv().text() == "a"
}
页面内容定义 DSL 实际上定义的是页面内容模版,下面的例子可以很好的展示这一点:
class TemplatedPageWithDiv extends Page {
static content = {
theDiv { id -> $('div', id: id) }
}
}
Browser.drive {
to TemplatedPageWithDiv
assert theDiv("a").text() == "a"
}
这里我们看到,我定定义了一个叫做 theDiv 的页面元素,随着我们传递给它的参数不同,他就代表页面上不同的元素,所以说 theDiv 实际上是任意满足标签为 ‘div’,id 为变量 id 的页面元素的模版。可以传递给页面内容模版的参数也是没有限制的。
页面内容模版可以返回任何值。在大部分使用场景下,它们都是通过使用 $() 方法进而返回一个 Navigator 对象,但其实它们可以返回任何值。
class PageWithStringContent extends Page {
static content = {
theDivText { $('div#a').text() }
}
}
Browser.drive {
to PageWithStringContent
assert theDivText == "a"
}
认识到 <<definition>> 代码是在页面对象上执行的,这一点很重要。这使得像下面这样在定义页面内容时重用已定义页面内容是允许的:
class PageWithContentReuse extends Page {
static content = {
theDiv { $("div#a") }
theDivText { theDiv.text() }
}
}
不仅可以使用已定义的页面内容,使用类中定义的其他东西(如:属性或方法)也是可以的:
class PageWithContentUsingAField extends Page {
def divId = "a"
static content = {
theDiv { $('div', id: divId) }
}
}
或者:
class PageWithContentUsingAMethod extends Page {
static content = {
theDiv { $('div', id: divId()) }
}
def divId() {
"a"
}
}
5.3.1 模版选项
页面内容模版定义时可以使用不同的选项,语法如下:
«name»(«options map») { «definition» }
例如:
theDiv(cache: false, required: false) { $("div", id: "a") }
下面是可用的模版选项:
required
默认值: true
required 选项用来指明模版定义返回的页面内容在页面上是否是必须存在的。只有在页面返回的页面内容是 Navigator 对象或 null 时,该选项才起作用,如果返回的是其他类型的对象,该选项会被忽略。如果 required 选项被设置成 true,而返回的页面内容却不存在,Geb 将会抛出 RequiredPageContentNotPresent 异常。
给定一个空的 HTML 文档,下面这些代码将能通过:
class PageWithTemplatesUsingRequiredOption extends Page {
static content = {
requiredDiv { $("div", id: "b") }
notRequiredDiv(required: false) { $("div", id: "b") }
}
}
to PageWithTemplatesUsingRequiredOption
assert !notRequiredDiv
def thrown = false
try {
page.requiredDiv
} catch (RequiredPageContentNotPresent e) {
thrown = true
}
assert thrown
min
默认值:1
min 选项用来指定由页面内容定义模版返回的 Navigator 对象中包含的最小元素个数。如果返回的 Navigator 对象中包含的元素个数小于 min 将会抛出 ContentCountOutOfBoundsException 异常。min 选项的值应该是个非负数。min 选项不能和下面将要讲到的 times 选项一起使用。
给定下面的 HTML:
<html>
<body>
<p>first paragraph</p>
<p>second paragraph</p>
<body>
</html>
访问下面的页面内容定义:
atLeastThreeElementNavigator(min: 3) { $('p') }
将会抛出 ContentOutOfBoundsException 和 类似下面的异常信息:
"Page content 'pages.PageWithTemplateUsingMinOption -> atLeastThreeElementNavigator: geb.navigator.NonEmptyNavigator' should return a navigator with at least 3 elements but has returned a navigator with 2 elements"
max
默认值:Integer.MAX_INT
max 选项用来指定由页面内容定义模版返回的 Navigator 对象中包含的最大元素个数。如果返回的 Navigator 对象中包含的元素个数大于 max 将会抛出 ContentCountOutOfBoundsException 异常。max 选项的值应该是个非负数。max 选项不能和下面将要讲到的 times 选项一起使用。
给定下面的 HTML:
<html>
<body>
<p>first paragraph</p>
<p>second paragraph</p>
<p>third paragraph</p>
<p>fourth paragraph</p>
<body>
</html>
访问下面的页面内容定义:
atMostThreeElementNavigator(max: 3) { $('p') }
将会抛出 ContentOutOfBoundsException 和 类似下面的异常信息:
"Page content 'pages.PageWithTemplateUsingMaxOption -> atMostThreeElementNavigator: geb.navigator.NonEmptyNavigator' should return a navigator with at most 3 elements but has returned a navigator with 4 elements"
times
默认值:null
times 是一个帮助选项用来在一个选项中同时指定 min 和 max 选项。如果页面内容定义模版返回的 Navigator 对象中包含的元素个数不在 times 指定的范围内,将会抛出 ContentCountOutOfBoundsException 异常。times 的值应该是个非负整数(当min 和 max 相等时)或是一个非负整数范围。这个选项不可以和 min 或 max 一起使用。
给定下面的 HTML:
<html>
<body>
<p>first paragraph</p>
<p>second paragraph</p>
<p>third paragraph</p>
<p>fourth paragraph</p>
<body>
</html>
访问下面的页面内容定义:
twoToThreeElementNavigator(times: 2..3) { $('p') }
将会抛出 ContentCountOutOfBoundsException 异常,和类似下面的异常信息:
"Page content 'pages.PageWithTemplateUsingTimesOption -> twoToThreeElementNavigator: geb.navigator.NonEmptyNavigator' should return a navigator with at most 3 elements but has returned a navigator with 4 elements"
cache
默认值:false
cache 选项用来控制是否每次请求页面内容时都对页面内容定义进行重新求值(对每个不同的输入参数集合,都会进行页面内容缓存)。
class PageWithTemplateUsingCacheOption extends Page {
def value = 1
static content = {
notCachedValue { value }
cachedValue(cache: true) { value }
}
}
to PageWithTemplateUsingCacheOption
assert notCachedValue == 1
assert cachedValue == 1
value = 2
assert notCachedValue == 2
assert cachedValue == 1
Cache 是为了优化性能,默认情况下是不开启的。如果你发现解析某个页面内容定义要花很长时间,你可能就需要开启缓存。
to
默认值:null
to 选项用来指明如果页面内容被点击后,浏览器应该跳转到哪个页面。
class PageWithTemplateUsingToOption extends Page {
static content = {
helpLink(to: HelpPage) { $("a", text: "Help") }
}
}
class HelpPage extends Page { }
to PageWithTemplateUsingToOption
helpLink.click()
assert page instanceof HelpPage
to 选项的值将会隐式的作为被点击的页面内容的 click() 方法的参数,这将会有效的设置点击后浏览器当前页面对象的类型并且会验证该页面的 at 检查器。该选项也支持所有能够作为 Brower.page() 方法参数的各种参数形式:
- 一个 Page 的实例
- 一个 Page 子类组成的列表
- 一个 Page 实例组成的列表
当使用列表形式的参数时(这里以 Page 子类的列表作为参数):
static content = {
loginButton(to: [LoginSuccessfulPage, LoginFailedPage]) { $("input.loginButton") }
}
页面内容被点击后,浏览器的 page 实例将会被设置成参数列表中第一个使 at 检查器通过的页面类型的实例。这就等效于在改变Browser 页面章节中讲到的浏览器实例的方法 page(Class<? extends Page>[]) 和 page(Page[])。作为 to 选项值传入的所有页面类或页面实例的类中都必须定义 at 检查器,否则将会抛出 UndefinedAtCheckerException 异常。
wait
默认值:false
可能值:
- true - 使用默认 wait 配置来等待页面内容
- 一个字符串 - 使用在配置中预定义的 wait 设置的名称来等待页面内容
- 一个数字 - 等待页面内容直到数字所指定的秒数,使用配置中指定的默认重试间隔
- 2 个数字组成的列表 - 使用第一个数字作为超时秒数,使用第二个数字作为重试间隔秒数
所有其他的值都将被解释为 false。
wait 选项允许 Geb 在一段时间内等待页面内容出现,而不是在被请求的页面内容不存在时,直接抛出 RequiredContentNotPresent 异常。
class DynamicPageWithWaiting extends Page {
static content = {
dynamicallyAdded(wait: true) { $("p.dynamic") }
}
}
to DynamicPageWithWaiting
assert dynamicallyAdded.text() == "I'm here now"
这就等效于使用 waitFor 方法进行显式的等待:
class DynamicPageWithoutWaiting extends Page {
static content = {
dynamicallyAdded { $("p.dynamic") }
}
}
to DynamicPageWithoutWaiting
assert waitFor { dynamicallyAdded }.text() == "I'm here now"
请查看后续关于 waiting 的章节来了解 waitFor 方法的语意,wait 选项被设置后,在内部其实是使用了该方法。就像 waitFor 方法一样,等待超时将会抛出 WaitTimeoutException 异常。
当定义非导航器的页面内容(如字符串或数字)时,也是可以使用 wait 选项的。Geb 会等待直到内容定义返回一个满足 Groovy Truth 的值或超时。
class DynamicPageWithNonNavigatorWaitingContent extends Page {
static content = {
status { $("p.status") }
success(wait: true) { status.text().contains("Success") }
}
}
to DynamicPageWithNonNavigatorWaitingContent
assert success
在这个例子中,我们在内部会等待页面内容 status 出现在页面上,并且等待它的文本包含字符串 Success。当我们访问内容 success 时,如果页面上还不存在 status 元素,抛出的 RequiredPageContentNotPresent 异常将会被 Geb 吞下,等到重试时间间隔到达后又回进行一次重试。
你可以修改 wait 选项被设置为 true 的页面内容的行为,如果你同时将该内容的 required 选择设置成了 false 的话。假设有这样的页面内容定义:
static content = {
dynamicallyAdded(wait: true, required: false) { $("p.dynamic") }
}
如果在获取 dynamicallyAdded 页面元素时等待超时,将不会抛出 WaitTimeoutException 异常,而是会返回闭包最后一次执行时的返回值。如果在闭包执行过程中抛出了异常,该异常会被包装成 UnknownWaitForEvaluationException 类的实例并返回。页面内容等待代码块会进行隐式的断言。请查看隐式断言章节来获取更多信息。
waitCondition
默认值:null
waitCondition 选项允许在等待页面内容时指定一个条件闭包,从页面内容模版定义返回的页面内容只有满足闭包给定的条件时,才被认为是可用的。作为等待条件传入的闭包,将会在一个 waitFor 循环中被调用,每次调用时会将从页面内容定义返回的页面内容作为闭包的唯一参数传入。如果隐式断言被开启的话(默认情况下就是开启的),那么闭包中的每一条语句都将被隐式的断言。如果等待条件被满足时,该闭包应该返回一个 Groovy Truth 值。
来看下面的例子:
static content = {
dynamicallyShown(waitCondition: { it.displayed }) { $("p.dynamic") }
}
访问页面元素:
dynamicallyShown
将会导致 Geb 等待直到该元素出现在 DOM 而且被显式(displayed)出来。当等待超时后,等待条件都不满足,将会抛出 WaitTimeoutException 异常。当指定 waitCondition 选项时,究竟该等多久是由 wait 选项来控制的,但是如果没有指定 wait 选项,就会把 wait 选项当作 true,这意味着将会使用默认的等待配置。
toWait
默认值:false
可能值和 wait 选项的可能值一样。
该选项可以和 to 选项一起使用,来指明当页面内容被点击后,页面改变动作是异步的。这本质上意味着对页面变化的验证(at 检查器)应该被包装到 waitFor 方法中。
class PageWithTemplateUsingToWaitOption extends Page {
static content = {
asyncPageLoadButton(to: AsyncPage, toWait: true) { $("button#load-content") } //1
}
}
class AsyncPage extends Page {
static at = { $("#async-content") }
}
//1: 页面改变是异步的,例如,通过 ajax 调用
to PageWithTemplateUsingToWaitOption
asyncPageLoadButton.click()
assert page instanceof AsyncPage
请查看后续关于 waiting 的章节来了解 waitFor 方法的语意,toWait 选项被设置后,在内部其实是使用了该方法。就像 waitFor 方法一样,等待超时将会抛出 WaitTimeoutException 异常。
page
默认值:null
如果页面内容定义的是一个 frame,并且被放在一个 withFrame() 方法中调用时,可以通过 page 选项来指明操作该 frame 内容时,浏览器的 page 实例是哪个页面类的对象。
给定下面这个 HTML:
<html>
<body>
<iframe id="frame-id" src="frame.html"></iframe>
<body>
</html>
假设 frame.html 的内容如下:
<html>
<body>
<span>frame text</span>
</body>
</html>
那么下面这些代码将会通过:
class PageWithFrame extends Page {
static content = {
myFrame(page: FrameDescribingPage) { $('#frame-id') }
}
}
class FrameDescribingPage extends Page {
static content = {
frameContentsText { $('span').text() }
}
}
to PageWithFrame
withFrame(myFrame) {
assert frameContentsText == 'frame text'
}
5.3.2 别名
如果你想用不同的名字来表示相同的页面内容定义,那么可以通过创建使用 aliases 参数的页面内容来实现此需求:
class AliasingPage extends Page {
static content = {
someDiv { $("div#aliased") }
aliasedDiv(aliases: "someDiv")
}
}
to AliasingPage
assert someDiv.@id == aliasedDiv.@id
请记住,被起别名的页面内容必须定义在别名定义之前,否则将会抛出 InvalidPageContent 异常。
5.3.3 访问已定义的页面内容名称
如果你需要在运行时访问已经定义的页面内容的名称,你可以使用页面实例的 contentName 属性来获取:
class ContentNamesPage extends Page {
static content = {
footer { $("#footer") }
paragraphText { $("p").text() }
}
}
to ContentNamesPage
assert contentNames == ['footer', 'paragraphText'] as Set
5.4 At 验证
每个页面都可以定义一种方式来检查底层浏览器是否真的在该页面类所代表的页面上。这是通过一个 static at 闭包来实现的:
class PageWithAtChecker extends Page {
static at = { $("h1").text() == "Example" }
}
这个闭包验证不通过时,返回一个 fase 或抛出一个 AssertionError 异常(通过 assert 方法)都是可以的。浏览器对象的 verifyAt() 方法调用有下面几种可能:
- 返回 true,如果 at 检查器通过
- 抛出 AssertionError 异常,如果隐式断言被开启,并且 at 检查失败
- 返回 false,如果隐式断言未开启,并且 at 检查失败
建议:at 检查器应该尽量简单 - 它们应该只检查预期的页面的确已经被渲染,而不处理和页面相关的具体逻辑。例如,它们应该只允许用来检查浏览器的确在订单小结页面,而不在产品详情页面,或订单未找到页面。从另一方面来说,它们不应该用来验证和任何页面逻辑相关的内容,例如页面结构,或页面上总是要满足的条件等,这些检查更适合放在一个测试用例里,而不是在 at 检查器中。因为 at 检查器通常会执行多次,并且通常是隐式的执行,例如使用 to() 方法的时候就会是这样。这意味着相同的内容将会一次又一次的被验证。所以一个比较好的原则就是使页面的 at 检查器尽量的简单,它们在大部分情况下都应该访问相同的内容,如页面 title,标题文本等,只是它们期待的值会有差异而已。
就上面的示例代码而言,你可以像下面这样来使用它:
class PageLeadingToPageWithAtChecker extends Page {
static content = {
link { $("a#to-page-with-at-checker") }
}
}
to PageLeadingToPageWithAtChecker
link.click()
page PageWithAtChecker
verifyAt()
Browser.at() 方法接受一个页面类或页面实例作为参数,该方法内部其实使用了 verifyAt() 方法。所以如果 at 检查器失败时,它的行为和 verifyAt() 方法一样。而 at 检查器成功时,它会返回一个被检查页面类的实例(如果你想使用 Geb 写强类型检查代码时,这就很有用):
to PageLeadingToPageWithAtChecker
link.click()
at PageWithAtChecker
注:如果是使用 to() 方法或者是通过点击一个带有 to 选项的页面内容来导航到一个页面时,就可以不用进行显式的 at 检查,因为在这些情况下,at 检查都是会自动隐式进行的。
At 检查器也会进行隐式断言,请查看后续关于隐式断言的章节来获取更多信息。
如果你并不希望 at 检查器失败时抛出异常,这种情况下,Geb 也提供了几个放回 false 而非异常的方法:Page#verifyAtSafely() 和 Browser#isAt(Class<? extents Page>)。
前面我们已经提到过,如果页面内容定义模版中使用了 to 选项,且选项的值为 1 个以上的页面,那么这些页面的 verifyAt() 方法就会自动被调用来判断浏览器当前究竟在哪个页面。在这种情况下,由 at 检查器抛出的任何断言失败 AssertionError 都会被抑制。
At 检查器是在页面实例上执行的,所以可以访问已定义的页面内容或页面中其他的任何变量或方法:
class PageWithAtCheckerUsingContent extends Page {
static at = { heading == "Example" }
static content = {
heading { $("h1").text() }
}
}
如果页面中并未定义 at 检查器,那么 verifyAt() 和 at() 方法将会抛出 UndefinedAtCheckerException 异常。同样,作为页面内容定义模版 to 选项参数的页面列表中,如果存在未定义 at 检查器的页面,也会抛出此异常。
默认情况下,将 at 检查器包装在 waitFor 方法调用中是比较有用的,因为有些 WebDriver 实现会在页面 URL 发生变化后就返回程序控制权,而这时页面可能还没有完全加载完成或者未像用户预期的那样加载完成。如果想启用这种行为的话,可以通过 atCheckWaiting 配置项来实现。
非预期的页面
可以通过 unexpectedPages 配置项来指定一系列非预期的页面。
注意:本功能不是基于 HTTP 响应状态码来实现的,因为 WebDriver 没有把这些暴露出来,所以 Geb 无法访问它们。如果想使用本功能,你的应用程序必须渲染定制化的错误页面,并且这些错误页面可以被建模成 Page 子类,并可以使用 at 检查器进行检测。
假设你的应用程序会渲染定制化的错误页面,例如,当请求的页面找不到时,页面会展示一个字符串 “Sorry but we could not find that page”,那么你可以使用类似于下面的类来建模这个页面:
class PageNotFoundPage extends Page {
static at = { $("#error-message").text() == "Sorry but we could not find that page" }
}
然后在配置里注册这个页面:
unexpectedPages = [PageNotFoundPage]
那么在检查浏览器是否在某个页面时,如果 PageNotFoundPage 页面类的 at 检查器通过,将会抛出 UnexpectedPageException 异常:
try {
at ExpectedPage
assert false //should not get here
} catch (UnexpectedPageException e) {
assert e.message.startsWith("An unexpected page ${PageNotFoundPage.name} was encountered")
}
无论何时执行 at 检查都会检查是否在非预期页面。甚至隐式检查的时(如,使用页面内容模版 to 选项,或 Navigator.click() 方法中有一个或多个页面类参数时)也都会进行。
你也可以显式的检查浏览器是否在一个非预期的页面。下面的代码将会通过,且不抛出 UnexpectedPageException 异常,如果 PageNotFoundPage 页面的 at 检查器执行成功的话。
at PageNotFoundPage
注意:在检查是否在非预期页面时,全局的 atCheckWaiting 配置不会生效。就是说,即使我们使用配上让 at 检查器隐式的包装在 waitFor 方法中,在检查是否在非预期页面时也不会进行等待。这是因为在大部分情况下对非预期页面的 at 检查都是不通过的,并且在每次 at() 和 to() 方法调用中都要进行非预期页面的 at 检查,如果把他们包装在隐式的 waitFor 调用中,将会使这些方法非常慢。与之不同的是,页面级别的 atCheckWaiting 配置是会对非预期页面生效的,因此如果你真需要对这些页面执行等待的话,可以使用页面级别的 atCheckWaiting 配置。
5.5 页面 URL
可以通过 static url 属性为页面定义 URL。
class PageWithUrl extends Page {
static url = "example"
}
在使用 Browser 实例的 to() 方法时,就会使用页面定义的 url:
to PageWithUrl
assert currentUrl.endsWith("example")
请查看基准 URL 章节,来了解更多关于 URL 和 / 的信息。
5.5.1 URL 分节(fragments)
还可以使用 static fragment 属性来为页面定义 URL 分节标识符(url 尾部处于 # 号后面的那一段)。该属性的值可以是一个字符串(构造 URL 时会直接使用)或是一个 Map(构造 URL 时将会被转换成 application/x-www-form-urlencoded 字符串)。后者在处理单页面应用(这类应用会在 URL 分节中通过 form encoding 来存储状态)时非常有用。
注:这里并不需要对 URL 分节中使用的字符串进行转义,因为 Geb 会为我们处理转义的事情。
来看下面这个单页面应用场景中定义了 URL 分节的页面:
class PageWithFragment extends Page {
static fragment = [firstKey: "firstValue", secondKey: "secondValue"]
}
在使用 Browser 的 to() 方法时,这些分节就会被用上:
to PageWithFragment
assert currentUrl.endsWith("#firstKey=firstValue&secondKey=secondValue")
你也可以使用动态的页面分节,后续高级页面导航章节中会有介绍。
5.5.2 页面级别 atCheckWaiting 配置
每个页面的 at 检查器都可以被配置使用 waitFor 方法来包装,使用 static atCheckWaiting 属性就能实现此目的。
class PageWithAtCheckWaiting extends Page {
static atCheckWaiting = true
}
atCheckWaiting 属性的可能值和页面内容模版的 wait 选项的可能值相同。
页面级别的 atCheckWaiting 配置的优先级要高于全局的 atCheckWaiting 配置。
5.6 高级页面导航
页面类可以定制它们被用在 Browser 的 to() 方法中时如何生成 URL。以下面这段代码为例:
class PageObjectsPage extends Page {
static url = "pages"
}
Browser.drive(baseUrl: "http://www.gebish.org/") {
to PageObjectsPage
assert currentUrl == "http://www.gebish.org/pages"
}
Browser 的 to() 方法还可以同时接受其他参数:
class ManualsPage extends Page {
static url = "manual"
}
Browser.drive(baseUrl: "http://www.gebish.org/") {
to ManualsPage, "0.9.3", "index.html"
assert currentUrl == "http://www.gebish.org/manual/0.9.3/index.html"
}
Browser 的 to() 方法中,在页面类后面的任何参数都将作为请求 URL 的一段路径,各个参数都会使用 toString() 方法转换成字符串,各参数之间使用 “/” 来连接。
然而,这也是可扩展的。你可以指定一个参数集如何转换成 URL 路径然后被添加到页面的 URL 之后。通过重写 convertToPath() 方法就能达到此目的。在 Page 类中,该方法的实现大致如下:
String convertToPath(Object[] args) {
args ? '/' + args*.toString().join('/') : ""
}
你可以重写这个兜底的方法来控制所有调用的路径生成规则,或者提供一个特定签名的重载版本。请看下面代码:
class Manual {
String version
}
class ManualsPage extends Page {
static url = "manual"
String convertToPath(Manual manual) {
"/${manual.version}/index.html"
}
}
def someManualVersion = new Manual(version: "0.9.3")
Browser.drive(baseUrl: "http://www.gebish.org/") {
to ManualsPage, someManualVersion
assert currentUrl == "http://www.gebish.org/manual/0.9.3/index.html"
}
5.6.1 具名参数
Browser 的 to() 方法中可以使用任何类型的参数,除了具名参数(例如:Map)。因为具名参数总是会被当作查询字符串(查询参数),而不是被当作页面的路径变量。
def someManualVersion = new Manual(version: "0.9.3")
Browser.drive(baseUrl: "http://www.gebish.org/") {
to ManualsPage, someManualVersion, flag: true
assert currentUrl == "http://www.gebish.org/manual/0.9.3/index.html?flag=true"
}
5.6.2 URL 分节
为了动态的控制所访问页面的分节,可以在 Browser 的 to() 方法的页面类或页面实例实例参数之后,传递一个 UrlFragment 类的实例作为参数。UrlFragment 类提供了两个静态的工厂方法,一个用来使用显式指定的 String 创建分节,另一个用来从一个 Map 创建分节并进行 application/x-www-form-urlencoded 编码。
下面结合前面章节中的类,来演示一下这种用法:
Browser.drive(baseUrl: "http://www.gebish.org/") {
to ManualsPage, UrlFragment.of("advanced-page-navigation"), "0.9.3", "index.html"
assert currentUrl == "http://www.gebish.org/manual/0.9.3/index.html#advanced-page-navigation"
}
如果你正在使用参数化页面,并且想动态生成页面分节(例如想基于页面属性来生成分节),那么你可以重写 getPageFragment() 方法:
class ParameterizedManualsPage extends Page {
String version
String section
@Override
String convertToPath(Object[] args) {
"manual/$version/index.html"
}
@Override
UrlFragment getPageFragment() {
UrlFragment.of(section)
}
}
Browser.drive(baseUrl: "http://www.gebish.org/") {
to new ParameterizedManualsPage(version: "0.9.3", section: "advanced-page-navigation")
assert currentUrl == "http://www.gebish.org/manual/0.9.3/index.html#advanced-page-navigation"
}
5.7 参数化页面
Browser 的诸多方法,如 to(),via(),at(),page() 等,不仅可以接受页面类作为参数,也可以接受页面实例作参数。在参数化页面以便在 at 检查器中使用属性值时,这将会有用。
class BooksPage extends Page {
static content = {
book { bookTitle -> $("a", text: bookTitle) }
}
}
class BookPage extends Page {
String forBook
static at = { forBook == bookTitle }
static content = {
bookTitle { $("h1").text() }
}
}
Browser.drive {
to BooksPage
book("The Book of Geb").click()
at(new BookPage(forBook: "The Book of Geb"))
}
注意:手动实例化的页面在使用前必须进行初始化。初始化是上面提到的那些 Browser 方法的一部分工作。如果给这些方法传送页面实例失败或者在未初始化的实例上调用这些方法可以引起 PageInstanceNotInitializedException 异常
5.8 页面继承
页面可以被组织成继承层次结构。这种情况下,页面内容定义会进行合并。
class BasePage extends Page {
static content = {
heading { $("h1") }
}
}
class SpecializedPage extends BasePage {
static content = {
footer { $("div.footer") }
}
}
Browser.drive {
to SpecializedPage
assert heading.text() == "Specialized page"
assert footer.text() == "This is the footer"
}
如果子类中定义了一个和父类中同名的页面内容模版,那么子类中的定义的版本将会取代父类中的定义。
5.9 生命期钩子
页面类可以选择实现在该页面被设置成浏览器的当前页面时,或浏览器的当前页面由该页面被替换成其他页面时会被调用的那些可选方法。这些方法可以用来在页面之间传递状态。
5.9.1 onLoan(Page previousPage)
当某个页面变长 Browser 的当前页面对象时,该页面的 onLoad() 方法就会被调用,方法的参数是之前作为浏览器当前页面的那个页面实例。
class FirstPage extends Page {
}
class SecondPage extends Page {
String previousPageName
void onLoad(Page previousPage) {
previousPageName = previousPage.class.simpleName
}
}
Browser.drive {
to FirstPage
to SecondPage
assert page.previousPageName == "FirstPage"
}
5.9.2 onUnload(Page newPage)
当浏览器的当前页面对象将要发生变化时,当前页面对象的 onUnload() 方法就会被调用,调用的参数是即将被设置成浏览器当前页面的那个页面实例。
class FirstPage extends Page {
String newPageName
void onUnload(Page newPage) {
newPageName = newPage.class.simpleName
}
}
class SecondPage extends Page {
}
Browser.drive {
def firstPage = to FirstPage
to SecondPage
assert firstPage.newPageName == "SecondPage"
}
5.10 处理 frames
Frame 看起来好像是已经有点过时的东西了,但如果你在使用 Geb 测试一些老的应用,你可能仍然需要处理他们。谢天谢地,在 Geb 中使用 withFrame() 方法来处理 frame 相当方便。在 Browser,Page 和 Module 对象上都可以使用 withFrame 方法。
5.10.1 在 frame 的上下文中执行代码
withFrame() 方法有多种变种,但是所有这些 withFrame 方法的最后一个闭包参数都是在它的第一个参数指定的 frame 的上下文中执行的。闭包参数执行后的返回值就作为 withFrame() 方法的返回值,并且执行完成后,浏览器的当前页面会被恢复成执行 withFrame 方法之前的页面。下面是各种形式的 withFrame 方法:
- withFrame(String, Closure) - String 参数是 frame 元素的 name 或 id
- withFrame(int, Closure) - int 参数表示 frame 元素的索引,例如,页面中包含 3 个 frame 元素,那么第一个 frame 的下标 为 0,第二个为 1,依此类推
- withFrame(Navigator, Closure) - Navigator 参数应该包含一个 frame 元素
- withFrame(SimplePageContent, Closure) - SimplePageContent 是页面内容模版返回的一个类型,它应该包含一个 frame 元素
给定下面的 HTML:
<html>
<body>
<iframe name="header" src="frame.html"></iframe>
<iframe id="footer" src="frame.html"></iframe>
<iframe id="inline" src="frame.html"></iframe>
<span>main</span>
<body>
</html>
且假设 frame.html 的内容如下:
<html>
<body>
<span>frame text</span>
</body>
</html>
且有下面的页面类:
class PageWithFrames extends Page {
static content = {
footerFrame { $('#footer') }
}
}
那么下面这段代码将能通过:
to PageWithFrames
withFrame('header') { assert $('span').text() == 'frame text' }
withFrame('footer') { assert $('span').text() == 'frame text' }
withFrame(0) { assert $('span').text() == 'frame text' }
withFrame($('#footer')) { assert $('span').text() == 'frame text' }
withFrame(footerFrame) { assert $('span').text() == 'frame text' }
assert $('span').text() == 'main'
如果 withFrame 方法第一个参数所指定的 frame 找不到,将会抛出 NoSuchFrameException 异常。
5.10.2 同时切换页面和 frame
前面提到的各种 withFrame 方法都能接受一个可选的第二个参数(一个页面类或页面实例),这允许在执行最后的闭包参数时切换浏览器的当前页面。如果传入的页面类定义了 at 检查器,那么当切换到 frame 的上下文后,就会对该 at 检查器进行验证。
还是以前面一节提高的 HTML 和 页面类为例,下面是使用页面类作为 withFrame 第二参数的一个例子:
class PageDescribingFrame extends Page {
static content = {
text { $("span").text() }
}
}
to PageWithFrames
withFrame('header', PageDescribingFrame) {
assert page instanceof PageDescribingFrame
assert text == "frame text"
}
assert page instanceof PageWithFrames
下面是使用页面类实例作为参数的例子:
class ParameterizedPageDescribingFrame extends Page {
String expectedFrameText
static at = { text == expectedFrameText }
static content = {
text { $("span").text() }
}
}
to PageWithFrames
withFrame('header', new ParameterizedPageDescribingFrame(expectedFrameText: "frame text")) {
assert page instanceof ParameterizedPageDescribingFrame
}
assert page instanceof PageWithFrames
为一个代表一个 frame 的页面内容指定要切换到的页面(使用页面内容模版的 page 选项)也是可以的。