Closed9

ブラウザE2EテストでQRコードを読み取りたい

d yoshikawad yoshikawa

ブラウザで描画されたQRコードをPlaywright/CypressなどによるE2Eテスト実行時に読み取り+デコードして値を検証したい。

ちゃんと動作確認しようと思うと、最終的には実際に読み取りに使用する機器(iOS/Androidスマホや専用リーダーなど)で実機テストまで行うべきだろう。

ただ、実機を使った自動テスト環境の整備は実際なかなかハードルが高そう。多くの現場で比較的容易に実践可能な自動テストのラインは手元のPCやCI環境上でブラウザを立ち上げての自動操作テストになってくると思っている。なのでとりあえずここではブラウザ内完結での自動QRコード読み取り+検証にトライしていく。

d yoshikawad yoshikawa

やりたいことをもう少し具体化する:

  1. QRコードをimg要素で描画する
  2. img要素を取得してデコードする
d yoshikawad yoshikawa

React+Viteでフロントエンド環境を作り、

soldair/node-qrcode: qr code generator

を使ってQRコード出力サンプルを書いてみる。

import qrCode from 'qrcode'
import { ChangeEvent, useCallback, useEffect, useState } from 'react'

export const App = () => {
  const [qrCodeValue, setQrCodeValue] = useState<string>('')
  const changeQrCodeValueInput = useCallback(
    (e: ChangeEvent<HTMLInputElement>) => setQrCodeValue(e.target.value),
    [setQrCodeValue]
  )

  const [qrCodeBase64Str, setQrCodeBase64Str] = useState<string>('')
  useEffect(() => {
    if (qrCodeValue === '') {
      return
    }

    qrCode.toDataURL(qrCodeValue).then((base64Str) => {
      setQrCodeBase64Str(base64Str)
    })
  }, [qrCodeValue, qrCodeBase64Str, setQrCodeBase64Str])

  return (
    <div>
      <h1>QR Code Demo</h1>
      <div>
        <h2>Input QR Code value</h2>
        <input value={qrCodeValue} onChange={changeQrCodeValueInput} />
      </div>
      <div>
        <h2>Output QR Code</h2>
        <div>
          {qrCodeBase64Str != null && (
            <img src={qrCodeBase64Str} />
          )}
        </div>
      </div>
    </div>
  )
}

Input要素に適当な文字列を入力するとそれがQRコードとして出力される。

d yoshikawad yoshikawa

「QRコード JS 読み取り」などでググるとjsQRというパッケージがよく出てくる。

cozmo/jsQR: A pure javascript QR code reading library. This library takes in raw images and will locate, extract and parse any QR code found within.

npm trendsを見た感じ、おそらく最も使われている。が、 jsQR の引数にImageDataを渡す必要があり、img要素やbase64文字列をデコードするのはこれ単体では簡単にできなさそう。

この点、以下のIssueで議論されていた。

Base64 decoding · Issue #96 · cozmo/jsQR

解決策として、上のIssueで紹介されているqr-scannerを使うのが手っ取り早そうなので試してみる。

nimiq/qr-scanner: Lightweight Javascript QR Code Scanner

d yoshikawad yoshikawa

qr-scannerを組み込んだ:

import qrCode from 'qrcode'
import { ChangeEvent, useCallback, useEffect, useState } from 'react'
+import qrScanner from 'qr-scanner'

export const App = () => {
  const [qrCodeValue, setQrCodeValue] = useState<string>('')
  const changeQrCodeValueInput = useCallback(
    (e: ChangeEvent<HTMLInputElement>) => setQrCodeValue(e.target.value),
    [setQrCodeValue]
  )

  const [qrCodeBase64Str, setQrCodeBase64Str] = useState<string>('')
  useEffect(() => {
    if (qrCodeValue === '') {
      return
    }

    qrCode.toDataURL(qrCodeValue).then((base64Str) => {
      setQrCodeBase64Str(base64Str)
    })
  }, [qrCodeValue, qrCodeBase64Str, setQrCodeBase64Str])

+ const [decodedValue, setDecodedValue] = useState<string>('')
+ const clickDecodeButton = useCallback(() => {
+   if (qrCodeBase64Str === '') {
+     return
+   }
+
+   // useRef使ったほうが良さそうだが、ここでは最低限動くサンプルが得れれば良いので一旦querySelectorで進める
+   const qrCodeImg = document.querySelector<HTMLImageElement>('#qrCodeImg')
+   if (qrCodeImg == null) {
+     throw new Error('Notfound #qrCodeImg')
+   }
+
+   qrScanner.scanImage(qrCodeImg, {
+     scanRegion: undefined,
+   }).then((res) => setDecodedValue(res.data))
+ }, [setDecodedValue])

  return (
    <div>
      <h1>QR Code Demo</h1>
      <div>
        <h2>Input QR Code value</h2>
        <input value={qrCodeValue} onChange={changeQrCodeValueInput} />
      </div>
      <div>
        <h2>Output QR Code</h2>
        <div>
          {qrCodeBase64Str != null && (
-           <img src={qrCodeBase64Str} />
+           <img id="qrCodeImg" src={qrCodeBase64Str} />
          )}
        </div>
      </div>
+    <div>
+       <h2>Decode QR Code</h2>
+       <button onClick={clickDecodeButton}>Decode QR Code</button>
+       <div>
+         <label>Decoded value</label>
+         <input value={decodedValue} disabled={true} />
+       </div>
+     </div>
    </div>
  )
}
d yoshikawad yoshikawa

  1. Inputに test と入力
  2. QRコードが生成される
  3. Decode ボタンを押下
  4. QRコードがデコードされて Decoded value Inputに test と出力される

意図通り動いていそう。

d yoshikawad yoshikawa

Playwrightを導入。

npm i -D @playwright/test
npx playwright install

QRコードスキャンロジックをPlaywrightテストコードに移動するので、アプリケーションコードから削除する。また、Playwrightから要素取得するために data-testid を付与。

App.tsx
import qrCode from 'qrcode'
import { ChangeEvent, useCallback, useEffect, useState } from 'react'
import qrScanner from 'qr-scanner'

export const App = () => {
  const [qrCodeValue, setQrCodeValue] = useState<string>('')
  const changeQrCodeValueInput = useCallback(
    (e: ChangeEvent<HTMLInputElement>) => setQrCodeValue(e.target.value),
    [setQrCodeValue]
  )

  const [qrCodeBase64Str, setQrCodeBase64Str] = useState<string>('')
  useEffect(() => {
    if (qrCodeValue === '') {
      return
    }

    qrCode.toDataURL(qrCodeValue).then((base64Str) => {
      setQrCodeBase64Str(base64Str)
    })
  }, [qrCodeValue, qrCodeBase64Str, setQrCodeBase64Str])

- const [decodedValue, setDecodedValue] = useState<string>('')
- const clickDecodeButton = useCallback(() => {
-   if (qrCodeBase64Str === '') {
-     return
-   }
-
-   const qrCodeImg = document.querySelector<HTMLImageElement>('#qrCodeImg')
-   if (qrCodeImg == null) {
-     throw new Error('Notfound #qrCodeImg')
-   }
-
-   qrScanner
-     .scanImage(qrCodeImg, {
-       returnDetailedScanResult: true,
-     })
-     .then((res) => {
-       console.log(res)
-       setDecodedValue(res.data)
-     })
- }, [setDecodedValue, qrCodeBase64Str])

  return (
    <div>
      <h1>QR Code Demo</h1>
      <div>
        <h2>Input QR Code value</h2>
        <input
+         data-testid="qrCodeValueInput"
          value={qrCodeValue}
          onChange={changeQrCodeValueInput}
        />
      </div>
      <div>
        <h2>Output QR Code</h2>
        <div>
          {qrCodeBase64Str != null && (
-           <img id="qrCodeImg" src={qrCodeBase64Str} />
+          <img data-testid="qrCodeImg" src={qrCodeBase64Str} />
          )}
        </div>
      </div>
-     <div>
-       <h2>Decode QR Code</h2>
-       <button onClick={clickDecodeButton}>Decode QR Code</button>
-       <div>
-         <label>Decoded value</label>
-         <input value={decodedValue} disabled={true} />
-       </div>
-     </div>
    </div>
  )
}
d yoshikawad yoshikawa

mainファイルは次のようにしておく。

[Question] Nodejs support? · Issue #95 · nimiq/qr-scanner

qr-scannerはブラウザ環境での使用を想定されており、Node.jsはサポート外と思われる。そのため、Playwrightの .evaluate() 内で扱えるように window 以下に qrScanner を生やしておく。

main.tsx
import React from 'react'
import ReactDOM from 'react-dom/client'
import { App } from './App'
import './index.css'
import qrScanner from 'qr-scanner'

declare global {
  interface Window {
    qrScanner: typeof qrScanner
  }
}

// window.qrScannerを生やす
window.qrScanner = qrScanner

ReactDOM.createRoot(document.getElementById('root')!).render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
)

なお、本番コードでは、例えば以下のような分岐を入れてこの細工を含めないことが望ましいだろう。

+if (import.meta.env.APP_ENV === 'TESTING') {
  // テスト環境のみ実行
  window.qrScanner = qrScanner
+}

(ちなみにこういう場合、テスト環境以外では import qrScanner from 'qr-scanner' もtree shakingで除外されてくれるんだろうか。別の機会に検証したい)

Playwrightテストを書く。

App.test.ts
import { expect, test } from '@playwright/test'

test('QRコードを読み取る', async ({ page }) => {
  await page.goto('http://localhost:3000')
  await page.fill('[data-testid=qrCodeValueInput]', 'QR_CODE_VALUE')
  const decoded = await page.evaluate(async () => {
    const qrCodeImg = document.querySelector<HTMLImageElement>(
      '[data-testid=qrCodeImg]'
    )
    if (qrCodeImg == null) {
      throw new Error('error')
    }

    const decoded = await window.qrScanner.scanImage(qrCodeImg, {
      returnDetailedScanResult: true,
    })
    return decoded
  })

  expect(decoded.data).toBe('QR_CODE_VALUE')
})
npm run dev # or `npx vite`

で開発サーバを立ち上げ、ターミナル別タブでテスト実行コマンドを叩く。

npx playwright test

# Running 1 test using 1 worker
#
# ✓  1 src/App.test.ts:3:1 › QRコードを読み取る (500ms)
#
#
# 1 passed (910ms)

意図通りのテストができた。

参考

このスクラップは2022/11/23にクローズされました