社のプロダクトではビジュアルリグレッションテストのためのツールであるBackstopJSをここ半年くらい使っている。referenceという正解データの画像と、testとして開いてキャプチャした画像を見比べて、差分があれば失敗、というテストを書ける。テストケースの設定がJSONを書くだけなのでテストを書くコストが低いのと、結果的に画像に出る形でテストすることを強制される、というシンプルさが気に入っている。過去にはE2Eテストをいろいろ試したけどどれも最終的に滅びてしまい、BackstopJSはそんななか生き残っている希望のテクノロジー。
リリース前の確認フローにも使っているし、書いてる途中のコードの動作確認にも使えて、数ピクセルの意図しないズレを検知できたりしている。
便利だけど、運用上苦労している点もあって、テストの書き方が悪かったり、ネットワークの調子が悪かったり、で確率的に落ちるテストがあるので、リトライする仕組みを用意していた。
既存研究
落ちたテストをリトライしたいというissueで紹介されていた技がこれで、HTMLレポートが呼び出しているreportという関数を上書きして、その中で落ちたテストを順番に実行するというもの。
実際に動かしてみたわけではないけど、backstop testを複数回実行しているので、全ケースが載ったHTMLのレポートを作れない点が惜しいと思う。機械的にfilterをつけて再実行するというアイデアは参考になった。
#!/usr/bin/env node # Name: backstop_retry.js # Usage example: backstopjs --config=backstop.json || backstop_retry.js backstop.json const { execSync } = require('child_process') const fs = require('fs') const path = require('path') const configJsonPath = process.argv[2] const configJson = JSON.parse(fs.readFileSync(configJsonPath, 'utf8')) const reportConfigPath = path.join(process.cwd(), configJson.paths.html_report, 'config.js') global.report = function (config) { // Fail fast if no timeout failure found config.tests.forEach((test) => { if (test.status === 'fail' && !(test.pair.engineErrorMsg && test.pair.engineErrorMsg.match(/Navigation Timeout Exceeded: \d+ms exceeded/))) { process.exit(1) } }) // Retry each timeout config.tests.forEach((test) => { if (test.status === 'fail') { const label = test.pair.label console.log(`\nRetrying a failed scenario label="${label}"...\n`) const cmd = `backstop test --config=${configJsonPath} --filter="${label}"` try { execSync(cmd, { stdio: 'inherit' }) } catch (err) { process.exit(1) } } }) } require(reportConfigPath)[New Feature Request] Retry Function · Issue #761 · garris/BackstopJS · GitHub
Jenkins上で落ちたテストを抜き出してリトライする
我々はJenkins上でBackstopJSを動かしていたので、Jenkinsでやってみよう、と最初に用意したのがこれで、Jenkinsfileのretryを使う手法。見どころは、jqを使ってjson_reportから失敗しているものを抜き出してfilter用の正規表現を作っているところ。
retry(5) { // レポートが上書きされないように、前回の結果を別ディレクトリに移しておく sh "cp -r backstop_data/ report/backstop_data_`date +%s`/; true" // --filter で (落ちたテスト名1|落ちたテスト名2) のような正規表現を渡す def filter = sh( script: "cat backstop_data/json_report/jsonReport.json | jq -r '[.tests[] | select(.status == \"fail\") | .pair.label] | join(\"|\")'", returnStdout: true, ) def filterOption = filter ? "--filter '^(${filter.trim()})\$'" : "" // 何度かリトライするので、referenceは失敗してもジョブの失敗にしない stage 'Reference' sh "docker run --net=host --user=`id -u` --rm -v `pwd`:/src backstopjs/backstopjs reference --config backstop.js ${filterOption} || true" stage 'Test' sh "docker run --net=host --user=`id -u` --rm -v `pwd`:/src backstopjs/backstopjs test --config backstop.js ${filterOption}" }
初回は--filterなしですべて実行→次回は落ちたところだけfilterを指定して実行、を繰り返して、テストが通れば完了。安定して動くようにはなったものの、いまいちな点がいくつかあった。
結果のレポートが複数に分かれてしまう
backstop testを一度実行するたびにHTMLのレポートができていく。5回実行しているので、最大5つのレポートを見て回ることになる。
JenkinsのHTML Publisherとの相性の悪さ
結果はHTML Publisherを使ってブラウザから見えるようにしていたのだけど、HTML Publisherの仕様で、HTMLが複数あるときには、こういう画面がJenkinsに現れる。素朴な画面で、どこからレポートを見れるのか初見ではわからない。
- Jenkinsfileでjqを呼び出したりしてすごい処理をやっていることへの違和感
- Jenkinsfileはスクリプトを実行するだけ、くらいにとどめたい
- GitHub Actionsからテストしたい場合はGitHub Actions界で使える技を探して再実装する、という無駄感
リトライし、レポートを1つに合体するグッズを作った
もろもろの困りごとを解消するためのコマンドラインツールを作ってみることにした。
- レポート見づらい問題
- → レポートは1つにまとめる処理を実装する。各回のレポートを見比べて、落ちたところがのちに通っていたら上書きしていけばよい
- Jenkinsfileにロジックを書きたくない
- → リトライ処理を持ったコマンドラインツールを作る。どのコマンドを何回実行するかを起動時に受け取る
- そのさい、上でやっていたようなfilterの合成もおこなう
- BackstopJSとセットで使いたいだろうからNode.jsで実装し、npm installで入手できるようにする
何度かリトライしたときの結果をつなげたアニメーションGIFがこういう調子で、上のテストは先に緑になる、下のテストはリトライされ続ける、全部通ったら終わり、というような形。
これによって、リトライ機能はそのままに、レポートが1つに統一されて見やすい、という形を作れるようになった。
どのテストがリトライしているかは、成果物であるレポートでは見れないけど、実行時のログを眺めていると分かるようになっている。
# Running(2/5) backstop test --filter '^(random_face)$' # BackstopJS v5.0.1 # # Loading config: /Users/hitode909/co/github.com/hitode909/backstop-retry-failed-scenarios/examples/retry/backstop.json
backstop-retry-failed-scenariosコマンドが今回作ったもので、こういう形で実行する。リトライ回数、実行するためのコマンド、実行時に使うconfigファイルをそれぞれ指定する形。
$ backstop-retry-failed-scenarios \ --retry 5 \ --command 'backstop test' \ --config backstop.js
BackstopJSをDockerで動かしてたらこんな感じで、commandにdocker runを書けば良い。
$ backstop-retry-failed-scenarios \ --retry 5 \ --command 'docker run --rm -v $(pwd):/src backstopjs/backstopjs test'
イマイチな点としては、BackstopJSが生成するJSONの構造に勝手に依存していることで、公開されたインターフェイスではなくて、HTMLを描画するアプリケーションが読んでいるJSのファイルを直接書き換えたりしていて、行儀は悪い。型定義が公開されていれば最新版と統合して動くか試したりとか、E2Eテストのようなものを用意して、実際にテスト中でBackstopと統合して動くか見ておけるとより良いかもしれない。
もう一つは、実装しきってから気づいたけど、社のプロダクトではNodeとBackstopJSを別のDockerイメージで動かしていたので、backstop-retry-failed-scenariosをDockerで動かし、そこに渡すcommandでもdocker runしようとすると、DockerからDockerを呼び出す形になってしまう。これを避けるためには、Nodeが入っていて、BackstopJSやGoogle Chromeが入っていて、今回実装したbackstop-retry-failed-scenariosもインストールした全部入りイメージを作る必要が出てきて、全部入りの巨大イメージができてしまって悲しかった。
Nodeが動いてるイメージはFROM nodeしてチョロっと必要なものを入れる、くらいにとどめたいし、BackstopJSのイメージはGoogle Chromeのダウンロードとかが必要なので自前で面倒見たくない、というあたりがイマイチ。
BackstopJS使ってる人と知見交換したいけどあまり出会いがない、友だちになってください、よろしくお願いします。
github.com
www.npmjs.com