天天看點

Cypress 的學習筆記

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:

Cypress 的學習筆記

支援類似 jQuery 的鍊式調用:

cy.get('#main-content').find('.article').children('img[src^="/static"]').first()
      

隻是有一點需要特别注意:

Cypress 的學習筆記

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!

      
Cypress 的學習筆記

正确的做法,把 html 元素 evaluation 的代碼放在 then 的callback裡:

Cypress 的學習筆記
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 循環,也得不到執行的機會。

正确的寫法:

  1. 利用遞歸
  2. 在 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 個 步驟的例子:

  1. Visit a URL.
  2. Find an element by its selector.
  3. Perform a click action on that element.
  4. Grab the URL.
  5. Assert the URL to include a specific string.

上述 5 步驟 是 串行執行的,而不是并發執行。每個步驟背後,Cypress 架構都悄悄執行了一些“魔法”:

  1. Visit a URL

魔法:Cypress wait for the page load event to fire after all external resources have loaded

該指令執行時,Cypress 等待頁面所有外部資源加載,然後頁面抛出 page load 事件。

  1. Find an element by its selector

    魔法:如果 find 指令沒找到 DOM element,就執行重試機制,直到找到位置。

  2. 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 , I expect its class to eventually be active.

注意其中的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')
      
  1. Queries for the element .mobile-nav, 然後停頓 4 秒,直至元素出現在 DOM 裡。
  2. 再停頓 4 秒,等待元素出現在頁面上。