Posted at

JestとPuppeteerでお手軽(Visual)レグレッションテスト

More than 1 year has passed since last update.


はじめに

先日、Y Combinatorのデモデーと投資家向けのデモに向けてリリースラッシュがあるもののバグは出すなとのお達しを受けたので、JestとPuppeteerを使ってVisualレグレッションテストと挙動のレグレッションテストを作りました。備忘も兼ねてまとめてみます。


今回使うツール

主に使うものは以下の2点です。PuppeteerとJest自体の説明は割愛します。


jest-puppeteer

2018年3月にリリースされたJestとPuppeteerを連携するためのツールです。

Jestのテスト内でPuppeteerのpageオブジェクトが使えるようになります。またいくつかのmatcherが追加されます。


// フォームを埋める。対象のフォーム及びフィールドが見つからない場合はテストが失敗する

await expect(page).toFillForm('form[name="login-form"]', {
email: 'test@example.com',
password: 'password',
})

// Loginというテキストのボタンをクリックする。対象のボタンが見つからない場合はテストが失敗する
await expect(page).toClick('button', { text: 'Login' })


jest-image-snapshot

AmericanExpress製のVisualレグレッションテストツールです。


// jest-puppeteerにより利用可能になったpageオブジェクトを使ってスクリーンショットを撮る

await page.goto("https://example.com/target/page", { waitUntil: WAIT_UNTIL });
const image = await page.screenshot();

// 今回撮ったスクリーンショットが以前撮ったスクリーンショットとマッチするかテストする
expect(image).toMatchImageSnapshot();

スクリーンショットが一致しない場合はテストを失敗させた上でsnapshot保存先ディレクトリ(デフォルトは<PROJECT_ROOT>/snapshots)内の__diff_output__というディレクトリ内に以下のようなファイルを生成してくれます。

-programs-diff.png


手順


インストール

まずは必要なものをインストールします。

yarn add -D jest puppeteer jest-puppeteer jest-image-snapshot


設定ファイルの作成

まずはPuppeteerの設定を記述するファイルを作成します。


jest-puppeteer-config.js

module.exports = {

launch: {
// デバッグ時は以下をuncommentします
// headless: false,

// デフォルトのChromiumで動かない機能がある場合はChromeの実行ファイルパスを渡すこともできます
// executablePath: "/usr/bin/google-chrome-stable",

// 必要に応じて引数を渡します
args: ["--start-maximized", "--no-sandbox"],
},
};


次にJestの設定ファイルを作成します。preset以外は環境に合わせて適宜設定してください。


jest.config.js

module.exports = {

preset: "jest-puppeteer",
globalSetup: require.resolve("./globalSetup.js"),
setupTestFrameworkScriptFile: require.resolve("./setup.js"),
rootDir: process.cwd(),
testEnvironment: "<rootDir>/__tests__/NgtTestEnvironment.js",
modulePathIgnorePatterns: [
"<rootDir>/__tests__/utils",
"<rootDir>/__tests__/NgtTestEnvironment.js",
],
};

setup.jsでは以下のようにjest-image-snapshotexpect-puppeteer(jest-puppeteer内のパッケージ)のmatherが使えるようにします。


setup.js

const { toMatchImageSnapshot } = require("jest-image-snapshot");

expect.extend({ toMatchImageSnapshot });
module.exports = require("expect-puppeteer");

globalSetup.jsでは環境変数の読み込みを行ってます。これは必須ではないので環境に合わせて適宜設定してください。自分の場合は解像度別にテストを分けてるのでその値を読み込んでいます。


globalSetup.js

const { TEST_ENV } = process.env;

const { setup: setupPuppeteer } = require("jest-environment-puppeteer");
const dotenv = require("dotenv");
module.exports = async () => {
await setupPuppeteer();
dotenv.config({ path: require.resolve("./.env") });
dotenv.config({
// TEST_ENVにはdesktop/phoneなどの値が渡る
path: require.resolve("./environments/" + TEST_ENV + ".env"),
});
};

jest.config.jsで指定したNgtTestEnvironment.jsでは以下のようにjest-environment-puppeteer(jest-puppeteer内のパッケージ)をextendsしたクラスを作り、pageの共通系の処理を書いています。これも必須ではありません。


NgtTestEnvironment.js

const PuppeteerEnvironment = require("jest-environment-puppeteer");

const { PAGE_WIDTH, PAGE_HEIGHT, IS_MOBILE, HAS_TOUCH, IS_LANDSCAPE } = process.env;

class NgtTestEnvironment extends PuppeteerEnvironment {
async setup() {
await super.setup();
await this.global.page.setViewport({
width: parseInt(PAGE_WIDTH, 10),
height: parseInt(PAGE_HEIGHT, 10),
isMobile: !!IS_MOBILE,
hasTouch: !!HAS_TOUCH,
isLandscape: !!IS_LANDSCAPE,
});
}

async teardown() {
await super.teardown();
}
}

module.exports = NgtTestEnvironment;



Visualレグレッションテスト

前置きが長くなりましたが、あとはひたすらテストを書いていきます。VisualレグレッションテストはURL一覧を作って対象のページをテストしていくだけでできます。

まずはページ一覧のファイルを作ります。


page-list.csv

page

/page1
/page1/subpage1

続いてユーザ一覧を作ります。これは一般的にadminユーザや一般ユーザ、またユーザのコンテキストに応じて画面の表示内容が変わるためです。


user-list.csv

type,email,password

user type1,user1@example.com,password1
user type2,user2@example.com,password2

そしてあとはこれらのページ・ユーザに対して以下のようにループでテストを回すだけです。


app.test.js

// 前述のcsvからユーザとページ一覧を読み込む。具体的な処理は割愛。

const { users, pages } = init(
"/scripts/data/visual/user-list.csv",
"/scripts/data/visual/auth-page-list.csv",
);

// 取得したユーザ数とページ数から必要に応じてタイムアウトを設定
// jest.setTimeout(users.length * pages.length * TIMEOUT_PER_PAGE);

// ユーザごとのループ
for (let user of users) {
// ユーザタイプ別にスナップショット保存先を分ける
const snapshotsDir = snapshotsBaseDir + "/" + user.type.replace(/ /g, "-");
describe(user.type, async () => {
beforeAll(async () => {
// ログイン処理(省略)
await login(user);
});
afterAll(async () => {
// ログアウト処理(省略)
await logout();
});
// ページごとのループ
for (let { page: p } of pages) {
it(p, async () => {
// Puppeteerのpage APIを使ってページ遷移
await page.goto(APP_BASE + p, { waitUntil: WAIT_UNTIL });
// スクリーンショットを取得
const image = await page.screenshot({ fullPage: false });
// スクリーンショットを比較
expect(image).toMatchImageSnapshot({
customSnapshotsDir: snapshotsDir,
// URLに含まれるスラッシュをダッシュに変換
customSnapshotIdentifier: p.replace(/\//g, "-"),
// 一定のパーセント(またはピクセル)以下は失敗と見なさないように設定
failureThreshold: SNAPSHOT_FAILURE_THRESHOLD,
failureThresholdType: "percent",
});
});
}
});
}



挙動のレグレッションテスト

こっちはjest-puppeteerが提供するAPIを利用してゴリゴリ書いていくだけなので省略します。

残念ながらSeleniumのようにレコーディングしたりするツールや拡張機能は今の所ないみたいです。(あったら教えてください)


注意点


アプリを書くとき

アプリを書くときは可能な限りdata-testという属性を使ってレグレッションテストから要素を選択できるようにしておきます。


遭遇したトラブル


CentOS上で動かない

EC2上のテスト専用マシンで動かないことがありましたが、こちらを参考にして解決出来ました。具体的には以下の2点で対応できました。


  • Puppeteerのlaunchオプション(今回の場合はjest-puppeteer.config.jsの中に記載)にargs: ["--no-sandbox"]を追加

  • 以下をインストール

sudo yum install pango.x86_64 libXcomposite.x86_64 libXcursor.x86_64 libXdamage.x86_64 libXext.x86_64 libXi.x86_64 libXtst.x86_64 cups-libs.x86_64 libXScrnSaver.x86_64 libXrandr.x86_64 GConf2.x86_64 alsa-lib.x86_64 atk.x86_64 gtk3.x86_64 ipa-gothic-fonts xorg-x11-fonts-100dpi xorg-x11-fonts-75dpi xorg-x11-utils xorg-x11-fonts-cyrillic xorg-x11-fonts-Type1 xorg-x11-fonts-misc -y


パラレルにテストを走らせられない

これは今のところPuppeteerがcontextをサポートするまではJestとPuppeteerのインスタンスを複数立ち上げるしかないと思ってます。


最後に

ということで、今回の仕組みを導入することによりURLがステートレスになっているアプリならURLを並べておくだけでVisualレグレッションテストができるようになります。既存のアプリやテストでJestやPuppeteerを使っていればかなりお手軽に導入できると思います。(ただしマルチデバイス・マルチブラウザ対応する場合は別のツール推奨です)

なお、弊社では複数のサイト・アプリで共通的に利用するコンポーネント用のプロジェクトもあり、そちらはenzymeのsnapshotテスト(とstorybookを使ったデザイナーとの認識合わせ)のみを行っています。そして実際のアプリや各サイトは今回紹介した仕組みでVisualレグレッションテストを行っています。