Webページを監視して表示崩れが起きていないか検出できるE2Eテストを実装しました

お世話になります、フロントエンド担当をしている小原正大です。Webページの表示を監視して差異があった場合、どのページで表示の変化が起きているかを知ることが出来るプログラムを実装したのでそのことについて書こうと思います。

何につかったの?

ロゴ

僕がフロントエンドを担当しているサービス『料理サプリ』で大規模なフロントエンドコードのリファクタリング行う際に表示テストを自動化するために作成しました。『料理サプリ』はPC・スマホ合わせて大体350-400ページの表示パターンが存在する比較的規模の大きいサイトです。全ページに影響を与えるような作業は大規模な回収となり、今回のリファクタリングでは表示テストの計画などの段取りが必要でした。従来の人手によるQAでは細かいバグを見過ごしたり時間がかかり効率が悪いので、可能な限り自動化しようと考え実装しました。

実装の概要

システム

この監視のシステムは以下の2つ実装を組合わせて作成されています。
1.任意のタイミングで全ページをスクリーンショットする
2.全ページのスクリーンショットのセット同士でページごとの画像差分を取ってどれくらい変化があったのかを数値化する

実装方法

サーバーがRuby on Railsで実装されていたのでそれに合わせてRubyで実装しました。

全ページをスクリーンショットする

RSpec, Capybara, PhantomJSを使って実装しました。
PhantomJSの代わりにSeleniumを使えばFirefoxやChromeなどを動かすこともできますが、Headlessな実装が簡単なPhantomJSを使った方法を紹介します1)話はそれますがHeadlessをググるときは『headless web』とかで調べるようお気をつけ下さい。『headless』で調べるとショッキングな画像出てきて怖かったです。絶対に調べないでください。
OSによって設定方法が違ったりするので環境構築の説明は割愛します。

参考リンク
* PhantomJSの設定方法:CentOSにPhantomJSとCasperJSをインストールする
* capybara-webkitのgemを入れるときこけるのでqtをインストール、xvfbは不要です:CentOSにcapybara-webkitインストール

Capybaraの設定

設定は以下の用に行いました。レスポンシブサイトだったためPCとスマホで画面サイズを調整してスクリーンショットを取れるように設定しています。User-Agentでページを出し分けている場合はその設定も追加で必要になります。

# for capybara
require 'capybara'
require 'capybara/rspec'
require 'capybara/poltergeist'
Capybara.javascript_driver = :poltergeist
# スマホで表示するための設定
Capybara.register_driver :poltergeist_sp do |app|
  Capybara::Poltergeist::Driver.new(app, :js_errors => true, :timeout => 60, window_size: [320, 580])
end
# PCで表示するための設定
Capybara.register_driver :poltergeist do |app|
  Capybara::Poltergeist::Driver.new(app, :js_errors => true, :timeout => 60)
end
Capybara.configure do |config|
  config.run_server = false
  config.default_driver = :poltergeist
  config.app_host = "http://#{ ENV["BASE_URL_HOST"] || "localhost" }:3000/"
end

スクリーンショットの実装

以下のような実装でスクリーンショットを行いました。
この実装ではgetでアクセスして表示されたページをそのままスクリーンショットすることに対応しています。ページ数が多いと(実際の実装では350-400ページ)処理に時間がかかるので中断が入っても大丈夫なようにスクショ済みページをスキップできる機能も実装しました。
実際の運用ではログインが必要だったりJavaScriptを発火させたかったりpost先のページのスクリーンショットも撮りたかったりすると思いますが、普通にCapybaraを使う場合と同じでストーリーに追記したりするだけで対応できます。

# 先ほどのファイルをインポート
require "config"
# 保存先ディレクトリにコミットIDでフォルダを作ってスクリーンショットを保存する
commit_id = `git rev-parse HEAD`.chomp
save_path = "/screenshot/save/path/" + commit_id + "/"
# getでアクセスで表示したいページのurl配列
pages = [
  "hoge",
  "fuga/fuga",
  "piyo/piyo/piyo"
]
# スクショ済みページをスキップする
skip_exist_pagees_pc = []
skip_exist_pagees_sp = []
Dir::glob(save_path+"*").each {|f|
  next if f.match("'/\.gif$|\.png$|\.jpg$|\.jpeg$|\.bmp$/i'").nil?
  file_name = File.basename(f)
  # file名をpathに変更
  path = file_name.gsub(/__/, "/").gsub(/\.png/, "")
  # sp判定
  if path.start_with?("sp")
    path = path.gsub(/sp\//, "")
    skip_exist_pagees_sp.push(path)
    next
  end
  skip_exist_pagees_pc.push(path)
}
# スキップされるページを表示
p "skipped pc pages"
p skip_exist_pagees_pc
p "skipped sp pages"
p skip_exist_pagees_sp
pages_pc = pages - skip_exist_pagees_pc
pages_sp = pages - skip_exist_pagees_sp
# 実際にスクリーンショットされるページを表示
p "these pc pages will be captured"
p pages_pc
p "these sp pages will be captured"
p pages_sp
describe "get pages", :type => :feature do
  subject{ page }
  pages_pc.each do |url|
    it "get_pc_pages: " + url do
      Capybara.current_driver = :poltergeist
      visit(url)
      # urlをファイル名に変換する("/" => "__")
      img_path = save_path + url.gsub(/\//, "__") + ".png"
      page.save_screenshot(img_path, full: true)
    end
  end
  pages_sp.each do |url|
    it "get_sp_pages: " + url do
      Capybara.current_driver = :poltergeist_sp
      visit(url)
      # urlをファイル名に変換する("/" => "__")
      img_path = save_path + "sp__" + url.gsub(/\//, "__") + ".png"
      page.save_screenshot(img_path, full: true)
    end
  end
end

スクリーンショットされた画像たち

以下のようにそれぞれのコミットIDのフォルダに対してurlで命名されたスクリーンショット群が作成されます。

スクリーンショット群

実際のスクリーンショットも綺麗に撮れています(一部を切り取って表示しています)。

スマホ画面

全ページの画像差分を抽出する

ImageMagickをRMagickで動かして実装しました。
同じく環境構築については割愛します。

参考リンク
* ImageMagickが必要なので:CentOS に ImageMagick, RMagick のインストール

画像差分の実装

第一、第二引数にフォルダ名をargvとして渡して、同一ファイル名のものの画像差分を取るようにしています。第二引数を指定しない場合デフォルトで指定したディレクトリを比較するようにしています。

// 第一引数のみ指定
$ ruby image_diff.rb /screenshot/save/path/comit_id
// 第一、第二引数を指定
$ ruby image_diff.rb /screenshot/save/path/comit_id_1 /screenshot/save/path/comit_id_2
require "rmagick"
# 保存先の設定
save_base_path = "/image_diff/save/path/"
# 引数がない場合の比較元
default_path = "/screenshot/save/path/master"
# argvの整形
def folder_path(path)
  if path.slice(-1) != "/"
    return path + "/" 
  else
    return path 
  end
end
# 画像の変化率を計測
def image_diff_rate(image)
  i = 0.0
  black = Magick::Pixel.new(0, 0, 0)
  # イメージを1pxごとに黒色かどうか判定
  for y in 0...image.rows
    for x in 0...image.columns
      image_color = image.pixel_color(x, y)
      # 色が黒の場合
      if image_color == black
      else
        i = i + 1
      end
    end
  end
  # ドット数で割って変化率を出す
  i = i / (image.rows * image.columns) * 100
  return i.round(2).to_s + "%"
end
def image_diff(path, composite_path, save_directory)
  k = 0
  file_count = Dir[ path + "**/*" ].length + 1
  # 差分のログファイルを作成する
  diff_log = "compared " + path + " and " + composite_path + "\n"
  diff_log = diff_log + "path\tdiff[%]\n"
  Dir::foreach(path) do |file|
    next if file.match("'/\.gif$|\.png$|\.jpg$|\.jpeg$|\.bmp$/i'").nil?
    k = k + 1
    # 元画像の読み込み
    image = Magick::ImageList.new(path + file)
    # 参照先のファイルが存在しない場合
    if !File.exist?(composite_path + file)
      diff_log += composite_path + file + " doesn't find\n" 
      p "(" + k.to_s + "/" + file_count.to_s + ") " + composite_path + file + " doesn't find"
      next
    end
    # 比較先の読み込み
    composite_image = Magick::ImageList.new(composite_path + file)
    # 比較
    save_image = image.composite(composite_image, 0, 0, Magick::DifferenceCompositeOp)
    save_path = save_directory + file
    # 画像の保存
    save_image.write(save_path)
    # 差分のログを追記
    diff_rate = image_diff_rate(save_image)
    file_name_to_path = file.gsub(/__/, "/").gsub(/\.png/, "") 
    # 追記処理
    diff_log += file_name_to_path + "\t" + diff_rate + "\n" 
    p "(" + k.to_s + "/" + file_count.to_s + ") " + file_name_to_path + " " + diff_rate 
    File.write(save_directory + "log.csv", diff_log)
    # メモリ解放
    save_image.destroy!
  end
end
if ARGV[0].nil?
  p "argvの第一引数に参照先フォルダ、第二引数に参照元フォルダを入力してください"
  p "第二引数が存在しない場合は参照元フォルダ2に" + default_path + "が適応されます"
  return 
else 
  composite_path = folder_path(ARGV[0]) 
  if ARGV[1].nil?
    path = folder_path(default_path)
  else
    path = folder_path(ARGV[1])
  end
end
p "参照元: " + path 
p "参照先: " + composite_path
p "比較を開始します"
# argvで取得したスクリーンショットフォルダ名を取得
folder1 = File.basename(path)
folder2 = File.basename(composite_path)
if ARGV[1].nil?
  save_directory = save_base_path + folder2 + "/"
else
  save_directory = save_base_path + folder1 + "__vs__" + folder2 + "/"
end
# 保存先のフォルダがない場合作成する
FileUtils.mkdir_p(save_directory) unless FileTest.exist?(save_directory)
# 実行
image_diff(path, composite_path, save_directory)

実際の比較画像

実際に画像差分を取ると細かい変化でも検知できます。

スクリーンショット

例えば下の画像は変更前と変更後のスクリーンショットを並べているのですが、変化に気づくことが出来るでしょうか。

間違い探し

差分画像

二箇所違いがありました。『のレシピ』部分のfont-sizeが変化しているのと、『食物繊維』が『食物維繊』となっています。後者は意図的に起こしましたが、font-sizeの変化などはリファクタリングなどでCSSに大きく改変を加えれば起きうる誤差です。しかもサイトデザインを熟知してなければ画像を並べないと気づきにくいような変化です。このような変化を監視出来るようになります。

差分画像

実行ログ

log.csvを表計算ソフトなどをつかって開くと下のように差分を確認することができて、どこに表示誤差があるのか確認できます。

差分ログ

まとめ

表示崩れを監視するプログラムを実装すると、E2Eの表示テストについて一定の品質が保証されるので、かなり気持ちが楽にリファクタリング作業を進めることができました。コントローラーでページごとに指定されていたCSSを全ページ共通で読み込めるようにしたり、ローカルで改変されたBootstrapを消したりして、全体で4万行ほど不要だったCSSを消して綺麗にできたりしました。フロントエンドの大掛かりなリファクタリングを検討する際にはぜひ参考になれば幸いです。

脚注

脚注
1 話はそれますがHeadlessをググるときは『headless web』とかで調べるようお気をつけ下さい。『headless』で調べるとショッキングな画像出てきて怖かったです。絶対に調べないでください。