Seleniumで撮ったスクリーンショットがブラウザごとにばらばら問題

  • 62
    いいね
  • 0
    コメント

この記事はSelenium/Appiumアドベントカレンダー2015の5日目の記事です。

1~4日目はhiroshitodaさんの薄くて熱い怒涛の4連発投稿でした。すごい!
今日はSeleniumを使ってきれいなスクリーンショットを撮るためには…というお話です。

モチベーション

  • E2Eテストを人手でやるのつらい
  • ターゲットとするデバイス、ブラウザ全部で同じテストを回すのもっとつらい

そこで「とりあえず画面全体のスクショを1枚撮って、最初は目視で確認して、2回目以降のテストは画像比較で自動化しよう」と思いつきました。

方法

Seleniumにはスクリーンショットを取るコマンドが用意されています。(下記はJavaバインディングの場合)

 File screenshotFile = ((Screenshot)driver).getScreenshotAs(file); // ファイル

テストスクリプトに1行追加するだけ。簡単ですね。
ところが色々なブラウザでこれを実行しようとするとややこしくなってきます。

Seleniumで撮ったスクリーンショットがブラウザごとにばらばら問題

こちらをご覧ください。
スクリーンショット
(左からIE, Chrome, Safari(iOS))

実際に幾つかのブラウザでスクリーンショット取得コマンドを実行してみると、
ブラウザによって全画面の画像が取れていたり、ウィンドウに表示されている内容だけだったり、
ページの内容以外(ステータスバーなど)が写り込んでしまっています。

なんで?

仕様です。そもそもSeleniumのAPIで下記のように決められています。

(公式APIの引用)

For WebDriver extending TakesScreenshot, this makes a best effort depending on the browser to return the following in order of preference:
- Entire page
- Current window
- Visible portion of the current frame
- The screenshot of the entire display containing the browser


(意訳)
このメソッドはブラウザに依存して、下記のいずれかを上から優先にベストエフォートで返します。
- ページ全体
- 現在のウインドウ
- 現在のフレームの可視範囲
- ブラウザを含むディスプレイ全体
// https://selenium.googlecode.com/git/docs/api/java/org/openqa/selenium/TakesScreenshot.html

ブラウザを駆動するドライバの実装の仕方はブラウザによって様々なので、中にはページ全体の描画が難しい場合もあるのでしょうね。
なお、上記のAPIで規定された項目別にブラウザを分類すると下記のような感じです。

スクリーンショットの形式 ブラウザ
ページ全体 Internet Explorer, Safari(OS X)
現在のウインドウ
現在のフレームの可視範囲 Edge, Chrome, FireFox
ブラウザを含むディスプレイ全体 Chrome(Android), Safari(iOS)

(2017/01表を修正)1年ほどこの仕様の動向をウォッチしていましたが、WebDriver界隈の事情でかなりコロコロ変わる印象です。例えばSafari(OS X)ドライバーは最近Appleによるネイティブサポートの恩恵で可視範囲→ページ全体が撮れるようになりましたね。逆にFirefoxは新しいドライバー(GeckoDriver)を開発中のためか、ページ全体→可視範囲しか取れなくなってます。

対策

力技で解決します。
Seleniumは、ブラウザ上で任意のJavaScriptコードを実行できます。
そこで「一枚スクリーンショットを撮って、ウインドウの幅だけスクロール」を繰り返し、
最後にそれらを結合して1枚の大きな画像にします。

(Javaバインディングでの例)

    /**
     * ページの左上から指定された要素までを含むスクリーンショットを撮影し、{@link BufferedImage}として返します。<br/>
     * 要素が指定されていない場合はページ全体を撮影します。
     * 
     * @param params スクリーンショット撮影用パラメータ
     * @return 撮影したスクリーンショット
     */
    @Override
    public BufferedImage getMinimumScreenshot(ScreenshotParams params) {
        // 可視範囲のサイズを取得
        long windowWidth = getWindowWidth();
        long windowHeight = getWindowHeight();

        // 撮影したい要素の位置を取得
        double targetBottom = -1;
        if (params != null) {
            RectangleArea elementArea = params.getTarget().getArea();
            targetBottom = elementArea.getY() + elementArea.getHeight();
        }

        List<BufferedImage> images = new ArrayList<BufferedImage>();
        long currentScrollAmount = 0;
        double captureTop = 0d;
        long scrollTop = -1L;
        double currentScale = Double.NaN;
        int imageHeight = -1;
        int totalHeight = 0;
        try {
            // 次の撮影位置までスクロール
            scrollTo(0d, 0d);
            // Wait until scroll finished
            Thread.sleep(100L);

            // スクロール位置を確認
            long currentScrollTop = Math.round(getCurrentScrollTop());
            while (scrollTop != currentScrollTop) {
                currentScrollAmount = currentScrollTop - scrollTop;
                scrollTop = currentScrollTop;

                // 可視範囲のスクリーンショットを撮影
                BufferedImage image = getScreenshotAsBufferedImage();
                int headerHeight = getHeaderHeight(scrollTop);
                int footerHeight = getFooterHeight(scrollTop, captureTop);
                if (headerHeight > 0 || footerHeight > 0) {
                    // ヘッダ・フッタがあれば切り取る
                    image = ImageUtils.trim(image, headerHeight, 0, footerHeight, 0);
                }

                // 画像のサイズからscaleを計算(初回のみ)
                if (Double.isNaN(currentScale)) {
                    currentScale = calcScale(windowWidth, image.getWidth());
                    scale = currentScale;
                }

                // 次の画像と重なる部分を切り取っておく
                image = trimCaptureTop(captureTop, windowHeight, scale, image);

                // 今回撮った画像をリストに追加
                images.add(image);
                if (imageHeight < 0) {
                    imageHeight = image.getHeight();
                }
                totalHeight += imageHeight;

                // 次のキャプチャ開始位置を設定
                double scrollIncrement = 0;
                if (headerHeight > 0) {
                    // HeaderHeightがある場合、画像の高さからスクロール幅を逆算
                    scrollIncrement = calcScrollIncrementWithHeader(imageHeight, scale);
                    captureTop += scrollIncrement;
                } else {
                    scrollIncrement = calcScrollIncrement(windowHeight);
                    captureTop += scrollIncrement;
                }

                // Targetが写りきっていたら終了
                if (targetBottom > 0 && targetBottom < captureTop) {
                    break;
                }

                // 次の撮影位置までスクロール
                scrollTo(0d, captureTop);
                // Wait until scroll finished
                Thread.sleep(100L);

                // スクロール位置を確認
                currentScrollTop = Math.round(getCurrentScrollTop());
            }
        } catch (InterruptedException e) {
            throw new TestRuntimeException(e);
        }

        // 末尾の画像の重複部分をトリムする
        if (images.size() > 1) {
            BufferedImage lastImage = images.get(images.size() - 1);
            int trimTop = lastImage.getHeight() - (int) Math.round(currentScrollAmount * scale);
            LOG.debug("trimTop: " + trimTop);

            if (trimTop > 0 && trimTop < lastImage.getHeight()) {
                images.set(images.size() - 1, ImageUtils.trim(lastImage, trimTop, 0, 0, 0));
                totalHeight -= trimTop;
            }
        }

        // 全キャプチャを結合
        BufferedImage screenshot = new BufferedImage(images.get(0).getWidth(), totalHeight, BufferedImage.TYPE_INT_RGB);
        Graphics graphics = screenshot.getGraphics();
        int nextTop = 0;
        for (BufferedImage image : images) {
            graphics.drawImage(image, 0, nextTop, null);
            nextTop += image.getHeight();
        }

        return screenshot;
    }

https://github.com/hifive/hifive-pitalium/blob/master/pitalium/src/main/java/com/htmlhifive/pitalium/core/selenium/SplitScreenshotWebDriver.java

AndroidやiOSの場合は、上記にさらにステータスバー等をトリムする処理が加わるイメージです。
これで無事、ひと繋がりのスクショが撮れるようになりました。うれしさ!

ただし、モチベーションで述べた「画像ベースでのテスト」を実現するためには、実はまだ問題が山積みです(主にブラウザ間の実装の違いに起因していて、やっかいです)この問題についてはまた別の機会に説明したいと思います。

宣伝

Seleniumを拡張したPitaliumというツールを作っています。
* さまざまなデバイス・ブラウザで、ページ全体のスクショがきれいに撮れます。
* 画像比較によってテストの合否を判定できます。
* テスト対象ブラウザの情報を外出ししていて、同じテストスクリプトで対象ブラウザのテストを一括実行できます。
ぜひ試してみてください!
Pitalium
github

この投稿は Selenium/Appium Advent Calendar 20155日目の記事です。