https://docs.cypress.io/guides/core-concepts/introduction-to-cypress#Subject-Management
describe('Post Resource', () => {
it('Creating a New Post', () => {
cy.visit('/posts/new') // 1.
cy.get('input.post-title') // 2.
.type('My First Post') // 3.
cy.get('input.post-body') // 4.
.type('Hello, world!') // 5.
cy.contains('Submit') // 6.
.click() // 7.
cy.url() // 8.
.should('include', '/posts/my-first-post')
cy.get('h1') // 9.
.should('contain', 'My First Post')
})
})
上述 cypress 代码,很像自然语言。
cypress 的 语法,cy.get('.my-selector'),很像jQuery: cy.get('.my-selector')
事实上,cypress 本身就 bundle 了 jQuery:
支持类似 jQuery 的链式调用:
cy.get('#main-content').find('.article').children('img[src^="/static"]').first()
只是有一点需要特别注意:
ct.get 并不会像 jQuery 那样,采用同步的方式返回待读取的元素。Cypress 的元素访问,采取异步方式
完成。
因为 jQuery 的同步访问机制,我们在调用元素查询 API 之后,需要手动查询其结果是否为空:
// $() returns immediately with an empty collection.
const $myElement = $('.element').first()
// Leads to ugly conditional checks
// and worse - flaky tests!
if ($myElement.length) {
doSomething($myElement)
}
而 Cypress 的异步操作,导致待读取的元素真正可用时,其结果才会被作为参数,传入回调函数:
cy
// cy.get() looks for '#element', repeating the query until...
.get('#element')
// ...it finds the element!
// You can now work with it by using .then
.then(($myElement) => {
doSomething($myElement)
})
In Cypress, when you want to interact with a DOM element directly, call .then() with a callback function that receives the element as its first argument.
也就是说,Cypress 内部帮我们封装了 retry 和 timeout 重试机制。
When you want to skip the retry-and-timeout functionality entirely and perform traditional synchronous work, use Cypress.$.
如果想回归到 jQuery 那种同步读取元素的风格,使用 Cypress.$ 即可。
// Find an element in the document containing the text 'New Post'
cy.contains('New Post')
// Find an element within '.main' containing the text 'New Post'
cy.get('.main').contains('New Post')
Cypress commands do not return their subjects, they yield them. Remember: Cypress commands are asynchronous and get queued for execution at a later time. During execution, subjects are yielded from one command to the next, and a lot of helpful Cypress code runs between each command to ensure everything is in order.
Cypress 命令并不会直接返回其工作的目标,而是 yield 这些目标。Cypress 命令以异步的方式执行,命令被插入到队列里,并不会立即执行,而是等待调度。当命令真正执行时,目标对象经由前一个命令生成,然后传入下一个命令里。命令与命令之间,执行了很多有用的 Cypress 代码,以确保命令执行顺序和其在 Cypress 测试代码里调用的顺序一致。
To work around the need to reference elements, Cypress has a feature known as aliasing. Aliasing helps you to store and save element references for future use.
Cypress 提供了一种叫做 aliasing 的机制,能将元素引用保存下来,以备将来之用。
看一个例子:
cy.get('.my-selector')
.as('myElement') // sets the alias,使用 as 命令将 get 返回的元素存储到自定义变量 myElement 中。
.click()
/* many more actions */
cy.get('@myElement') // re-queries the DOM as before (only if necessary),通过@ 引用自定义变量
.click()
使用 then 来对前一个命令 yield 的目标进行操作
cy
// Find the el with id 'some-link'
.get('#some-link')
.then(($myElement) => {
// ...massage the subject with some arbitrary code
// grab its href property
const href = $myElement.prop('href')
// strip out the 'hash' character and everything after it
return href.replace(/(#.*)/, '')
})
.then((href) => {
// href is now the new subject
// which we can work with now
})
Cypress 的异步执行特性
It is very important to understand that Cypress commands don't do anything at the moment they are invoked, but rather enqueue themselves to be run later. This is what we mean when we say Cypress commands are asynchronous.
it('changes the URL when "awesome" is clicked', () => {
cy.visit('/my/resource/path') // Nothing happens yet
cy.get('.awesome-selector') // Still nothing happening
.click() // Nope, nothing
cy.url() // Nothing to see, yet
.should('include', '/my/resource/path#awesomeness') // Nada.
})
// Ok, the test function has finished executing...
// We've queued all of these commands and now
// Cypress will begin running them in order!
Cypress doesn't kick off the browser automation magic until the test function exits.
这是 Cypress 不同于其他前端自动测试框架的特别之处:直到测试函数退出,Cypress 才会触发浏览器的自动执行逻辑。
it('does not work as we expect', () => {
cy.visit('/my/resource/path') // Nothing happens yet
cy.get('.awesome-selector') // Still nothing happening
.click() // Nope, nothing
// Cypress.$ is synchronous, so evaluates immediately
// there is no element to find yet because
// the cy.visit() was only queued to visit
// and did not actually visit the application
let el = Cypress.$('.new-el') // evaluates immediately as []
if (el.length) {
// evaluates immediately as 0
cy.get('.another-selector')
} else {
// this will always run
// because the 'el.length' is 0
// when the code executes
cy.get('.optional-selector')
}
})
// Ok, the test function has finished executing...
// We've queued all of these commands and now
// Cypress will begin running them in order!
正确的做法,把 html 元素 evaluation 的代码放在 then 的callback里:
Each Cypress command (and chain of commands) returns immediately
每个 Cypress 命令(包含命令链)调用后立即返回,不会阻塞住以达到同步运行的效果。
Having only been appended to a queue of commands to be executed at a later time.
这些 command 只是被添加到一个命令队列里,等待 Cypress 框架稍后统一调度执行。
You purposefully cannot do anything useful with the return value from a command. Commands are enqueued and managed entirely behind the scenes.
对于 Cypress 直接返回的命令的执行结果,我们无法对其实行任何有效的操作,因为代码里命令的调用,实际上只是加入到待执行队列里。至于何时执行,由 Cypress 统一调度,对Cypress 测试开发人员来说是黑盒子。
We've designed our API this way because the DOM is a highly mutable object that constantly goes stale. For Cypress to prevent flake, and know when to proceed, we manage commands in a highly controlled deterministic way.
Cypress API 如此设计的原因是,DOM 是一种易变对象,随着用户操作或者交互,状态经常会 go stale. 为了避免出现 flake 情形,Cypress 遵循了上文描述的思路,以一种高度可控,确定性的方式来管理命令执行。
下面一个例子:网页显示随机数,当随机数跳到数字 7 时,让测试停下来。 如果随机数不是数字 7,重新加载页面,继续测试。
下列是错误的 Cypress 代码,会导致浏览器崩溃:
let found7 = false
while (!found7) {
// this schedules an infinite number
// of "cy.get..." commands, eventually crashing
// before any of them have a chance to run
// and set found7 to true
cy.get('#result')
.should('not.be.empty')
.invoke('text')
.then(parseInt)
.then((number) => {
if (number === 7) {
found7 = true
cy.log('lucky **7**')
} else {
cy.reload()
}
})
}
原因就是:在 while 循环里迅速将巨量的 get command 插入到任务队列(准确的说是 test chain)里,而根本没有机会得到执行。
The above test keeps adding more cy.get('#result') commands to the test chain without executing any!
上面的代码,起到的效果就是,在 while 循环里,不断地将 cy.get 命令,加入到 test chain里,但是任何一个命令,都不会有得到执行的机会!
The chain of commands keeps growing, but never executes - since the test function never finishes running.
命令队列里的元素个数持续增长,但是永远得不到执行的机会,因为 Cypress 代码本身一直在 while 循环里,没有执行完毕。
The while loop never allows Cypress to start executing even the very first cy.get(...) command.
即使是任务队列里第一个 cy.get 语句,因为 while 循环,也得不到执行的机会。
正确的写法:
- 利用递归
- 在 callback 里书写找到 7 之后 return 的逻辑。
const checkAndReload = () => {
// get the element's text, convert into a number
cy.get('#result')
.should('not.be.empty')
.invoke('text')
.then(parseInt)
.then((number) => {
// if the expected number is found
// stop adding any more commands
if (number === 7) {
cy.log('lucky **7**')
return
}
// otherwise insert more Cypress commands
// by calling the function after reload
cy.wait(500, { log: false })
cy.reload()
checkAndReload()
})
}
cy.visit('public/index.html')
checkAndReload()
command 执行过程中背后发生的事情
下列这段代码,包含了 5 部分逻辑:
it('changes the URL when "awesome" is clicked', () => {
cy.visit('/my/resource/path') // 1.
cy.get('.awesome-selector') // 2.
.click() // 3.
cy.url() // 4.
.should('include', '/my/resource/path#awesomeness') // 5.
})
5 个 步骤的例子:
- Visit a URL.
- Find an element by its selector.
- Perform a click action on that element.
- Grab the URL.
- Assert the URL to include a specific string.
上述 5 步骤 是 串行执行的,而不是并发执行。每个步骤背后,Cypress 框架都悄悄执行了一些“魔法”:
- Visit a URL
魔法:Cypress wait for the page load event to fire after all external resources have loaded
该命令执行时,Cypress 等待页面所有外部资源加载,然后页面抛出 page load 事件。
-
Find an element by its selector
魔法:如果 find 命令没找到 DOM element,就执行重试机制,直到找到位置。 -
Perform a click action on that element
魔法:after we wait for the element to reach an actionable state
在 点击元素之前,先等待其成为可以点击状态。
每个 cy 命令都有特定的超时时间,记录在文档里:
https://docs.cypress.io/guides/references/configuration
Commands are promise
This is the big secret of Cypress: we've taken our favorite pattern for composing JavaScript code, Promises, and built them right into the fabric of Cypress. Above, when we say we're enqueuing actions to be taken later, we could restate that as "adding Promises to a chain of Promises".
Cypress 在 promise 编程模式的基础上,增添了 retry 机制。
下列这段代码:
it('changes the URL when "awesome" is clicked', () => {
cy.visit('/my/resource/path')
cy.get('.awesome-selector').click()
cy.url().should('include', '/my/resource/path#awesomeness')
})
翻译成 promise 风格的 JavaScript 代码为:
it('changes the URL when "awesome" is clicked', () => {
// THIS IS NOT VALID CODE.
// THIS IS JUST FOR DEMONSTRATION.
return cy
.visit('/my/resource/path')
.then(() => {
return cy.get('.awesome-selector')
})
.then(($element) => {
// not analogous
return cy.click($element)
})
.then(() => {
return cy.url()
})
.then((url) => {
expect(url).to.eq('/my/resource/path#awesomeness')
})
})
Without retry-ability, assertions would randomly fail. This would lead to flaky, inconsistent results. This is also why we cannot use new JS features like async / await.
缺少重试机制,后果就是造成 flaky 和不一致的测试结果,这就是 Cypress 没有选择 async / await 的原因。
You can think of Cypress as "queueing" every command. Eventually they'll get run and in the exact order they were used, 100% of the time.
Cypress 的命令执行顺序和其被插入 test chain 队列的顺序完全一致。
How do I create conditional control flow, using if/else? So that if an element does (or doesn't) exist, I choose what to do?
有的开发人员可能会产生疑问,如何编写条件式控制流,比如在 IF / ELSE 分支里,执行不同的测试逻辑?
The problem with this question is that this type of conditional control flow ends up being non-deterministic. This means it's impossible for a script (or robot), to follow it 100% consistently.
事实上,这种条件式的控制逻辑,会使测试流失去确定性(non-deterministic). 这意味着测试脚本挥着机器人,无法 100% 严格按照测试程序去执行。
下列这行代码:
cy.get('button').click().should('have.class', 'active')
翻译成自然语言就是:
After clicking on this
注意其中的eventually.
This above test will pass even if the .active class is applied to the button asynchronously - or after a indeterminate period of time.
Cypress 会不断重试上述的 assertion,直至 .active class 被添加到 button 上,不管是通过异步添加,还是在一段未知长度的时间段后。
What makes Cypress unique from other testing tools is that commands automatically retry their assertions. In fact, they will look "downstream" at what you're expressing and modify their behavior to make your assertions pass.
You should think of assertions as guards.
Use your guards to describe what your application should look like, and Cypress will automatically block, wait, and retry until it reaches that state.
Cypress 命令默认的 assertion 机制
With Cypress, you don't have to assert to have a useful test. Even without assertions, a few lines of Cypress can ensure thousands of lines of code are working properly across the client and server!
This is because many commands have a built in Default Assertion which offer you a high level of guarantee.
很多 cy 命令都有默认的 assertion 机制。
-
cy.visit() expects the page to send text/html content with a 200 status code. 确保 页面发出 text/html 内容后,收到200 的状态码。
-
cy.request() expects the remote server to exist and provide a response.
确保远端系统存在,并且提供响应。 -
cy.contains() expects the element with content to eventually exist in the DOM.
确保制订的 content 最终在 DOM 中存在。 -
cy.get() expects the element to eventually exist in the DOM.
确保请求的 element 最终在 DOM 中存在。
-
.find() also expects the element to eventually exist in the DOM. - 同 cy.get
-
.type() expects the element to eventually be in a typeable state.
确保元素处于可输入状态。 -
.click() expects the element to eventually be in an actionable state.
确保元素处于可点击状态。 -
.its() expects to eventually find a property on the current subject.
确保当前对象上能够找到对应的 property
All DOM based commands automatically wait for their elements to exist in the DOM.
所有基于 DOM 的命令,都会自动阻塞,直至其元素存在于 DOM 树为止。
cy
// there is a default assertion that this
// button must exist in the DOM before proceeding
.get('button')
// before issuing the click, this button must be "actionable"
// it cannot be disabled, covered, or hidden from view.
.click()
在执行 click 命令之前,button 必须成为可点击状态,否则 click 命令不会得到执行。可点击状态(actionable),意思是 button 不能是 disabled,covered,或者 hidden 状态。
Cypress 命令自带的超时设置
cy.get('.mobile-nav').should('be.visible').and('contain', 'Home')
-
Queries for the element .mobile-nav, 然后停顿 4 秒,直至元素出现在 DOM 里。
-
再停顿 4 秒,等待元素出现在页面上。
-
再等待 4 秒,等待元素包含 home 的 text 属性。
一段测试程序里的所有 Cypress 命令,共享同一个超时值。
更多Jerry的原创文章,尽在:"汪子熙":