はじめに
レイアウト変更や項目追加のたびに、代理ログインして同じ画面を開き、キャプチャを撮影して・・・
そこで今回は、テストツールを使ってSalesforceの画面周りのテストを自動化できないか試してみます。
また、この手のものはSeleniumがよく使われると思いますが、Salesforceの開発者にとってはJavaScriptのほうがなじみがありそうですので、JavaScriptの範囲で動かせる「Playwright」というライブラリを触ってみることにしました。
インストール方法
以下の環境で試してみます。
- OS:Windows11
- IDE:VSCode
- npm, nodeのバージョンは以下
>npm -v
11.4.2
>node -v
v22.17.0
playwrightは、以下コマンドでインストールできます。
npm init playwright@latest
そうすると、いろいろ選択肢が出てくると思いますが、全部デフォルトでOKです。
>npm init playwright@latest
Need to install the following packages:
create-playwright@1.17.136
Ok to proceed? (y) y
> npx
> create-playwright
Getting started with writing end-to-end tests with Playwright:
Initializing project in '.'
√ Do you want to use TypeScript or JavaScript? · JavaScript
√ Where to put your end-to-end tests? · tests
√ Add a GitHub Actions workflow? (y/N) · false
√ Install Playwright browsers (can be done manually via 'npx playwright install')? (Y/n) · true
Initializing NPM project (npm init -y)…
最後、このメッセージが出たら準備完了です。
Visit https://playwright.dev/docs/intro for more information. ✨
Happy hacking! 🎭
拡張機能も入れます。 「playwright」で検索して出てくる一番上の「Playwright Test for VSCode」をインストールします。
インストールすると、サイドバーにアイコンが追加されていると思います。
クリックすると、左側にPlaywrightの画面が開きます。
testsのところを開いて、「Run Test」で実行してみると、最初にインストールして追加されたサンプルテストが動くはずです。
ここまで動いていたらインストールは問題なくできているはずです!
テストを作ってみる
インストールできているかの確認ついでに、手始めにPlaywrightにある便利な機能を触ってみたいと思います。左側の「TOOLS」の下に、「Record New」というボタンがあるので、押してみます。すると、Chromeの画面が立ち上がります。
立ち上がったChromeの画面を操作すると、なんと、VSCodeに操作の内容をコードとして自動で記録してくれます。
Chromeの画面を閉じると記録は終了します。VSCodeには、記録したテスト手順が残りますので、先ほどのPlaywrightの画面で実行することができます。
また、Record中に表示されている小さい操作窓で、要素の取得や値のexpectなどをクリックだけで挿入することができます。 この辺りまでの流れは、公式ドキュメントにも詳しく書いてあるのでぜひご覧ください。
テストをやってみる(LWC)
今回、お試しするテスト対象として、
- LWC(適当なカウンターアプリ)
- 標準オブジェクトのレコードの作成、編集
をやってみたいと思います。
まずは、カウンターアプリです。Geminiに投げてそれっぽいものを作らせました。
<template> <lightning-card title="Counter" icon-name="utility:number_input"> <div class="slds-m-around_medium slds-text-align_center"> <p class="slds-text-heading_large">{count}</p> </div> <div class="slds-m-around_medium slds-text-align_center"> <lightning-button label="1" icon-name="utility:dash" onclick={handleDecrement} disabled={disableDecrement} class="slds-m-left_x-small" ></lightning-button> <lightning-button label="1" icon-name="utility:add" onclick={handleIncrement} class="slds-m-left_x-small" ></lightning-button> </div> </lightning-card> </template>
import { LightningElement, track } from 'lwc'; export default class Counter extends LightningElement { @track count = 0; /** * +1ボタンがクリックされたときの処理 */ handleIncrement() { this.count++; } /** * -1ボタンがクリックされたときの処理 */ handleDecrement() { // 念のため、0より大きい場合のみ処理を実行 if (this.count > 0) { this.count--; } } /** * -1ボタンを無効化するかどうかの判定 * countが0の場合にtrueを返す */ get disableDecrement() { return this.count === 0; } }
これを適当にページに置いて、さきほどのRecord Newから適当に操作して、テスト手順を作ります。
(ログイン画面のURL、ユーザー名、パスワードは各自の環境のものを使ってください)
import { test, expect } from '@playwright/test'; test('test', async ({ page }) => { await page.goto('[ログイン画面のURL]'); await page.getByRole('textbox', { name: 'Username' }).click(); await page.getByRole('textbox', { name: 'Username' }).fill('[ユーザー名]'); await page.getByRole('textbox', { name: 'Password' }).click(); await page.getByRole('textbox', { name: 'Password' }).fill('[パスワード]'); await page.getByRole('button', { name: 'Log In' }).click(); await page.getByRole('button', { name: 'App Launcher' }).click(); await page.getByRole('combobox', { name: 'Search apps and items...' }).fill('counter'); await page.getByRole('option', { name: 'Counter' }).click(); await page.getByRole('button', { name: '1' }).nth(1).click(); await expect(page.getByRole('paragraph')).toContainText('1'); await page.getByRole('button', { name: '1' }).nth(1).click(); await expect(page.getByRole('paragraph')).toContainText('2'); await page.getByRole('button', { name: '1' }).first().click(); await expect(page.getByRole('paragraph')).toContainText('1'); await page.getByRole('button', { name: '1' }).first().click(); await expect(page.getByRole('paragraph')).toContainText('0'); await page.getByRole('button', { name: '1' }).first().click(); await expect(page.getByRole('paragraph')).toContainText('0'); await page.getByRole('button', { name: '1' }).nth(1).click(); await expect(page.getByRole('paragraph')).toContainText('1'); });
ここまでざっくり作ったら、以下の手順で少し調整します。(ここは少し面倒です)
- スクリーンショット
以下の関数を呼び出すことで画面全体のスクリーンショットを撮影することができます。
エビデンスを残したいことが多いと思うので、適宜挿入。
await page.screenshot({
path: "./screenshot/test-2-2-fullpage.png",
fullPage: true,
});
- 処理待ち
例えば、ボタンを押すと値が変わるといった単純な挙動でも、ボタンを押した瞬間はまだ値が変わってなく、コンマ何秒か遅れて画面に反映されます。そのため、ボタンを押した後にexpectなりスクショする場合は、以下のように少し待つ処理を入れておかないと失敗します。
await page.waitForTimeout(500);
- タイムアウト
デフォルトではテスト開始から30秒経過するとタイムアウト扱いになります。playwright.config.jsファイルのdefineConfigにタイムアウト時間の設定を追加しておきましょう。
export default defineConfig({
timeout: 120000, // 追加
testDir: './tests',
/* Run tests in files in parallel */
fullyParallel: true,
...
});
最終的に以下のようになりました。
import { test, expect } from '@playwright/test'; test('test', async ({ page }) => { await page.goto('[ログイン画面のURL]'); await page.getByRole('textbox', { name: 'Username' }).click(); await page.getByRole('textbox', { name: 'Username' }).fill('[ユーザー名]'); await page.getByRole('textbox', { name: 'Password' }).click(); await page.getByRole('textbox', { name: 'Password' }).fill('[パスワード]'); await page.getByRole('button', { name: 'Log In' }).click(); await page.getByRole('button', { name: 'App Launcher' }).click(); await page.getByRole('combobox', { name: 'Search apps and items...' }).fill('counter'); await page.getByRole('option', { name: 'Counter' }).click(); await page.waitForTimeout(2000); await page.screenshot({ path: "./screenshot/test-2-1-fullpage.png", fullPage: true, }); await page.getByRole('button', { name: '1' }).nth(1).click(); await page.waitForTimeout(500); await expect(page.getByRole('paragraph')).toContainText('1'); await page.getByRole('button', { name: '1' }).nth(1).click(); await page.waitForTimeout(500); await expect(page.getByRole('paragraph')).toContainText('2'); await page.getByRole('button', { name: '1' }).first().click(); await page.waitForTimeout(500); await expect(page.getByRole('paragraph')).toContainText('1'); await page.getByRole('button', { name: '1' }).first().click(); await page.waitForTimeout(500); await expect(page.getByRole('paragraph')).toContainText('0'); await page.getByRole('button', { name: '1' }).nth(1).click(); await page.waitForTimeout(500); await expect(page.getByRole('paragraph')).toContainText('1'); await page.screenshot({ path: "./screenshot/test-2-2-fullpage.png", fullPage: true, }); });
Show Browserのオプションを有効にして、
テストを実行すると、こんな感じでテストの様子を見ることができます。
問題なく動いていそうです。
続いて、標準機能も試してみます。 リードの画面にいって、新規リードの作成をして、編集をして、みたいなのをやってみました。
ポイントごとにスクショを取って、少し調整をして、最後まで通ったのですが・・・
import { test, expect } from '@playwright/test'; test('test', async ({ page }) => { await page.goto('[ログイン画面のURL]'); await page.getByRole('textbox', { name: 'Username' }).click(); await page.getByRole('textbox', { name: 'Username' }).fill('[ユーザー名]'); await page.getByRole('textbox', { name: 'Password' }).click(); await page.getByRole('textbox', { name: 'Password' }).fill('[パスワード]'); await page.getByRole('button', { name: 'Log In' }).click(); await page.getByRole('button', { name: 'App Launcher' }).click(); await page.getByRole('option', { name: 'Sales', exact: true }).click(); await page.waitForTimeout(2000); // Salesの画面に遷移するまで待機 await page.getByRole('link', { name: 'Leads' }).click(); await page.getByRole('button', { name: 'New' }).click(); await page.getByRole('combobox', { name: 'Salutation' }).click(); await page.getByRole('option', { name: 'Mr.' }).click(); await page.getByRole('textbox', { name: '*Last Name' }).click(); await page.getByRole('textbox', { name: '*Last Name' }).fill('Test'); await page.getByRole('textbox', { name: 'First Name' }).click(); await page.getByRole('textbox', { name: 'First Name' }).fill('Playwright'); await page.getByRole('textbox', { name: '*Company' }).click(); await page.getByRole('textbox', { name: '*Company' }).fill('terrasky'); await page.getByRole('textbox', { name: 'Title' }).click(); await page.getByRole('textbox', { name: 'Title' }).fill('manager'); await page.getByRole('combobox', { name: 'Rating' }).click(); await page.getByRole('option', { name: 'Hot' }).locator('span').nth(1).click(); await page.getByRole('textbox', { name: 'Phone' }).click(); await page.getByRole('textbox', { name: 'Phone' }).fill('00-0000-0000'); await page.getByRole('textbox', { name: 'Mobile' }).click(); await page.getByRole('textbox', { name: 'Mobile' }).fill('080-0000-0000'); await page.getByRole('button', { name: 'Save', exact: true }).click(); await expect(page.getByText('Lead "Test Playwright" was created.', { exact: true })).toContainText('Lead "Test Playwright" was created.'); await page.waitForTimeout(3000); await page.screenshot({ path: "./screenshot/test-3-1-fullpage.png", fullPage: true, }); await page.getByRole('button', { name: 'Edit', exact: true }).click(); await page.getByRole('textbox', { name: 'Fax' }).click(); await page.getByRole('textbox', { name: 'Fax' }).fill('00-1111-2222'); await page.getByRole('combobox', { name: 'Salutation' }).click(); await page.getByRole('option', { name: 'Dr.' }).locator('span').nth(1).click(); await page.getByRole('combobox', { name: 'Lead Source' }).click(); await page.getByRole('option', { name: 'Web' }).locator('span').nth(1).click(); await page.getByRole('combobox', { name: 'Lead Source' }).click(); await page.getByRole('option', { name: 'Web' }).locator('span').nth(1).click(); await page.getByRole('combobox', { name: 'Industry' }).click(); await page.getByText('Entertainment').click(); await page.getByRole('button', { name: 'Save', exact: true }).click(); await expect(page.getByText('Lead "Test Playwright" was saved.', { exact: true })).toContainText('Lead "Test Playwright" was saved.'); await page.waitForTimeout(3000); await page.screenshot({ path: "./screenshot/test-3-2-fullpage.png", fullPage: true, }); await page.getByRole('tab', { name: 'Details' }).click(); await page.getByRole('button', { name: 'Edit Description' }).click(); await page.getByRole('textbox', { name: 'Description' }).fill('Test Description'); await page.getByRole('button', { name: 'Save' }).click(); await page.waitForTimeout(3000); await page.screenshot({ path: "./screenshot/test-3-3-fullpage.png", fullPage: true, }); });
登録完了
編集完了
最後に全体キャプチャ、と思ったら画面が崩れています・・・
最後の画面がうまく取れませんでした・・・
標準画面でフルスクリーンで撮ろうとすると、描画が追い付いていなさそうですね。
full screenのオプションを外しておけば問題なく撮れていそうなので、適宜使い分ける必要がありそうです。
おまけ
また、playwrightの便利機能の一つとして、VRT(Visual Regression Test)も試してみます。
テストの最後に、以下の関数呼び出しを挿入します。
(本質はtoHaveScreenshotで、maskは一旦おまじないだと思ってください!)
await expect(page).toHaveScreenshot({
mask: [
page.getByRole('listitem').filter({ hasText: 'Created By' }).locator('span').nth(1),
page.getByRole('listitem').filter({ hasText: 'Last Modified By' }).locator('span').nth(1)
]
});
VSCode上で、「Show trace viewer」を有効、「Update snapshots」をallに設定して一度テストを実行します。
このあと、少し画面のレイアウトを変えて、「Update snapshots」をNoneに変更して、もう1回テストを実行すると・・・
エラーが発生します!
このとき、playwright-reportフォルダにたくさんファイルが生成されていると思います。 これは、テストの実行結果がまとまったフォルダで、以下のコマンドをターミナルで実行すると見ることができます。
npx playwright show-report [playwright-reportフォルダのパス]
実行したテストの詳細画面に移動すると、以下のようにテスト失敗の内容が出力されています。
「・・・ are different」とあるように、先ほどのtoHaveScreenshotは、そのタイミングでの、前回のキャプチャと比較をして、差分があればテスト失敗になります。
ただ、「差分があると」というところが少しやっかいで、レコードの作成日時や更新日時はテストのたびに変わってしまうため、オプションでmaskをして差分比較対象から除外する必要があります。
こんな感じで、項目の場所を少し変えたのですが、差分としてはっきりでているのが分かると思います。
項目を変えたところが一目で分かる!
課題
ざっくり良い点としては、
- 一度準備できれば、人間が操作するより何十倍も速くエビデンスを撮れる
- JSで書かれているため、LWC開発経験があればとっかかりやすい
- VSCodeの拡張機能が使いやすい
- 公式ドキュメントがちゃんとしていそう(全部英語ですが)
といったところでしょうか。逆に、使っていて課題かなと思ったところは、
- Record New中やテスト中はかなり重くなる
- 複雑な操作をする場合、テスト手順や期待値を微調整する必要がある
になります。
じゃあ本当に実運用するとしたら?として考えたのですが、プロファイルごとに画面レイアウトを確認したいときにループでエビデンス回収すると爆速になりますし、項目に変更がかかったりしてエビデンス再回収しないといけないときでもすぐ実行できるので、使いどころはあるんじゃないかなと思いました。
まとめ
他にもいろいろ機能がありそうなので、ぜひインストールして公式ドキュメントを見ながら試してみてください!