毎日1時間でWebページ1,000以上のうち1pxの変化を逃さない、ソフトバンクのE2Eビジュアルリグレッションテスト取り組みについて

2022年12月11日掲載

キービジュアル

ソフトバンククラウドエンジニアリングでは、人間の代わりに自動で毎日1時間でWebページ1,000ページ以上のうち1pxの変化を逃さない、E2Eビジュアルリグレッションテストを行っていますので、その事例をご紹介します。

これはソフトバンク Advent Calendar 2022の11日目の記事です。

目次

  • E2Eビジュアルリグレッションテストに関する記事です
  • 理論だけで無く、実際に実装する方法を記載しているので、実装に関する知識を取得できます

はじめに

私の部署ではテクニカルサポート業務として、日頃からパブリッククラウドサービスに対するお客様の様々な問い合わせを丁寧に調査し、まとめながら回答する取り組みをしており、同時にパブリッククラウドサービスを通じて新しい技術要素などの取得・サービス改善や情報発信に励んでいます。 そうした活動をもっと外部へ発信しよう、というのが本記事の趣旨となります。

私が担当するミッションの1つに、E2Eビジュアルリグレッションテスト(Visual Regression Testing)がありますので、今回はそれを紹介します。E2Eビジュアルリグレッションテストは、Webブラウザで変更前のスクリーンショット画像(前日データ)と変更後のスクリーンショット画像(当日データ)を取り比べながら、画像比較し、差分を検知するテスト手法です

主要パブリッククラウドでコンソールなどの仕様が変更されると、社内外問わず現在運用中の様々なサービスに色々な影響が発生します。例えば、クラウドサービスで仕様が変更された際、それがHelpページやリリースノートに載っていないもの、すなわち予告なしの仕様変更だったらどうしますか?

Alibaba Cloud、AWS、Azure、Google Cloudらクラウドサービスはいつも変化が速く、リリースノートやHelpドキュメントに載っていない仕様変更もいくつか存在します。私たちのチームはこういったクラウドサービスの予告なし仕様変更の対策として、サービスの仕様変更の自動検知や障害自動検知などを図っています。その検知結果はお客様のサービス支障にも繋がっていくので、パブリッククラウドのリセラーとしてアフターフォローアップを行っています。

1.全体構成

E2Eビジュアルリグレッションテストには市販中のAutify やオープンソースCypressPlaywrightCodeceptJSStorybookなどがあります。基本的にはどれを選んでもよいですが、今回は「主要クラウドサービスのコンソール(Webページ)全てを監視するため、サイト全体で1,000ページ以上もあるページを短時間以内に処理すること」「自分でコードによるカスタマイズができること」「サブページのURL自動取得」「ログイン処理時に二段階処理機能を自動処理すること」が必須要件だったので、それを満たすツールはまだ存在しないこともあり、社内でNode.js およびPuppeteerでゼロベースから開発しました。

全体図は次の図通りです。基本的にサーバレス構成です。Alibaba Cloud のサーバレスプロダクトサービスのFunction Computeを選んだ理由は、メモリが32GBと大きく、タイムアウト時間がAlibaba Cloud Function Computeだけ1日もあるため、時間に余裕を持たせながらWebスクレイピング等処理ができるためです。AWSなどはメモリが3GB、タイムアウトはせいぜい15分です。

流れとしては以下の通りになります。

①Alibaba Cloud Serverless Workflow というサーバレスワークフローを使って、複数のFunction Computeと連携しながらパイプラインを構築し、毎日タイマートリガー経由でコードを実行します。Alibaba Cloud Serverless Workflow は AWSだとAWS Step Functionsに該当します。

②Serverless Workflow 配下にある、複数のFunction Computeにて、コンソール画面のURL情報を読み取りながら、コンソール画面全ての画像をスクリーンショットし、オブジェクトストレージ(Object Storage Service (OSS))へ格納。

③ここもFunction Computeを使って、OSSに格納した画像データを使って画像比較。差分が大きいものはリストアップし、比較処理が終わったらリスト一覧をチャットツールへ自動通知。

E2Eのビジュアルリグレッションテストの結果として、毎日クラウドサービスのコンソールから、このような前日と当日の画像差分データを自動通知してくれます。

2.なぜNode.jsおよびPuppeteerを選んだのか?

E2Eビジュアルリグレッションテストといえば、PythonのSeleniumが思いつきますが、今回の要件からNode.jsおよびPupperteerを選定しています。理由は以下の通りです。

 

  • Google Chromeをより少ないメモリでより速く操作しながら、より柔軟かつ自由にE2Eテストが行えること
  • WebサイトがReact、Angular、など、どのスタック/フレームワークでも動作ができること(厳密には、DOMによる、操作の段階でHTMLコードが変わる構成でも柔軟に対応できるなど)
  • Google Chrome APIを併用してシームレスに操作できること。例えばruntimeという異なるjsソース間でのメッセージやり取りをコントロールするなど。
  • Node.jsはChrome の V8 エンジンに基づいているため、Pythonより少ないCPU/メモリ/IOリソースでありながらPythonより数百倍早い。
  • PythonのSeleniumでもWebページのフルページスクリーンショット取得が可能ですが、Seleniumは取得対象の領域がviewportに制限されてしまう問題があります。そのため、Seleniumでフルページスクリーンショットを実現するためには、スクロールしながら複数枚のスクリーンショットを撮った後、1枚につなぎ合わせるといった実装が必要になり工数がかかる。

SeleniumはPythonベースなので、ネット上の情報量の豊富さから非エンジニアでもメンテナンスがしやすく、非常に便利です。一方、Pythonベースなのか、Webサイトで多数のページ画面を保存する際、フルページのスクリーンショット時には幾つかの制約事項があること、パフォーマンスや自由度、工数削減の観点で今回はNode.jsおよびPuppeteerを選びました。

以下、技術要素を検討したときにまとめた比較表です。

 

Puppeteer

Selenium

対応言語

Node.js

Python、Java、JavaScropt

ブラウザ互換性

ChromeもしくはChromiumのみサポート

Chrome、FireFox、Opera、IEなどほとんどのブラウザをサポート

Webサイトで単独処理で1,000ページを画像保存したときの処理スピード

23.3s
非常に高速

1693.6s(28分)
非常に遅め

クロスプラットフォームのサポート

並列処理をサポート

標準でサポート

WebブラウザでテキストからDOM要素を取得

〇(React、Angular、などにも対応)

その都度一部改修が必要

フルページスクリーンショット

簡単に実現可能

viewportに制限されてしまう問題あり

関連記事リンク

3.Puppeteerによるページ操作

本件、パブリッククラウドサービスのコンソール向けにE2Eビジュアルリグレッションテストとして、いくつかテクニックを使っていますので、その一部をご紹介します。

  • Webページをフルページでスクリーンショット

Puppeteerでは、Webページのフルページスクリーンショットをする際、page.waitForNavigation() 関数の条件として、networkidle2(ネットワークコネクション数が2個以下である状態が500ミリ秒続いたとき)を 指定しています。 ただしウェブサイトによっては、 画像ロード以外のネットワーク接続の影響でこの条件が恒久的に満たされないため、timeoutオプション(デフォルトは5000sec)を明示的に指定しています。scrollToBottom()関数の中ではwhileループの中でスクロールしながら毎回scrollHeightの値を更新しています。 これは、スクロール操作によってページのscrollHeightの値が動的に変わる場合があるためです。

async function scrollToBottom(page, viewportHeight) {
    const getScrollHeight = () => {
        return Promise.resolve(document.documentElement.scrollHeight)
    }
 
    let scrollHeight = await page.evaluate(getScrollHeight)
    let currentPosition = 0
    let scrollNumber = 0
 
    while (currentPosition < scrollHeight) {
        scrollNumber += 1
        const nextPosition = scrollNumber * viewportHeight
        await page.evaluate(function (scrollTo) {
            return Promise.resolve(window.scrollTo(0, scrollTo))
        }, nextPosition)
        await page.waitForNavigation({ waitUntil: 'networkidle2', timeout: 5000 })
            .catch(e => console.log('timeout exceed. proceed to next operation'));
 
        currentPosition = nextPosition;
        scrollHeight = await page.evaluate(getScrollHeight);
        console.log(format('Scroll operation with scroll number: {0}, current position: {1}, scroll height: {2}', scrollNumber, currentPosition, scrollHeight));
    }
}
  • Bot検出対策

クラウドサービスのサイト側による監視として、WebスクレイピングによるBot判定をされにくくするために、 puppeteer-core の代わりに puppeteer-extra を使用しています。puppeteer-extrapuppeteer-extra-plugin-stealthを入れると、公開されているすべてのBotテストをクリアすることができます。

// puppeteer-extra は、puppeteer のドロップイン代替品です。
// インストールされているpuppeteerにプラグイン機能を追加するものです。
https://www.npmjs.com/package/puppeteer-extra-plugin-stealth
const puppeteer = require('puppeteer-extra');
// ステルスプラグインを追加し、デフォルト(全回避)を使用する。
const stealthPlugin = require('puppeteer-extra-plugin-stealth');
puppeteer.use(stealthPlugin());
  • タイムアウトのデフォルト設定の設定

処理の過程にて、タイムアウトに関する以下のエラーがいくつか発生していました。

pyppeteer.errors.TimeoutError: Navigation Timeout Exceeded: 30000 ms exceeded

これを解決するには、デフォルトのタイムアウト設定または特定の操作のタイムアウト設定を更新する必要があります。その値は、テスト結果に基づいて設定する必要があります。

page.setDefaultNavigationTimeout(commonConfig.defaultTimeout);

この場合、デフォルトのタイムアウト設定を変更するだけです。この設定は、以下の記述でタイムアウトの設定に影響を与えます。

  • page.goto(url, options)
  • page.goBack(options)
  • page.goForward(options)
  • page.reload(options)
  • page.waitForNavigation(options)

 

  • iFrameを変更

対象要素がメインフレームの下にない場合は、要素を検索する前に iFrame を変更する必要があります。必要なフレーム名は、設定ページリストで定義しておく必要があります。何らかの理由でページ構造が更新される可能性があるため、エラーを回避するために、スクリプトは必要に応じてページ内のすべての既存フレームからターゲット要素を検索します。

let elements = null;
if (items[4] === '0') {
    elements = await page.$x(tmpElementXPath);
} else {
    console.log(format('get element from frame of : {0}', items[4]));
    let frame = page.frames().find(frame => frame.name() === items[4]);
    elements = await frame.$x(tmpElementXPath);
}
console.log(format('found element or not : {0}', (elements.length > 0)));
if (elements.length === 0) {
    for (let tmpFrame of page.frames()) {
        elements = await tmpFrame.$x(tmpElementXPath);
        if (elements.length > 0) {
            break;
        }
    }
}
  • ページ内の無駄な部分を削除

クラウドサービスのコンソールのトップバーのように、すべてのページコンテンツがE2Eビジュアルリグレッションテストの範囲に含まれないこともあります。そのために、スクリーンショットをキャプチャする前に、対象となる要素を見つけ、Puppeteerで非表示にします。

この機能は、重要でない画像バナーや、ページ内で自動的に動くフローティングコンポーネントなど、画像比較結果に影響を与えるコンポーネントを処理するために使用しています。

/**
 * スクリーンショットの前の無駄なdivセクションを削除
 *  @param {*} page
 */
async function removeUselessSectionBeforeScreenshots(page) {
    let uselessSections = commonConfig.hiddenDiv;
    for (let section of uselessSections) {
        var tmpElement = await page.$(section);
        if (tmpElement) {
            await tmpElement.evaluate((el) => el.style.display = 'none');
        }
    }
}
  • HTML要素に含まれているテキスト値の読み込み

コンソールのそれぞれのページにはプルダウン選定や日付等テキスト値を入力しないと、ページ遷移しなかったりする箇所があります。そのため、対象となるHTML要素にテキスト値を入れることで、HTML要素からテキスト値を取得します。単一の要素に対しては、 `page.$eval()` 関数を直接使用します。

var pageProductName = await page.$eval(commonConfig.pageProductName, el => el.innerHTML);
var pageUpdateDate = await page.$eval(commonConfig.pageUpdateDate, el => el.innerHTML);

対象領域内のすべての<a>タブのような、一連の要素については、代わりに以下の関数を使用します。areaパラメータは、PuppeteerのElementHandleのインスタンスである必要があります。

/**
 * リンクリストがある場合、ターゲットエリアから取得
 *  @param {*} page
 *  @param {*} area
 *  @returns
 */
async function getLinkListInTargetArea(page, area) {
    // ページからの関連リンクの取得
    let arrayList = await page.evaluate((area) => {
        let nodeListLinks = area.querySelectorAll('a'),
            array = [...nodeListLinks],
            list = array.map(({ innerText, href }) => ({ innerText, href }))
        return list;
    }, area);
    return arrayList;
}

4.URLの自動取得

コンソール内部のURL一覧情報は、コンソールの親ページから、HTML要素の配下にあるリンク集を再帰的に拾い、そこから順次ページ遷移する仕組みです。そのため、1つのURLを入力するだけで、コンソール配下の数多くのサブページのURLを自動収集しながら、結果として1,000ページを超える全てのコンソールページでE2Eビジュアルリグレッションテストをすることができます。

sync function checkMenuLinks(page, url, areaPath = null, retry = commonConfig.requestRetryTimes) {
    try {
        // Go to target page
        await page.goto(url, { waitUntil: 'networkidle0' });
        // Prepare checking area
        if (areaPath == null) {
            var area = await page.$('body');
        } else {
            var elements = await page.$x(areaPath);
            var area = elements[0];
        }
        // Sometimes the doc link is not ready for reading, such as IDaaS https://www.alibabacloud.com/help/en/idaas
        if (elements.length === 0 || area == undefined) {
            return [];
        }
        // Click expand all icon
        await expandAllIcons(page);
 
        // Click other expand icons if any
        await expandAllIconsInTargetArea(area);
 
        // Checking from the page
        let arrayList = await getLinkListInTargetArea(page, area);
 
        return arrayList;
    } catch (err) {
        console.log(format('Error occurred when checking links with retry opportunities: {0}', retry));
        console.log(err);
        if (retry === 0) {
            console.log('No retry opportunities!');
            return [];
        } else {
            console.log('Retrying!');
            await checkMenuLinks(page, url, areaPath, retry - 1);
        }
    }
}

リンクリストがある場合、ターゲットエリアから取得する際は次のようなコードを実装しています。

/**
 *  @param {*} page
 *  @param {*} area
 *  @returns
 */
 async function getLinkListInTargetArea(page, area) {
    // Get related links from the page
    let arrayList = await page.evaluate((area) => {
        let nodeListLinks = area.querySelectorAll('a'),
            array = [...nodeListLinks],
            list = array.map(({ innerText, href }) => ({ innerText, href }))
        return list;
    }, area);
    return arrayList;
}

5.自動ログイン対策

コンソールログイン時に、MFA(多要素認証)や二段階認証、reCAPTCHA認証(画像認証)などがありますが、今回はスライダーによる認証を予め設定することで、Pupperterによる自動ログインおよび自動Slider処理で、あたかも人間が処理したかのような操作を実施しています。

具体的には、Sliderのイベント操作が必要になったとき、次のコードを実行しながらslider.sikuliを使ってスライダー処理します。

const cmd = format('java -jar {0} -r ./src/sikuli/slider.sikuli', commonConfig.sikuliLocal);

もしMFA(多要素認証)やreCAPTCHA認証など他認証による認証要求があっても、本ツールはMFAやreCAPTCHA認証を自動処理するスクリプトも備えているため、柔軟に対応することができます。

関連記事リンク

6.画像比較

画像比較はOpenCVを使って比較検知処理をします。
比較対象の画像ファイルそれぞれが同じサイズであることを前提に、それぞれの画像のヒストグラムを上げながら、GaussianBlurで画像をぼかすことでノイズを排除し、それぞれの画像で輪郭抽出によるpixel単位での比較をします。このアプローチを選んだ理由はWebサイトに予め設定されているフォントタイプが閲覧する国(CDN)によって切り替えされるため、フォントタイプが変わることによるピクセル単位でのズレを排除するためです。

try:
    stream_one = tmp_bucket.get_object(path_one)
    stream_two = tmp_bucket.get_object(path_two)
    file_bytes_a = np.asarray(bytearray(stream_one.read()), dtype=np.uint8)
    file_bytes_b = np.asarray(bytearray(stream_two.read()), dtype=np.uint8)
    img1 = cv2.imdecode(file_bytes_a, cv2.IMREAD_GRAYSCALE)
    img2 = cv2.imdecode(file_bytes_b, cv2.IMREAD_GRAYSCALE)
 
    clahe = cv2.createCLAHE(clipLimit=10.0, tileGridSize=(10, 10))
    img1 = clahe.apply(img1)
    img2 = clahe.apply(img2)
 
    img1 = cv2.GaussianBlur(img1, (13, 13), 0)
    img2 = cv2.GaussianBlur(img2, (13, 13), 0)
 
    diff = cv2.absdiff(img1, img2)
    ret, diff = cv2.threshold(diff, 60, 255, cv2.THRESH_BINARY)
    diff = cv2.GaussianBlur(diff, (11, 11), 0)
 
    is_diff = False
    img3 = cv2.imdecode(file_bytes_a, cv2.IMREAD_COLOR)
    img1 = cv2.imdecode(file_bytes_a, cv2.IMREAD_COLOR)
    img2 = cv2.imdecode(file_bytes_b, cv2.IMREAD_COLOR)
    _, contours, hierarchy = cv2.findContours(diff, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
 
    for threshold in thresholds:
        if not is_diff:
            for c in contours:
                x, y, w, h = cv2.boundingRect(c)
 
                if w > threshold and h > threshold:
                    print('Diff! >>>>> threshold:{0}'.format(threshold))
                    is_diff = True
                    cv2.rectangle(img3, (x, y), (x + w, y + h), (0, 0, 255), 2)
                    text_x, text_y = get_tips_position(x, y, w, h)
                    cv2.putText(img3, "HERE!", (text_x, text_y), cv2.FONT_HERSHEY_SIMPLEX, 1.5, (0, 0, 255), 2)
 
                    print('Target images have differences, save the differences as image: {0} and {1}'.format(path_one, path_two))
                    # 結果画像フォーマットの更新
                    _, img_buf = cv2.imencode('.png', img3)
                    save_to_oss_single(tmp_bucket, img_buf, diff_img_location.format(threshold))
                    break
    if not is_diff:
        print('Target images are same: {0} and {1}!'.format(path_one, path_two))
    return is_diff
except Exception as e:
    print(e)
    return False

結果、コンソール画面にある日付などの日次変更情報など、人間による目視では検知(識別)しにくい情報も、漏れなく検知することができています。

7.カスタムコンテナによるサーバレスサービスの展開

本件E2Eビジュアルリグレッションテストツールは他クラウドや他サービスでも適用できるように、オープンソースのServerless Devsを使ったカスタムコンテナ構成にしています。Serverless Devsを使うことで、Alibaba Cloud以外、AWSやAzure、Google Cloudにも同じ構成でサーバレスサービスをデプロイすることができます。Serverless Devsによるデプロイ方法は、こちらのblogを参考に頂ければ幸いです。

8.E2Eビジュアルリグレッションテストの取り組み効果について

本件E2Eビジュアルリグレッションテストの過程で、次のような多くの有用な情報を得ることができています。

  • ローカライズエラー

  • ページ読み込みエラー
  • cssの表示エラー
  • テキスト設定エラー
  • ページ構成の変更(利用可能なRegionなど)

工数削減を目線とした、数値的な結果はどうでしょうか?例えば、E2Eビジュアルリグレッションテストの対象のページが全部で1,000ページだった場合とします。

導入前)

コンソールページの比較検知作業:1000ページ × 1ページあたり3分 = 50時間の目視作業

導入後)

コンソールページの比較検知作業:1000ページを1時間の自動処理

工数削減時間)

コンソールページの比較検知作業:

50時間×20営業日×6か月  -  1時間×20営業日×6か月

= 6000時間  -  120時間
=  5880時間(6か月) = 980時間(1ヶ月)
=  6.3人工数(1ヶ月)
※1人工数7.75時間×20営業日=155時間とする

総計)

6.3 人工数 = 1人当たりの人件費 最低20万円と見積もっても毎月100万円以上分の価値を創出できたことになります。

9.E2Eビジュアルリグレッションテストの難しさ

Kent C. Dodds が提唱するThe Testing Trophyというものがあります。

The Testing Trophyはテストピラミッドに似た概念ですが、Webブラウザ - フロントエンド開発における各種テストのコスト高さ(実行速度、開発、運用工数)に加えて、各テストの効果(どれほど大きな問題を解決できるか)を示した図です。

  • 下位レイヤーは単体テストや局部的なユニットテストで、素早く対応できますが解決できる問題も限られており、Webブラウザ全体からみた信頼性も弱いです。
  • 上位レイヤーへ行けば行くほど解決できる問題が多く、信頼性が大幅に向上できます。
  • 各レイヤーで横に広げれば広いほど、カバーできる範囲が広くなります。

WebブラウザのE2Eテストはカバーする範囲が広く、実際にユーザーのシナリオに沿ったテストを行うため、時間と労力、そしてコストがかかるため、トレードオフとして何かを犠牲にしなければならない、という考えがあります。

そういう意味でE2Eビジュアルリグレッションテストはエンジニアリングリソースを含めバランスを取るのが難しい領域です。

ましてパブリッククラウドサービスのコンソール画面を全てE2Eビジュアルリグレッションテストするのは非常に難易度が高く、先述通り市販中のAutify やオープンソースCypress、Playwright、CodeceptJS、reg-suit、storybook、Chromatic、playwrightなどの機能・仕様にはない要件が多数ありました。

そのため、ソフトバンクのクラウドエンジニアリングとして最初から一貫して、できるだけ短時間で、(フルサーバレスで)できるだけ低コストで、全てのページで、ユーザーシナリオに沿った挙動アクションを想定したわずかな変更を逃さないコンセプトのもと、このようなE2Eビジュアルリグレッションテストをゼロベースから2か月で構築し、この構成で1年以上ずっとフル活用しています。

なので、この記事を通じて、Webブラウザのテスター皆様の安全なUI管理に少しでも貢献できれば幸いです。

10.さいごに

パブリッククラウドサービスのコンソール画面の変化を素早く検知し、それを通知する取り組みはとても労力を要する活動になります。例えば、毎日クラウドサービスのコンソールへ自動ログインおよび二段階認証の自動処理、80を超えるプロダクトサービスから、1,000を軽々と超える様々なコンソールページを全てスクリーンショットし、できるだけ短時間以内に画像比較検知処理するのは正直骨が折れると思います。そのためにソフトバンクのクラウドエンジニアリングはこのように毎日E2Eビジュアルリグレッションテストを通じて、パブリッククラウドサービスのコンソール画面の僅かな変更を自動検知しながら、有事の際はお客様やクラウドサービスベンダーへ通知・連携を行っています。こういう部分がソフトバンクによるクラウドサービスのリセラーにおけるの一つの魅力ともいえます。

では、ソフトバンク Advent Calendar 2022 の 12日目にバトンを渡します。

おすすめの記事

条件に該当するページがございません