hitode909の日記

以前はプログラミング日記でしたが、今は子育て日記です

BackstopJSの落ちたテストをリトライしてレポートを1つにまとめてくれるコマンドラインツールを作った

社のプロダクトではビジュアルリグレッションテストのためのツールである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つのレポートを見て回ることになる。

f:id:hitode909:20200607173419p:plain
初回実行では133件成功、1件失敗
f:id:hitode909:20200607173425p:plain
二度目の実行では初回で失敗した1件が成功

JenkinsのHTML Publisherとの相性の悪さ

結果はHTML Publisherを使ってブラウザから見えるようにしていたのだけど、HTML Publisherの仕様で、HTMLが複数あるときには、こういう画面がJenkinsに現れる。素朴な画面で、どこからレポートを見れるのか初見ではわからない。

f:id:hitode909:20200607173355p:plain
この画面でindex.htmlをクリックするのが正解

  • Jenkinsfileでjqを呼び出したりしてすごい処理をやっていることへの違和感
    • Jenkinsfileはスクリプトを実行するだけ、くらいにとどめたい
    • GitHub Actionsからテストしたい場合はGitHub Actions界で使える技を探して再実装する、という無駄感

リトライし、レポートを1つに合体するグッズを作った

もろもろの困りごとを解消するためのコマンドラインツールを作ってみることにした。

  • レポート見づらい問題
    • → レポートは1つにまとめる処理を実装する。各回のレポートを見比べて、落ちたところがのちに通っていたら上書きしていけばよい
  • Jenkinsfileにロジックを書きたくない
    • → リトライ処理を持ったコマンドラインツールを作る。どのコマンドを何回実行するかを起動時に受け取る
    • そのさい、上でやっていたようなfilterの合成もおこなう
    • BackstopJSとセットで使いたいだろうからNode.jsで実装し、npm installで入手できるようにする

何度かリトライしたときの結果をつなげたアニメーションGIFがこういう調子で、上のテストは先に緑になる、下のテストはリトライされ続ける、全部通ったら終わり、というような形。

f:id:hitode909:20200607174221g:plain
テストがだんだん通る様子

これによって、リトライ機能はそのままに、レポートが1つに統一されて見やすい、という形を作れるようになった。

f:id:hitode909:20200607174319p:plain
134件のテストが通るというレポート

どのテストがリトライしているかは、成果物であるレポートでは見れないけど、実行時のログを眺めていると分かるようになっている。

#  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