Playwright 實踐 - 以技能競賽題目為例 - 1

tags: JavaScript Playwright Testing End-to-End
category: Front-End
description: Playwright 實踐 - 以技能競賽題目為例 - 1
created_at: 2024/05/07 07:00:00

cover image


回到 Playwright 測試筆記


前言

這一篇開始會是一系列的範例分享,這篇要測試的專案是 52屆技能競賽的速度模組(speed-test),也就是下面這份題目。

而專案的成品就不附上了,就如同最前面的前言所提到的,有興趣的的話可以自己練手,是滿不錯的練習,然後最後會在附上我修改過的題目來符合我的測試腳本,這樣就可以拿來練習了(?)。

可能有些地方有更好的做法或是我漏掉沒有注意到的地方,歡迎可以跟我一起討論。(畢竟眾人的智慧勝過一個人的智慧


開始寫之前

先來簡介一下生命週期(Hook),例如 beforeAllbeforeEachafterAllafterEach,這些都是在測試前後會執行的生命週期,這樣可以讓我們在測試前後做一些事情,例如 beforeAll 可以用來初始化測試環境,afterAll 可以用來清理測試環境,beforeEach 可以用來初始化測試資料,afterEach 可以用來清理測試資料。

而下面我們每一題都至少會有一個 beforeEach 去幫忙轉到該題目的頁面,這樣就不用寫在每一個測試中了,像是下面這樣:

test.beforeEach(async ({ page }) => {
    await page.goto('http://localhost:3000/01/Task01.html');
});

不過這邊我們可以再去修改 playwright.config.js,去設定 baseURL,這樣就不用在每一個 goto 中都寫前面長長的一串網址了,像是下面這樣:

export default defineConfig({
  // 略
  use: {
    baseURL: 'http://localhost:8000', // 假設我們專案的網址是這個
    // ...
  },
  // 略
});

這樣就可以在每一個 goto 中只寫 /task01.html 就好了,像是下面這樣:

test.beforeEach(async ({ page }) => {
  await page.goto('/01/Task01.html')
})

設定好之後就開始寫我們的測試吧。


第一題: CSS Grid

這題很單純,就是要你用 Grid 來排版,要長得跟他一樣,這邊其實有個做法,就是將題目修改的很死,例如強迫你每個區域設置的寬高,但是這樣基本上就是把答案給選手了,而且彈性也不夠,會變得很無趣,所以這邊我就不會這樣做。

所以為了保有彈性我只在題目上新增你的容器大小、間隙(gap)大小,以及各區塊的顏色,這樣就可以讓選手自己去設計了。

然而測試程式就只要檢查以下

  • 不可以使用 img 標籤來作弊
    test('check img tag', async ({ page }) => {
      const images = await page.locator('img').count()
      expect(images, { message: '你不可以使用 <img> 標籤' }).toBe(0)
    })
  • .container 必須是 display: grid
    test('check use grid layout', async ({ page }) => {
      const container = await page.locator('.container')
      const containerStyle = await container.evaluate((node) => {
        return window.getComputedStyle(node)
      })
      expect(containerStyle, { message: '你必須使用 grid 來排版' }).toMatchObject(
        {
          display: 'grid',
        }
      )
    })
  • 直接使用 screenshot 來檢查排版是否正確
    test('layout snapshot', async ({ page }) => {
      expect(await page.locator('.container').screenshot(), {
        message: 'Layout 不正確',
      }).toMatchSnapshot('01-answer.png')
    })

第二題: Loading 動畫

這題是要使用 HTMLCSS 來做一個 Loading 動畫,不可以使用 JavaScript,動畫長得像下面這樣:

loading

這題在一開始我寫的時候想了一陣子,後來想到一個方式可以檢查動畫是否正確(後面檢查動畫的部分都是一樣的邏輯),步驟如下

  1. 先使用 javascript 去暫停動畫
  2. 再使用 javascript 去設定動畫的時間
  3. 最後再使用 screenshot 去檢查在特定時間點的動畫是否正確

依照上面的步驟,所以題目就需要定義動畫的時間,所以此題的題目新增了一些限制:

  • 動畫應呈現在畫面正中央
  • 動畫的圓圈的大小為 50px
  • 動畫各圈圈之間的間距約為 15px
  • 動畫的速度為 2s 並重複循環

timing-function 的部分也可以附給選手,不過我想說既然都附上影片了,應該要能夠判斷出使用的是什麼 timing-function (畢竟是內建的,也不是特別調參過)

接著此時先建立一個 utils.js 裡面放著一些常用的函數,例如此題就可以多定義兩個函數,分別是:

  • pauseAnimation(page): 用來將頁面中所有動畫暫停
  • setAnimationTime(page, time, selector='*'): 用來設定動畫的時間,可以設定特定的 selector,預設是所有的動畫
export async function pauseAnimation(page) {
  return await page.evaluate(() => {
    const style = document.createElement('style')
    style.innerHTML = `
        * {
          animation-play-state: paused !important;
        }
      `
    document.body.appendChild(style)
  })
}

export async function setAnimationTime(page, time, selector = '*') {
  return await page.evaluate(
    ({ time, selector }) => {
      const elements = document.querySelectorAll(selector)
      elements.forEach((el) => {
        el.getAnimations().forEach((animation) => {
          animation.currentTime = time
        })
      })
    },
    { time, selector }
  )
}

接著就可以開始寫測試了:

  • 先禁用 JavaScript,可以使用下面這一行
test.use({ javaScriptEnabled: false })
  • 接著跟第一題一樣,避免作弊,所以要檢查是否有使用 img 標籤
  test('check img tag', async ({ page }) => {
    const images = await page.locator('img').count()
    expect(images, { message: '你不可以使用 <img> 標籤' }).toBe(0)
  })
  • 檢查初始的畫面,所以將動畫暫停,並將動畫時間設為零

    toMatchSnapshot 可以使用第一個參數去指定生成的圖檔的檔名,這個下一個測試會用很兇

  test('init element UI', async ({ page }) => {
    await pauseAnimation(page)
    await setAnimationTime(page, 0)

    expect(await page.locator('body').screenshot({}), {
      message: '初始化畫面不正確',
    }).toMatchSnapshot('02-0-answer.png', { maxDiffPixelRatio: 0.01 })
  })
  • 開始檢查每個時間點的畫面
  test('test animation', async ({ page }) => {
    await pauseAnimation(page)

    for (let time = 0; time <= 2000; time += 100) {
      await setAnimationTime(page, time)
      expect(await page.locator('body').screenshot(), {
        message: '動畫不正確',
      }).toMatchSnapshot(`02-${time}-answer.png`, { maxDiffPixelRatio: 0.01 })
    }
  })

而這邊因為對整個 pagescreenshot,所以我在 beforeEach 的地方多了下面這一段,簡單的將 marginpadding 設為 0,來增加比對的一致性,因為如果截出來的大小不對(哪怕只差1px)也會造成測試失敗。

  test.beforeEach(async ({ page }) => {
    await page.evaluate(() => {
      const style = document.createElement('style')
      style.innerHTML = `
        body {
          margin: 0;
          padding: 0;
        }
        `

      document.head.appendChild(style)
    })

    await page.goto('/02/Task02.html')
  })

第三題: 簡易計算機

這題是最像傳統程式設計的題目,只是多了網頁 UI 的部分,所以題目上基本上就是多了每一個按鈕的 id 定義,接著就是測試計算結果正不正確。

所以測試可以分成兩個部分:

  • 檢查元素是否都在

    test('check selector', async ({ page }) => {
      const selectors = [
        '#btn-0',
        '#btn-1',
        '#btn-2',
        '#btn-3',
        '#btn-4',
        '#btn-5',
        '#btn-6',
        '#btn-7',
        '#btn-8',
        '#btn-9',
        '#btn-dot',
        '#btn-clear',
        '#btn-add',
        '#btn-sub',
        '#btn-mul',
        '#btn-div',
        '#btn-calc',
        '#result',
      ]
    
      for (const selector of selectors) {
        expect(await page.locator(selector).count(), {
          message: `找不到 ${selector}`,
        }).toBe(1)
      }
    })
  • 接著就是一系列的操作,然後檢查結果是否正確,這邊就只截錄一些,因為大同小異

    test('init result value', async ({ page }) => {
      expect(await page.locator('#result').innerText(), {
        message: '初始化結果不正確',
      }).toBe('0')
    })
    
    test('click button', async ({ page }) => {
      await page.locator('#btn-1').click()
      await page.locator('#btn-2').click()
      await page.locator('#btn-3').click()
      expect(await page.locator('#result').innerText(), {
        message: '按鈕點擊不正確',
      }).toBe('123')
    })
    
    test('click add button', async ({ page }) => {
      await page.locator('#btn-1').click()
      await page.locator('#btn-add').click()
      await page.locator('#btn-2').click()
      await page.locator('#btn-calc').click()
      expect(await page.locator('#result').innerText(), {
        message: '加法不正確',
      }).toBe('3')
    })

第四題: 圖片裁剪工具

這一題是使用 JavaScriptCanvas,這題的題目是要做一個簡易的圖片裁剪工具,可以選擇圖片,然後選擇裁剪的範圍後按下 裁剪 按鈕,最後還可以下載裁剪後的圖片。

這一題的話基本上題目也是多了一些 id 的定義,然後就是一系列的操作,最後檢查是否有正確的下載。

而這題比較特別的是選取裁剪範圍時要使用虛線,後來也是思考了一陣子才想到一個解法,因為我不希望將題目寫得太死,去規定虛線要怎麼產生,如果規定了就可以使用 screenshot 輕鬆做掉(前提是選手照著你的題意走)

所以這邊判斷虛線的方式是我測試時真的去框一個區域,接著使用 canvas 去取得 imagedata 判斷我框的那一條是不是有多餘一種顏色,如果有就代表是虛線,否則就是實線。

所以這一題基本上就兩個測試(合併了滿多操作,要拆開測也可以):

第一個是檢查是否使用虛線

  test('check dashed line', async ({ page }, testInfo) => {
    // upload image
    const input = await page.locator('#file-input')
    await input.setInputFiles('./images/dog.jpg')

    await page.locator('#submit-btn').click()

    // check preview
    const preview = await page.locator('#preview')
    expect(await preview.screenshot(), {
      message: '預覽不正確',
    }).toMatchSnapshot('04-1-answer.png', { maxDiffPixelRatio: 0.01 })
    const originalImageData = await page.evaluate(() => {
      const canvas = document.querySelector('#preview')
      const ctx = canvas.getContext('2d')

      return ctx.getImageData(10, 10, 310, 1)
    })

    // crop area
    await preview.dragTo(preview, {
      sourcePosition: { x: 10, y: 10 },
      targetPosition: { x: 320, y: 290 },
    })

    const imageData = await page.evaluate(() => {
      const canvas = document.querySelector('#preview')
      const ctx = canvas.getContext('2d')

      return ctx.getImageData(10, 10, 310, 1)
    })

    let diff = 0
    const dataLength = Object.values(imageData.data).length
    for (let i = 0; i < dataLength; i += 4) {
      if (
        imageData.data[i] !== originalImageData.data[i] ||
        imageData.data[i + 1] !== originalImageData.data[i + 1] ||
        imageData.data[i + 2] !== originalImageData.data[i + 2] ||
        imageData.data[i + 3] !== originalImageData.data[i + 3]
      ) {
        diff++
      }
    }

    try {
      expect(diff === dataLength / 4, {
        message: '裁切預覽虛線設置不正確',
      }).toBeFalsy()
    } catch (e) {
      const screenshot = await preview.screenshot({
        type: 'jpeg',
        quality: 70,
      })
      await testInfo.attach('screenshot', {
        contentType: 'image/jpeg',
        body: screenshot,
      })
      throw e
    }
  })

這邊比較需要說明的是 testInfoattach 方法,這個方法可以將一些東西(例如檔案或文字)附加到測試結果中,例如我讓他失敗的時候把截圖附上,這樣就可以在測試報告中看到你的截圖。

接著就是檢查 happy path,也就是正常的操作流程

  test('happy path', async ({ page }) => {
    // upload image
    const input = await page.locator('#file-input')
    await input.setInputFiles('./images/dog.jpg')

    await page.locator('#submit-btn').click()

    // check preview
    const preview = await page.locator('#preview')
    expect(await preview.screenshot(), {
      message: '預覽不正確',
    }).toMatchSnapshot('04-1-answer.png', { maxDiffPixelRatio: 0.01 })

    // crop area
    await preview.dragTo(preview, {
      sourcePosition: { x: 50, y: 50 },
      targetPosition: { x: 150, y: 150 },
    })

    expect(await preview.screenshot(), {
      message: '裁切預覽不正確',
    }).toMatchSnapshot('04-2-answer.png', { maxDiffPixelRatio: 0.01 })

    // crop image
    await page.locator('#crop-btn').click()
    expect(await preview.screenshot(), {
      message: '裁切不正確',
    }).toMatchSnapshot('04-3-answer.png', { maxDiffPixelRatio: 0.01 })

    // download image
    const downloadPromise = page.waitForEvent('download')
    await page.locator('#download-btn').click()
    const download = await downloadPromise
    const downloadPath = download.suggestedFilename()
    expect(downloadPath).toBe('crop_dog.jpg')
  })

這邊需要提到的是 waitForEvent,這個方法可以等待一個事件,這邊就是等待 download 事件,這樣就可以等到下載完成後再去檢查檔名。


第五題: 資料視覺化

這題是上傳一個 csv 檔案後要繪製出對應的圖表,而這題我對題目的更改也不多

  • 指定畫布大小
  • 指定各個元素的 id 的定義

這題的測試其實也滿像傳統程式設計的,因為我只要提供選手一種測試資料,然後在真的跑測試時丟入另一組測試資料後檢查圖表是否長的跟我預先產生的答案一致即可。

所以基本上測試只有一個 happy path,不過為了避免選手使用在畫圖表途中有動畫,所以先包一個函數去等待,直到畫面不再更新。

export async function waitScreenshotUpdated(page, selector) {
  const bufferEqual = (a, b) => {
    if (a.length !== b.length) return false

    for (let i = 0; i < a.length; i++) {
      if (a[i] !== b[i]) return false
    }

    return true
  }

  let screenshot
  do {
    screenshot = await page.locator(selector).screenshot()
  } while (!bufferEqual(await page.locator(selector).screenshot(), screenshot))
}

接著就可以開始寫 happy path 的測試了

  test('happy path', async ({ page }) => {
    // upload csv file
    const input = await page.locator('#file-input')
    await input.setInputFiles('./data/05.csv')

    // wait for chart
    await waitScreenshotUpdated(page, '#chart')

    expect(await page.locator('#chart').screenshot(), {
      message: '圖表不正確',
    }).toMatchSnapshot('05-1-answer.png')
  })

第六題: PHP驗證碼

這題其實我還沒有想好一個漂亮的方法,因為這題要產生圖片,所以這題我先刪除了一些題目要求

  • 各別字元要隨機旋轉不同角度
  • 各別字元不可以在同一水平上
  • 至少要包含3條隨機線條

因為驗證碼的圖片雜訊可以用的非常複雜,就算今天整合如 OpenCV 之類的我想也是會弄得很複雜,所以如果目的是考驗產生圖片的話,可能就必須把題目簡單化,而這邊為了簡化,就沒有整合 OpenCV 之類的去做檢查,而是只單純檢查 header

另外再加上一些限制給題目,如下:

  • 驗證碼必須儲存在 session 中,且 keyans
  • 06-captcha.php 負責產生驗證碼與圖片檔
  • 各元素必須有相應的 id 設定

前兩點非常重要,如果沒有的話就真的反而是在考驗寫測試腳本的人影像辨識的能力了,這樣就本末倒置了。

只要有了第一點定義,在 Online-Judge 上我就可以在評分時偷偷塞一個檔案到你的專案中,然後在我測試時去打那個檔案來取得驗證碼的答案,進而檢查答案格式與判斷是否正確。

而在單純 local 跑的話,就只能自己在 06 這個資料夾中放進我偷偷塞進的檔案在去跑評分了,需要的檔案很簡單,只要把 session 的值拿出來就可以。

hack-answer.php

<?php session_start(); $ans = $_SESSION['ans']; echo $ans;

這樣我就可以在測試當中去透過 hack-answer.php 去取得答案,然後接著做檢查。

首先可以先寫一個函數去取得驗證碼的答案

  const getCaptchaAnswer = async (context) => {
    const page = await context.newPage()
    await page.goto('/06/hack-answer.php')

    const answer = await page.locator('body').innerText()
    await page.close()
    return answer
  }

context.newPage() 可以取得一個新的 page,這樣就可以在新的 page 中去做一些事情,例如這邊就是去取得答案,未來如果有測試需要開啟多個分頁時就可以使用這個方法。

接著是先檢查產生的驗證碼是否符合題目要的格式:

  test('check answer format', async ({ page, context }) => {
    for (let i = 0; i < 10; i++) {
      await page.goto('/06/06-captcha.php')
      const answer = await getCaptchaAnswer(context)
      expect(answer, { message: '驗證碼格式不正確' }).toMatch(/^[2-9A-Z]{4}$/)
    }
  })

再來是正確與錯誤時的案例

  test('correct case', async ({ page, context }) => {
    const answer = await getCaptchaAnswer(context)
    const captchInput = await page.locator('#captcha-input')
    await captchInput.fill(answer)
    const submitButton = await page.locator('#submit-btn')
    await submitButton.click()

    const result = await page.locator('#result').innerText()
    expect(result, { message: '驗證結果不正確' }).toBe('成功')
    expect(await page.locator('#result').screenshot(), {
      message: '結果樣式不正確',
    }).toMatchSnapshot('06-1-answer.png')
  })

  test('wrong case', async ({ page }) => {
    const captchInput = await page.locator('#captcha-input')
    await captchInput.fill('123')
    const submitButton = await page.locator('#submit-btn')
    await submitButton.click()

    const result = await page.locator('#result').innerText()
    expect(result, { message: '驗證結果不正確' }).toBe('失敗')
    expect(await page.locator('#result').screenshot(), {
      message: '結果樣式不正確',
    }).toMatchSnapshot('06-2-answer.png')
  })

還有刷新驗證碼:

  test('refresh captcha', async ({ page, context }) => {
    const oldAnswer = await getCaptchaAnswer(context)
    const refreshButton = await page.locator('#refresh-btn')
    await refreshButton.click()
    const newAnswer = await getCaptchaAnswer(context)
    expect(oldAnswer, { message: '驗證碼沒有刷新' }).not.toBe(newAnswer)
  })

最後是檢查驗證碼圖片的 header:

  test('check 06-captcha.php file header', async ({ request }) => {
    const response = await request.get('/06/06-captcha.php')
    expect(response.status()).toBe(200)
    expect(response.headers()['content-type']).toMatch(/image\/jpeg|image\/jpg/)
  })

這裡用到了 request ,這個是 playwright 提供的一個 API,可以用來發送 http 請求,這樣就可以去檢查 header 了。


第七題: 圖片防盜連

其實這一題我認為應該做在 server 的部分,但因為比賽限制,只能使用 php 稍微模擬一下,所以檢查的部分就很單純,就不去直接訪問圖片了,如下:

  • happy path
    test('happy path', async ({ page }) => {
      const img = await page.locator('img')
      expect(await img.getAttribute('src'), { message: 'src 不正確' }).toContain(
        'index.php'
      )
      expect(await img.screenshot(), { message: '圖片不正確' }).toMatchSnapshot(
        '07-1-answer.png'
      )
    })
  • 403
    test('check index.php 403', async ({ request }) => {
      const response = await request.get('/07/index.php')
      expect(response.status()).toBe(403)
    })

第八題: 跑道動畫

其實這題就是畫 8 這個數字,題目本來是橫向直向都可以,但為了簡化,我留下直向的動畫,如果要判斷兩個的話也可以,只是要多寫一點測試。

這題的動畫大概長得像下面這樣:

8

而題目上我多加了一些條件,來讓我的測試可以符合:

  • 圓圈大小為 20px,顏色為 #333
  • 8的圓形直徑為 200px
  • 動畫應呈現在畫面正中央
  • 整個動畫應該5秒鐘完成並重複循環

這樣的話就可以使用 screenshot 來檢查動畫是否正確,如下:

  test('test animation', async ({ page }) => {
    test.slow()
    await pauseAnimation(page)

    for (let time = 0; time <= 5000; time += 100) {
      await setAnimationTime(page, time)
      expect(await page.locator('body').screenshot(), {
        message: '動畫不正確',
      }).toMatchSnapshot(`08-${time}-answer.png`, { maxDiffPixelRatio: 0 })
    }
  })

其他的就不列了(例如禁止使用 img 標籤),因為基本上都是一樣的。

這裡比較特別的是使用的 test.slow(),這個方法可以讓 timeout 的設定變得比較寬鬆(變為三倍,原本設定是 10s,變成 30s),避免因為檢查的時間太長而導致測試失敗。


第九題: 七段顯示器

這題是做一個碼表,這題雖然不是內建的 CSS 動畫,但實際上也是和動畫一樣的測試方式,所以這邊的條件限制會稍微多一些,畢竟要長的幾乎一樣。

所以我對題目加了下面這一些要求

  • 整個七段顯示器被包在一個 id = display-containerDOM 中,並且大小為400px * 200px,顏色為 #333
  • 單一數字的大小為 100px * 190px,各線段粗細為10px,長度為80px,且在容器中垂直置中
  • 單一冒號的大小為 15px * 15px 且上下分散對齊
  • 開始按鈕 id = start-button
  • 暫停按鈕 id = stop-button
  • 歸零按紐 id = clear-button
  • 計時時間需正確,所以請使用 performance.now() 函數來算出正確時間

雖然限制看起來有點多,但又不會像是第一題的 grid 一樣直接把答案給選手,因為你還是得自己排出來,只是要符合這些限制而已。 (而且這題還要會動)

這題測試的方式會比較暴力一點,因為他實際上只有10秒內的動畫,所以我預先產生了所有可能的圖片(1000張),接著在根據對應的時間和圖片去檢查是否正確。

所以我先在我的答案上可以多吃一個 msGET 參數,這樣我的畫面就會產生對應時間點的畫面,在讓測試程式存起來,如下:

  test('generate all snapshots', async ({ page }) => {
    test.setTimeout(0)
    for (let ms = 0; ms <= 999; ms++) {
      await page.goto(`/09/index.html?ms=${ms}`)
      const container = await page.locator('#display-container')
      expect(await container.screenshot(), {
        message: `ms=${ms} 時不正確`,
      }).toMatchSnapshot(`09-${ms}-answer.png`, { maxDiffPixelRatio: 0.01 })
    }
  })

這裡比較特別的是 test.setTimeout(0) ,這個方法可以讓 timeout 變成無限,這樣就不會因為時間太長而導致測試失敗,但是平常不要這樣使用,因為如果出問題他會永遠不會結束,如果在一般的測試需要延長時間請使用 test.slow(),除非你很清楚你在做什麼。

當產生好答案的圖片後,現在因為我們要測試時間,多了一個時間的因素,會讓測試變得比較複雜,我這邊打算的解法是因為題目已經指定使用 performance.now(),所以我就覆寫 performance.now,讓他在選手使用時可以得到我想要的時間,這樣就可以避免時間差的問題。

setInterval 之類的函數不用處理,因為這些函數的時間本來就會不準確,所以我就不考慮這種情況,但這其實在人工評分時可能檢查不出來,剛好使用測是腳本的話就能夠解決這個問題。

所以現在的策略大概是這樣:

  • 先將 performance.now 覆寫為 0
  • 點擊開始按鈕
  • performance.now 覆寫為我想要的時間
  • 點擊暫停按鈕
  • 復原 performance.now (這個很重要)
  • 比對圖片是否正確

所以大概會得出下面這段程式:

  const testWithMs = async (page, ms) => {
    // 進入網頁,這很重要,因為之後要測每個時間點
    await page.goto('/09/index.html')

    const container = await page.locator('#display-container')
    const startButton = await page.locator('#start-button')
    const stopButton = await page.locator('#stop-button')

    // 先將 performance.now 覆寫為 0,並取得原本的 performance.now
    const handler = await page.evaluateHandle(() => {
      const originalNow = window.performance.now.bind(performance)
      performance.now = () => {
        return 0
      }
      return originalNow
    })

    // 點擊開始按鈕
    await startButton.click()

    // 將 performance.now 覆寫為我想要的時間
    await page.evaluate((ms) => {
      performance.now = () => {
        return ms
      }
    }, ms)

    // 點擊暫停按鈕
    await stopButton.click()

    // 復原 performance.now
    await page.evaluate((handler) => {
      performance.now = handler
    }, handler)

    // 比對圖片是否正確
    const duration = Math.min(Math.floor(ms / 10), 999)
    const snapshot = `09-${duration}-answer.png`
    expect(await container.screenshot(), {
      message: `ms=${duration} 時不正確`,
    }).toMatchSnapshot(snapshot)
  }

這邊第一次出現了 evaluateHandle,這個方法可以取得一個 JSHandle,這個 JSHandle 可以在 evaluate 相關函數中使用,或是在 playwright 環境中使用 .jsonValue() 取得他的值,這樣就可以在 evaluate 中使用外部的變數。

舉例來說你可以這樣用:

  const handle = await page.evaluateHandle(() => {
    return { a: 1, b: 2 }
  })

  const value = await handle.jsonValue()
  console.log(value) // { a: 1, b: 2 }

然後前面 testWithMs 只是將 performance.now 先拿出來,然後在最後的 evaluate 將他復原回去。

而為什麼一定要將 performance.now 復原呢? 因為如果不復原的話,其實選手不寫 stop 按鈕也會對,畢竟你覆寫的 performance.now 會一直是你想要的時間,所以這樣就會造成測試失效,所以一定要復原。

接著就是寫一個測試來測試所有的時間點:(以下都先暫時將 timeout 設為無限做實驗)

  test('test all time', async ({ page }) => {
    test.setTimeout(0)
    for (let i = 0; i <= 1000; i += 1) {
      await testWithMs(page, i * 10)
    }
  })

這樣一個一個測可能太慢,所以可以使用 Promise.all 來加速:

為了避免出意外,先讓他跑100就好

  test('test all time', async ({ page, context }) => {
    test.setTimeout(0)
    const pages = await Promise.all(
      Array(100 + 1)
        .fill(0)
        .map(() => context.newPage())
    )

    const promises = Array(100 + 1)
      .fill(0)
      .map((_, i) => testWithMs(pages[i], i * 10))

    await Promise.all(promises)
  })

或者如果你喜歡也可以整合成一行:

    await Promise.all(
      Array(100 + 1)
        .fill(0)
        .map(() => context.newPage())
    ).then((pages) =>
      Promise.all(pages.map((page, i) => testWithMs(page, i * 10)))
    )

但上面這樣同時跑 100 個是不太切實際的,因為你這樣可能會讓 CPU 跑滿(100不會的話你可以改1000或更高),而且設太高實際上也只是浪費,你的電腦不太可能同時並發處理這麼多事情,所以設太高你的效能並不見得會提升,說不定還會讓你的電腦當機,所以下面我們把它拆成多個批次來執行。

  test('test all time', async ({ page, context }) => {
    test.setTimeout(0)

    const pages = await Promise.all(
      Array(10)
        .fill(0)
        .map(() => context.newPage())
    )

    for (let i = 0; i < 100; i++) {
      const promises = []
      for (let j = 0; j < 10; j++) {
        const ms = i * 10 + j
        promises.push(testWithMs(pages[j], ms * 10))
      }
      await Promise.all(promises)
    }
  })

上面這樣確實能動,但是這樣會讓你的測試跑很久,這時可能就得做一點取捨,思考一下有必要從 0 檢查到 999 嗎? 還是其實我只要檢查每一位數字是否正確就好,這樣就可以減少很多時間。 例如像是只檢查下面這些顯示結果

0
111
222
333
444
555
666
777
888
999

這樣至少確保他每一位的渲染都是正確的,然後再抽點幾個時間點來檢查和邊界條件來檢查(這兩個可以相當於一起測到),這樣就可以減少很多時間。這邊指的邊界條件不是指頭尾,而是毫秒是否無條件捨去,這樣的計時器也比較合理。畢竟不會有計時器跟你四捨五入對吧,所以這時就剩下下面這些測試:

  • 檢查上面的數字
  • 檢查毫秒的邊界條件(是否無條件捨去)
  • 檢查超出 999 的時間點,是否維持在 999
  • 檢查各按鈕功能

根據前兩項,可以寫出下面的測試:

  test('test 111, 222, ..., 999', async ({ page }) => {
    for (let i = 111; i <= 999; i += 111) {
      await testWithMs(page, i * 10)
    }
  })

  test('test boundary case', async ({ page }) => {
    const testcases = [
      12, 123, 1234, 2345, 3456, 4567, 5678, 6789, 7891, 8901, 9012,
    ]
    for (const ms of testcases) {
      await testWithMs(page, ms)
    }
  })

接著再來檢查超出 999 的時間點是否維持在 999,這邊需要特別做處理,不能直接使用剛才定義的函數並給予超過的時間,因為強制設定成 10000 的話,可能會誤判選手的程式,因為選手可能不是判斷 ms 不能超過 999,而是當他到的時候停止計時器,如果強制給他超過的時間,就有可能導致選手有做,人工評分通過,但測試卻失敗的情況。

所以這邊先讓 testWithMs 幫我們將畫面先停留在 998,然後再讓他執行一小段時間後檢查是否畫面上為 999,這樣也可以順便檢查到暫停後再次開始的情境。

  test('test overflow case', async ({ page }) => {
    await testWithMs(page, 9980)

    const container = await page.locator('#display-container')
    const startButton = await page.locator('#start-button')
    await startButton.click()
    await page.waitForTimeout(100)

    const snapshot = `09-999-answer.png`

    expect(await container.screenshot(), {
      message: `ms超過999時不正確`,
    }).toMatchSnapshot(snapshot)
  })

最後還要測試 clear 按鈕

  test('test clear', async ({ page }) => {
    const container = await page.locator('#display-container')
    const startButton = await page.locator('#start-button')
    const stopButton = await page.locator('#stop-button')
    const clearButton = await page.locator('#clear-button')

    await startButton.click()
    await page.waitForTimeout(1000)
    await stopButton.click()

    await clearButton.click()
    expect(await container.screenshot(), {
      message: '清除不正確',
    }).toMatchSnapshot('09-0-answer.png')
  })

如果前面的測試正確,理論上他開始一段時間後正常停止,再清除後應該是顯示0


第十題: 原生路由

這題主要是要考選手內建的 history API,去實作一個 SPA 的路由,這題基本上改的不多,只是限制連結要設定的文字。

所以這題要測試的項目如下:

  • 檢查所有連結都存在且標籤設定正確
    test('init selectors on every page', async ({ page }) => {
      const pages = ['a.html', 'b.html', 'c.html']
      const links = ['a', 'b', 'c']
      for (let i = 0; i < pages.length; i++) {
        await page.goto(`/10/${pages[i]}`)
        const link = await page.locator(`text=${links[i]}`)
        expect(await link.count(), { message: `找不到 ${links[i]}` }).toBe(1)
        expect(await link.evaluate((e) => e.tagName), {
          message: '標籤錯誤',
        }).toBe('A')
      }
    })
  • 確定三個檔案都可以訪問(200 ok)
    test('check every page is 200 ok', async ({ request }) => {
      const pages = ['a.html', 'b.html', 'c.html']
      for (const pageName of pages) {
        const response = await request.get(`/10/${pageName}`)
        expect(response.status(), { message: `${pageName} 頁面錯誤` }).toBe(200)
      }
    })
  • 對每個連結做測試,不能有重整頁面的情況

針對每個連結做測試,可以抽離出一個函數負責檢查,這樣三個頁面就可以重複使用,所以可以定義一個函數做下面的事情

  • 初始為某一頁
  • 照著某個步驟點連結做檢查
    • 檢查要包含不可以重整頁面
    • 也要包含 body 內容的檢查
  const runClickEveryLinkWithPage = async (page, request, startPage, steps) => {
    await page.goto(`/10/${startPage}.html`)

    // 攔截重整頁面,讓他跳出 dialog
    await page.evaluate(() => {
      window.addEventListener('beforeunload', function (e) {
        e.preventDefault()
        return false
      })
    })

    // 抓到你的 beforeunload 的 dialog 代表你重整了頁面
    page.on('dialog', async (dialog) => {
      throw new Error('你刷新了頁面')
    })

    // 開始照著步驟去點連結
    for (const step of steps) {
      const link = await page.locator(`text=${step}`)
      await link.click()

      // 取得新的 body
      const html = await (await request.get(`/10/${step}.html`)).text()
      const newBody = await page.evaluate((html) => {
        const parser = new DOMParser()
        const doc = parser.parseFromString(html, 'text/html')
        return doc.body.innerHTML
      }, html)

      // 雖然檔案很小,但為了避免出狀況,還是等他讀取完
      await page.waitForLoadState('networkidle')

      // 比對 body 是否正確
      const body = await page.locator('body').innerHTML()
      expect(body, { message: '頁面內容不正確' }).toBe(newBody)
    }
  }

當做完這些苦工之後,就可以很簡單的來做測試:

  test('click every link start with a', async ({ page, request }) => {
    await runClickEveryLinkWithPage(page, request, 'a', [
      'b',
      'c',
      'a',
      'c',
      'b',
      'a',
    ])
  })

  test('click every link start with b', async ({ page, request }) => {
    await runClickEveryLinkWithPage(page, request, 'b', [
      'a',
      'c',
      'b',
      'c',
      'a',
      'b',
    ])
  })

  test('click every link start with c', async ({ page, request }) => {
    await runClickEveryLinkWithPage(page, request, 'c', [
      'a',
      'b',
      'c',
      'b',
      'a',
      'c',
    ])
  })

這樣分開寫的好處是可以直接讓 playwright 幫我們分為不同的 worker 去跑,這樣就可以讓測試跑更快(因為這些測試也沒有相依性)。 (如果你有啟用 parallel 的話)


第十一題: 圖片顏色放大鏡

這題跟前面的 04 題有點類似,但這題是要做一個顏色放大鏡,這題也是先將各元素的 id 和屬性定義好,然後要求色碼應該顯示在放大鏡的右下方。

所以基本上我就簡單測一條 happy path 就好:

  test('happy path', async ({ page }) => {
    // 上傳圖片
    const input = await page.locator('#file-input')
    await input.setInputFiles('./images/dog.jpg')

    // 檢查預覽是否正確
    const canvas = await page.locator('#canvas')
    expect(await canvas.screenshot(), {
      message: '預覽不正確',
    }).toMatchSnapshot('11-1-answer.png', { maxDiffPixelRatio: 0.01 })

    // 移動滑鼠到圖片上
    await canvas.hover(10, 10)
    const color = await page.locator('#color')

    // 避免 `transparent` 的問題,先將背景改成白色,只檢查本身
    await color.evaluate((el) => (el.style.backgroundColor = 'white'))
    expect(await color.screenshot(), {
      message: '顏色放大鏡不正確',
    }).toMatchSnapshot('11-2-answer.png', { maxDiffPixelRatio: 0.01 })

    // 色碼區域,方式與上面一樣
    const rgba = await page.locator('#rgba')
    await rgba.evaluate((el) => (el.style.backgroundColor = 'white'))
    expect(await rgba.screenshot(), { message: '色碼不正確' }).toMatchSnapshot(
      '11-3-answer.png',
      { maxDiffPixelRatio: 0.01 }
    )

    // 將滑鼠移到中間,檢查各元素位置是否正確
    const canvasBox = await canvas.boundingBox()
    const x = canvasBox.x + canvasBox.width / 2
    const y = canvasBox.y + canvasBox.height / 2
    await page.mouse.move(x, y)

    const colorBox = await color.boundingBox()
    expect(colorBox.x - x).toBeGreaterThan(10)
    expect(colorBox.y - y).toBeGreaterThan(10)

    const rgbaBox = await rgba.boundingBox()
    expect(rgbaBox.x - x).toBeGreaterThanOrEqual(colorBox.width)
    expect(rgbaBox.y - y).toBeGreaterThanOrEqual(
      colorBox.height - rgbaBox.height
    )
  })

第十二題: 夜間模式

這題很單純,只是要考選手會不會使用 CSS 的媒體查詢去檢查裝置為深色或淺色並顯示對應的顏色與文字。

這題我想盡量讓選手有最大的彈性,所以不做任何額外的限制,但這樣就會讓測試程式相對複雜一些,因為我必須去檢查單純的 HTML 內容之外,還要檢查是否其實文字在 CSSbeforeafter 中。

不管怎麼樣,這題最主要要檢查的內容不外乎就兩個:

  • 背景
  • 文字

而變數就只是裝置的主題(深淺色),所以可以先寫兩個函數去處理上面那兩件事:

檢查背景的部分這邊我直接將所有 body 內的元素都刪除與做隱藏的動作,然後將整個頁面做 screenshot 做比對。

這樣做的原因是因為我並不知道選手會將背景色放在哪裡,所以我就直接將所有元素隱藏,然後將整個頁面做 screenshot,這樣就可以檢查背景色是否正確。

const testBackgroundColor = async (page, mode) => {
  await page.evaluate(() => {
    const style = document.createElement('style')
    style.innerHTML =
      'body *, body::after, body::before { visibility: hidden; }'
    document.body.appendChild(style)

    const script = document.createElement('script')
    script.innerHTML = `
      for (let child of document.body.childNodes) {
        child.remove()
      }
      `
    document.body.appendChild(script)
  })

  expect(await page.screenshot(), {
    message: '背景顏色不正確',
  }).toMatchSnapshot(`12-${mode}-answer.png`)
}

再來是檢查文字的部分,這邊會比較複雜,因為文字不只可能在標籤中,也可以存在在 beforeafter 中,而且存在偽元素當中的文字會包含字串的引號當中,所以還要記得去掉。

const testContent = async (page, content, targetColor) => {
  // 過濾出所有正確文字的元素
  const targetElements = await page.evaluate((content) => {
    // 直接對所有元素做處理
    return Array.from(document.querySelectorAll('*'))
      // 先整理資料結構
      .map((el) => {
        // 拿到當前節點的文字與顏色
        const text = el.textContent
        const style = window.getComputedStyle(el)

        // 拿到 before 與 after 的文字與顏色
        const beforeElement = window.getComputedStyle(el, '::before')
        const beforeContent = beforeElement.content

        const afterElement = window.getComputedStyle(el, '::after')
        const afterContent = afterElement.content

        // 回傳 [顏色, 文字] 的組合
        return [
          [style.color, text],
          [beforeElement.color, beforeContent.trim().slice(1, -1)],
          [afterElement.color, afterContent.trim().slice(1, -1)],
        ]
      })
      // 攤平
      .flat()
      // 過濾出文字正確的 pair,要記得 trim 掉,因為 HTML 可能會有空白或換行
      .filter((pair) => pair[1].trim() === content)
  }, content)

  // 檢查是否至少存在一個正確的文字
  expect(targetElements?.length, {
    message: '文字不存在或不正確',
  }).toBeGreaterThan(0)

  // 檢查是不是至少有一個文字的顏色正確
  expect(
    targetElements.some((element) => element[0] === targetColor),
    { message: '文字顏色不正確' }
  ).toBeTruthy()
}

為什麼不是找到第一個正確的文字就檢查顏色呢? 因為可能有多個節點有正確的文字,但是只有一個節點的顏色正確。

而至於為什麼不是直接檢查到正確的就停,而要把全部都列舉出來檢查,原因是我希望給予選手更多錯誤的提示訊息,加上這題的程式不會太複雜,全部拿出來也不會產生效能問題。


第十三題: 水波紋按鈕

這一題又是一個動畫的題目,是要做一個按鈕他被點擊後要有水波紋的效果,如下: 13

基本上這題也是給了一些限制,例如:

  • 按鈕大小 300px * 200px,顏色為 #39f,沒有 border
  • 動畫在1秒鐘完成,大小從0*0500*500,透明度從0.50
  • 按鈕文字為 BUTTON
  • 各元素應有 id 設置,如下:
    • 按鈕: button

之後就可以和前面測試動畫的方式一樣了:

  • 先檢查禁止使用 img 標籤
  • 檢查初始化的狀態(畫面)
  • 測試點按鈕

礙於篇幅已經很長,相同的事情就不再貼上來了 (前面有太多類似的做法了


第十四題: 圖片浮水印

終於來到了最後一題,這題是要做幫圖片加上浮水印,不過題目原本的敘述有點問題,所以我稍微修改了一下,修改後我再為浮水印額外要求一些限制:

  • 字體請使用素材提供的 arial.ttf
  • 顏色為白色
  • size=20
  • 置於約靠右15px,靠下20px的位置

這題主要測試兩個,一個是 status codeheader 是否正確,另一個是浮水印是否添加正確。

  test('check status code and header', async ({ request }) => {
    const response = await request.get('/14/index.php?uri=/media/img.jpg')
    expect(response.status()).toBe(200)
    const contentType = response.headers()['content-type']
    expect(contentType).toMatch(/image\/(jpeg|jpg)/)
  })

  test('case 1', async ({ page }) => {
    await page.goto('/14/index.php?uri=/media/img.jpg')
    expect(await page.locator('img').screenshot(), {
      message: '浮水印處理不正確',
    }).toMatchSnapshot('14-1-image.png', { maxDiffPixelRatio: 0.01 })
  })

因為位置可能有點偏差,所以這邊有讓容許的誤差為 0.01,這樣就可以避免因為位置偏差而導致測試失敗。

而就算失敗,也能夠看到 diff 的部分,這樣就可以知道要怎麼去修正。


結語

這一篇主要是分享我怎麼測試這些題目,這些題目其實都不難,但可以練到很多技巧,也滿推薦就算不是選手也可以去做這些題目當作練習,然後也可以順便使用這些測試腳本來檢驗自己的程式。 (雖然也說不定會發現我的腳本還有問題(?)

而因為這次的腳本沒有要用在比賽上,所以有些測資可能比較簡單或不足,但至少可以檢查基本的功能是否正常。

最後再附上我修改過的題目:

還有測試專案的連結: https://github.com/LaiJunBin/WebSkill-52-Module-A-Test-Project

雖然不確定我預先放好的 screenshots 能不能用,但至少可以參考完成圖,如果認為自己的 screenshots 是正確的再覆蓋就可以了。


預告

下一篇應該會以今年(54屆)的遊戲模組來做測試專案分享,當中會有 WebSocket 的部分,如果對題目有興趣也可以上網去找題目自己練習看看。




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