Playwright 基本操作

tags: JavaScript Playwright Testing End-to-End
category: Front-End
description: Playwright 基本操作
created_at: 2024/05/04 18:01:00

cover image


回到 Playwright 測試筆記


前言

這一篇會從建立專案開始,到執行測試,再到一些基本的操作,這邊會以 JavaScript 來做說明, Playwright 也有提供 TypeScript 的版本,所以如果你比較習慣用 TypeScript 也可以。

這一篇主要是基本操作的介紹,如果要比較實際的案例會在之後的篇章來介紹。


從建立測試專案開始

首先我們需要建立一個專案,這裡我們先用指令來建立一個 Playwright 的專案。

以往我自己會習慣都先以 npm 來做處理,等東西生出來再用自己習慣的套件管理器(如 pnpm) 來安裝套件,不過因為這邊 Playwright 初始化之後就會自動安裝 Playwright 的相關套件,所以這邊可以直接用你習慣的套件管理器來初始化專案。 當然你也可以 npm 弄出來然後把該砍的砍一砍再重裝XD

  • npm

    npm init playwright@latest
  • yarn

    yarn create playwright
  • pnpm

    pnpm create playwright

後面基本上都選擇預設值就好,除了語言的部分為了簡化,我們選擇 JavaScript

等他跑完之後,畢竟也是一個 JavaScript 專案,所以也可以使用我之前寫的 lai-cmd 去幫你建立開發環境,讓你寫起來比較舒服,至少可以讓他幫你安裝 eslintprettier


基本指令

安裝完成後,他會在 console 跟你說有一些指令可以用,但這邊我們就只列出比較常用的指令。

  npx playwright test
    Runs the end-to-end tests.
    (執行測試)

  npx playwright test --ui
    Starts the interactive UI mode.
    (開啟互動式 UI)

  npx playwright test --debug
    Runs the tests in debug mode.
    (開啟 debug 模式)

  npx playwright codegen
    Auto generate tests with Codegen.
    (開啟 codegen 工具)

其中我自己最最最常用的是第一個,理由如下

  • UI
    • 使用 UI 模式的話等於他會多吃一些資源,有時候如果電腦性能比較差,影響比較大的時候會導致你的測試超時(timeout)
  • Debug
    • debug 模式我自己目前是還沒用到,還是比較習慣在測試當中塞入 console.log 來除錯,因為這樣可以直接對他的過程一目瞭然,不過如果你有需要的話,這個指令也是可以用的。
  • codegen
    • 這個指令我個人是覺得有點鳥,因為他會自動幫你生成測試腳本,但是我自己覺得他生成的腳本有點多餘,而且有時候還會有一些問題,所以我自己是不太會用這個指令。 這段不是我寫的,這是 copilot 寫的;他其實只是幫你開一個 codegen 工具,然後讓你在上面錄製你的操作來生成腳本,但能夠生成的測試就有限。
    • 我自己還是習慣自己寫測試腳本,因為這樣可以更加了解測試的過程,而且也可以更加精準的控制測試的過程。

生成的專案結構

生成的專案結構如下(主要)

.
├── package.json
├── playwright.config.js
└── tests
   └── example.spec.js
└── tests-examples
    └── playwright.spec.js

tests 當中會放你的測試腳本,而 tests-examples 當中會放比較完整的範例測試腳本,這樣你可以參考一下(不過他的量有點多,所以這邊先無視他,可以把它刪掉,有興趣的話在自己看)。

另外我們可以先在 package.json 當中加入 scripts 來方便我們執行測試,這邊我們就加入一個 test 的指令。

{
  "scripts": {
    "test": "playwright test"
  }
}

範例測試腳本

接著就可以執行測試了,這邊我們就先來看一下 example.spec.js 的內容。

const { test, expect } = require('@playwright/test')

test('has title', async ({ page }) => {
  await page.goto('https://playwright.dev/')

  // Expect a title "to contain" a substring.
  await expect(page).toHaveTitle(/Playwright/)
})

test('get started link', async ({ page }) => {
  await page.goto('https://playwright.dev/')

  // Click the get started link.
  await page.getByRole('link', { name: 'Get started' }).click()

  // Expects page to have a heading with the name of Installation.
  await expect(
    page.getByRole('heading', { name: 'Installation' })
  ).toBeVisible()
})

可以看到他的範例非常簡單,這兩個測試分別做了兩件事情

  • 檢查網頁的標題是否包含 Playwright
  • 點擊 Get started 連結,並檢查是否有 Installation 的標題

這邊對於剛開始接觸測試的人(不論是不是 Playwright)或者是其實對於 HTML 不夠熟悉的人來說,可能看到 getByRole 就不知所措了,到底那個 role 應該填什麼,

以這邊 getByRole('heading', { name: 'Installation' }) 來說,他是要找一個 roleheadingnameInstallation 的元素,這邊 heading 則是代表 h1 ~ h6 這些標籤,而 name 則是標籤內的文字。

可以參考 W3C

這時可能就會想說,那這樣去找元素是不是很麻煩,所以其實我自己也不是很常用 getByRole 來找元素,而是會用 locator 來找元素,這樣比較直覺,用起來也比較方便。


執行範例測試

這邊可以先打開 playwright.config.js 來看一下,這邊可以設定一些 config,例如 timeout、測試腳本的目錄、平行執行測試相關設定、產出報告的設定、baseURL與裝置的設定等等。

而這邊我們都先以 Chrome 來做測試,所以這邊就先把 projects 留下 chromium,其他的先註解掉。

  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },

    // 註解
  ],

之後就可以執行測試了,這邊我們就直接執行 npm run test

> [email protected] test
> playwright test


Running 2 tests using 2 workers
  2 passed (7.0s)

To open last HTML report run:

  npx playwright show-report

可以看到這邊執行了兩個測試,並且都通過了。


locator 找元素

其實有很多方式可以抓到你要的元素,如同官方文件說明,但如同前面所提到的,我們可以用 locator 來找元素,這樣比較直覺,用起來也比較方便。

這邊我們可以先來看一下 locator 的用法。

page.locator(selector);
page.locator(selector, options);

其實就是丟進一個 selector 來找元素,而 selector 可以是 CSS 選擇器、XPath 選擇器 之類的東西,而 options 則是一些選項,例如包含其他的元素或文字等等。

所以這邊就可以改寫一下原本的 get started link 範例變成以下:

test('get started link', async ({ page }) => {
  await page.goto('https://playwright.dev/')

  const link = await page.locator('text=Get started')
  await link.click()

  const h1 = await page.locator('h1', { hasText: 'Installation' })
  await expect(h1).toBeVisible()
})

也可以不宣告變數,直接操作與斷言(assert)。

test('get started link', async ({ page }) => {
  await page.goto('https://playwright.dev/')

  await page.locator('text=Get started').click()

  await expect(
    await page.locator('h1', { hasText: 'Installation' })
  ).toBeVisible()
})

不過這個就看個人喜好了,我自己是比較喜歡宣告變數,這樣比較好閱讀,而且也比較好除錯。


檢查元素是否存在

有時候我們會需要檢查一個元素是否存在,這時我們可以用 locatorcount 來檢查元素的數量。

test('check element exists', async ({ page }) => {
  await page.goto('https://playwright.dev/')

  const link = await page.locator('text=Get started')
  await link.click()

  const h1 = await page.locator('h1', { hasText: 'Installation' })
  await expect(await h1.count()).toBe(1)

  const h2 = await page.locator('h2', { hasText: 'Installation' })
  await expect(await h2.count()).toBe(0)
})

但這時執行後會發現他不會通過,會看到下面的訊息:

Error: expect(received).toBe(expected) // Object.is equality

Expected: 1
Received: 0

    8 |
    9 |   const h1 = await page.locator('h1', { hasText: 'Installation' })
> 10 |   await expect(await h1.count()).toBe(1);
        |                                  ^
    11 |
    12 |   const h2 = await page.locator('h2', { hasText: 'Installation' })
    13 |   await expect(await h2.count()).toBe(0);

這時會想說奇怪,剛才執行的時候不是有看到 Installation 的標題嗎,為什麼 count 會是 0 呢?

這是因為至少在我寫文章的當下,Playwright 是一個單頁應用程式(SPA),所以當你點擊 Get started 連結後,到你執行 count 的時候,其實 Installation 的標題還沒有出現,所以 count 會是 0

這時 Playwright 提供了一個方法可以應付之後需要測 SPA 的情況,就是 await page.waitForLoadState('networkidle')

所以你可以把它加入到連結被點擊後,再執行 count 的前面。

test('check element exists', async ({ page }) => {
  await page.goto('https://playwright.dev/')

  const link = await page.locator('text=Get started')
  await link.click()

  await page.waitForLoadState('networkidle') // <-- 加入這行

  const h1 = await page.locator('h1', { hasText: 'Installation' })
  await expect(await h1.count()).toBe(1)

  const h2 = await page.locator('h2', { hasText: 'Installation' })
  await expect(await h2.count()).toBe(0)
})

這時你再執行測試,如果裝置跑比較慢,可能他又會失敗,因為畢竟要等到 networkidle 有可能會超時,所以可以在後面設定 timeout

  await page.waitForLoadState('networkidle', { timeout: 10000 })

這樣應該就會通過了。


其他檢查元素是否存在的方法

在上一個做法中是取得元素的 count 再去判斷他的數字,但也有其他方法可以做到,例如直接使用 expect 斷言來判斷元素是否出現N次,或是使用 locatorfirst 方法來取得第一個元素再加以判斷。

例如下面這些做法

const h1 = await page.locator('h1', { hasText: 'Installation' })
await expect(h1).toHaveCount(1)
await expect(h1.first()).toHaveText('Installation')

這樣也可以達到相同的效果,但不過需要注意的是對於 SPA 來說,如果只是檢查 count 還是需要加上 await page.waitForLoadState('networkidle') 來等待網頁的載入。否則你的測試有跟沒有一樣

以上面的例子來說,expect(h1).toHaveCount(1) 一定要等待 networkidle,而 expect(h1.first()).toHaveText('Installation') 則不用,因為他不只抓出 first 以外,還又多判斷了文字是否正確。

所以可以用下面的程式實驗:

const h1 = await page.locator('h1', { hasText: 'Installation' })

await expect(h1).toHaveCount(0)
await expect(h1).toHaveCount(1)

然後執行一下,你會看到居然通過了,這什麼情況,難道他在你斷言他是0之後剛好render 出來然後下一行你執行的時候他就是1了嗎?

絕對不是,不然你可以多跑幾次看看

如果真的不信邪,我 code 都幫你準備好了,你就讓他跑個 100 次吧

for (let i = 0; i < 100; i++) {
  test(`get started link - ${i}`, async ({ page }) => {
    await page.goto('https://playwright.dev/')

    const link = await page.locator('text=Get started')
    await link.click()

    const h1 = await page.locator('h1', { hasText: 'Installation' })

    await expect(h1).toHaveCount(0)
    await expect(h1).toHaveCount(1)
  })
}

所以在測試 SPA 的時候,一定要非常注意,不然你的測試就會變成有跟沒有一樣


執行 JavaScript

有時候我們會需要執行一些 JavaScript 來操作網頁,這時我們可以使用 page.evaluate 來執行 JavaScript

不管你是要讓網頁變成你要的狀態,或是要取得一些資料,都可以使用這個方法。

例如下面這個例子,我們可以用 page.evaluate 來取得網頁的 title

test('get title', async ({ page }) => {
  await page.goto('https://playwright.dev/')

  const title = await page.evaluate(() => document.title)
  await expect(title).toBe(
    'Fast and reliable end-to-end testing for modern web apps | Playwright'
  )
})

這樣就可以取得網頁的 title 了,雖然取得 title 這個例子有點多餘,因為 Playwright 本身就有提供讓你拿到 title 的方法,但這邊只是為了示範 page.evaluate 的用法。

但這時執行後有機率會失敗,因為至少在寫文章的當下,他的官方網站是 SPA,所以在 goto 的時候也需要讓他等一下,而 goto 有提供 options 讓你直接設定。

await page.goto('https://playwright.dev/', {
    waitUntil: 'networkidle',
})

另外 page.evaluate 也可以讓你對網頁做一些操作,例如下面這個例子,我們可以用 page.evaluate 來對網頁做 scroll

test('scroll to bottom', async ({ page }) => {
  await page.goto('https://playwright.dev/', {
    waitUntil: 'networkidle',
  })

  await page.evaluate(() => {
    window.scrollTo(0, document.body.scrollHeight)
  })

  await page.screenshot({ path: 'screenshot.png' })
})

最後一行我加上 screenshot 來截圖,這樣你就可以看到他有沒有真的 scroll 到底部。

當然你也可以動態去對 DOM 做操作,例如我插入一個 <style> 標籤來改變網頁的樣式。

test('change style', async ({ page }) => {
  await page.goto('https://playwright.dev/', {
    waitUntil: 'networkidle',
  })

  await page.evaluate(() => {
    const style = document.createElement('style')
    style.textContent = `
      body {
        background-color: #f99;
      }
    `
    document.head.appendChild(style)
  })

  await page.screenshot({ path: 'screenshot.png' })
})

這麼做就有很大的彈性,不過要注意的是,你在 page.evaluate 中的 JavaScript 是在網頁的 context 中執行的,所以你不能直接使用外部的變數,如果你要使用外部的變數,你必須要傳入給他,而且裡面因為是在網頁的 context 執行,所以裡面的 console.log 也不會顯示在你的 terminal 中,在 debug 的時候要注意。

  await page.goto('https://playwright.dev/', {
    waitUntil: 'networkidle',
  })

  const text = 'Hello World'
  await page.evaluate((text) => {
    const div = document.createElement('div')
    div.textContent = text
    div.style.color = 'red'
    div.style.fontSize = '48px'
    document.body.appendChild(div)

    window.scrollTo(0, document.body.scrollHeight)
  }, text)

  await page.screenshot({ path: 'screenshot.png' })

這時你應該會看到網頁底部多了一個 Hello World 的紅色文字。


畫面比對

剛才使用到了 screenshot 來截圖,這時就會需要一個畫面比對的功能。

Playwright 有提供 Visual comparisons 功能,來做畫面的比對

這邊可以先做一點小的測試,來試試看怎麼使用他的畫面比對功能。

import { test, expect } from '@playwright/test'

test('example test', async ({ page }) => {
  await page.goto('https://playwright.dev')
  await expect(page).toHaveScreenshot()
})

之後一樣執行 npm run test,這時第一次執行測試會失敗並產生一張 screenshot,到你的 example.spec.js-snapshots 目錄下,而檔名是 example-test-1-chromium-win32.png

這邊目錄和檔名都是有規律的,例如目錄名稱其實就是你的測試腳本名稱再加上-snapshots,而檔名預設則是 測試標題-{screenshot次數}-裝置-作業系統.png 等。

當他存在的時候,你再次執行測試,這時就會拿這張 screenshot 來做比對,如果不一樣就會失敗,如果一樣就會通過。

如果想要更新 screenshot,可以使用 --update-snapshots 來更新。

npx playwright test --update-snapshots

如果你使用 npm 的話,想要在指令後面加上 --update-snapshots 的話,可以在 package.jsonscripts 中加上 --update-snapshots

{
  "scripts": {
    "test": "playwright test",
    "test:update": "playwright test --update-snapshots"
  }
}

或是懶惰一些(?),直接在 -- 後面加上參數。

npm run test -- --update-snapshots

如果是 pnpm 的話則可以直接在指令後面加上 --update-snapshots

pnpm test --update-snapshots

而預設的比對是要完全符合,你有一些方式可以設定一點容錯率:

  • maxDiffPixels: 最多能有多少像素差異
  • maxDiffPixelRatio: 最多能有多少像素比例差異
  • threshold: 一個介於 0 ~ 1 之間的數字,代表色彩的容錯率,0 代表顏色要完全相同,1 代表完全不同,基本上你設定成 1 的話就是不比對了。

前兩個都很直覺,就是兩張圖的像素差異,只是一個是絕對值,一個是比例,而 threshold 可以用下面的來實驗幫助理解。

await page.goto('http://google.com')
await expect(page).toHaveScreenshot()

先刻意用預設值來比對,然後你可以在目錄下找到 test-results,裡面會有這個快照比對的三種圖檔,分別是 diffexpectedactual,對應到 diff 就是兩張圖的差異,expected 就是你的預期圖,actual 就是實際的圖。

點進去 diff 會發現非常的不同,有很多紅色(差異)的地方,這時你在把 threshold 設定成 1,再執行一次,這時你會發現 diff 的圖檔幾乎是灰色的(pass),可以執行下面的程式碼看看。

await expect(page).toHaveScreenshot({ threshold: 1 })

然後最後再將 threshold 設定成 0.85,這時你會發現 diff 只剩下一點點的紅色。

await expect(page).toHaveScreenshot({ threshold: 0.85 })

然後也會看到下面的錯誤訊息

Error: Screenshot comparison failed:

619 pixels (ratio 0.01 of all image pixels) are different.

代表只剩下 619 個像素不同,也就是 0.01% 的比例不同,就可以在與前兩個參數一起使用,來達到你想要的比對。

例如你可以再將 maxDiffPixelRatio 設為 0.02 這樣他就會通過了。

await expect(page).toHaveScreenshot({
  threshold: 0.85,
  maxDiffPixelRatio: 0.02,
})

而這邊也稍微提一下他是怎麼實作的,他是用 pixelmatch 這個套件來做比對的,所以可以參考一下 pixelmatch文件,如果哪天需要單獨使用的話也許有幫助。

他主要實作了兩篇論文,一篇用在 threshold 感知色差,一篇用在比對像素,有興趣的話可以參考一下。


結語

這一篇主要是紀錄了一些 Playwright 的基本用法,還有很多相關的語法可以用,等到發現上面那些語法湊不出要測的功能的時候,就可以再去翻一下官方文件。

其實還有一些也是基本操作,例如一些生命週期的 hook,或是測試群組(describe)等等,但這滿好理解的而且就算不使用也不會有太大的問題,所以這邊就不先做介紹,等之後下一篇之後用到的時候再做介紹。

接著下一篇就會把整個 speed-test 的測試腳本寫出來,並且附上我修改過的題目。




最後更新時間: 2024年05月04日.