ブラウザE2EテストでQRコードを読み取りたい
![d yoshikawa](https://res.cloudinary.com/zenn/image/fetch/s--alswbvDD--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_70/https://storage.googleapis.com/zenn-user-upload/avatar/8c5e587568.jpeg)
ブラウザで描画されたQRコードをPlaywright/CypressなどによるE2Eテスト実行時に読み取り+デコードして値を検証したい。
ちゃんと動作確認しようと思うと、最終的には実際に読み取りに使用する機器(iOS/Androidスマホや専用リーダーなど)で実機テストまで行うべきだろう。
ただ、実機を使った自動テスト環境の整備は実際なかなかハードルが高そう。多くの現場で比較的容易に実践可能な自動テストのラインは手元のPCやCI環境上でブラウザを立ち上げての自動操作テストになってくると思っている。なのでとりあえずここではブラウザ内完結での自動QRコード読み取り+検証にトライしていく。
![d yoshikawa](https://res.cloudinary.com/zenn/image/fetch/s--alswbvDD--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_70/https://storage.googleapis.com/zenn-user-upload/avatar/8c5e587568.jpeg)
やりたいことをもう少し具体化する:
- QRコードをimg要素で描画する
- img要素を取得してデコードする
![d yoshikawa](https://res.cloudinary.com/zenn/image/fetch/s--alswbvDD--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_70/https://storage.googleapis.com/zenn-user-upload/avatar/8c5e587568.jpeg)
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 yoshikawa](https://res.cloudinary.com/zenn/image/fetch/s--alswbvDD--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_70/https://storage.googleapis.com/zenn-user-upload/avatar/8c5e587568.jpeg)
「QRコード JS 読み取り」などでググるとjsQRというパッケージがよく出てくる。
npm trendsを見た感じ、おそらく最も使われている。が、 jsQR
の引数にImageDataを渡す必要があり、img要素やbase64文字列をデコードするのはこれ単体では簡単にできなさそう。
この点、以下のIssueで議論されていた。
Base64 decoding · Issue #96 · cozmo/jsQR
解決策として、上のIssueで紹介されているqr-scannerを使うのが手っ取り早そうなので試してみる。
![d yoshikawa](https://res.cloudinary.com/zenn/image/fetch/s--alswbvDD--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_70/https://storage.googleapis.com/zenn-user-upload/avatar/8c5e587568.jpeg)
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 yoshikawa](https://res.cloudinary.com/zenn/image/fetch/s--alswbvDD--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_70/https://storage.googleapis.com/zenn-user-upload/avatar/8c5e587568.jpeg)
- Inputに
test
と入力 - QRコードが生成される
-
Decode
ボタンを押下 - QRコードがデコードされて
Decoded value
Inputにtest
と出力される
意図通り動いていそう。
![d yoshikawa](https://res.cloudinary.com/zenn/image/fetch/s--alswbvDD--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_70/https://storage.googleapis.com/zenn-user-upload/avatar/8c5e587568.jpeg)
ここまでできればもうE2Eでもできそう。
![d yoshikawa](https://res.cloudinary.com/zenn/image/fetch/s--alswbvDD--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_70/https://storage.googleapis.com/zenn-user-upload/avatar/8c5e587568.jpeg)
Playwrightを導入。
npm i -D @playwright/test
npx playwright install
QRコードスキャンロジックをPlaywrightテストコードに移動するので、アプリケーションコードから削除する。また、Playwrightから要素取得するために data-testid
を付与。
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 yoshikawa](https://res.cloudinary.com/zenn/image/fetch/s--alswbvDD--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_70/https://storage.googleapis.com/zenn-user-upload/avatar/8c5e587568.jpeg)
mainファイルは次のようにしておく。
[Question] Nodejs support? · Issue #95 · nimiq/qr-scanner
qr-scannerはブラウザ環境での使用を想定されており、Node.jsはサポート外と思われる。そのため、Playwrightの .evaluate()
内で扱えるように window
以下に qrScanner
を生やしておく。
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テストを書く。
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)
意図通りのテストができた。