前回の記事で、Playwrightを使ってDjango + ReactのE2Eテストができると分かりました。
WSL2 + Playwrightな環境にて、codegen機能によりReactアプリやDjango管理サイト向けのテストコードを自動生成してみた - メモ的な思考的な
前回は自動生成したテストコードを使っていたことから、今度は自分でPlaywrightによるE2のテストコードを書いてみたくなりました。
そこで色々と試したときのメモを残します。
なお、この記事ではPlaywrightの書き方に着目するため、Djangoアプリの実装については解説が必要な部分のみとしています。
もしソースコード全体を参照したい場合は、記事の末尾にあるソースコードを合わせてご確認ください。
また、この記事はPlaywrightの使い方が中心です。そのため、要素の特定については、深く考えずにid属性を使用しています。
ただ、実際のE2Eテストでは、要素の特定にid属性を使用するのではなく、「テキスト」や「data-testid
などのdata-*属性」を使用したほうが良いようです。
そのため、E2Eテストの設計段階にて、要素の特定には何を使うか検討・認識合わせが必要でしょう。
目次
- 環境
- 基本的な書き方
- Webアプリの挙動確認
- Reactの挙動を検証
- Playwrightの便利な機能について
- その他ドキュメントなど
- ソースコード
環境
Django・React・Playwright環境は前回のものに加え、必要なパッケージを追加しています。
- Django環境
- React 環境
- React 18.2.0
- React Router 6.4.4
- Vite 3.2.3
- TypeScript 4.6.4
- Playwright環境
ちなみに、今回はplaywright用のDjangoアプリを新規作成して試します。
$ python manage.py startapp playwright
なお、過去のWebの記事ではJestと組み合わせるために jest-playwright
を使うものもありました。
playwright-community/jest-playwright: Running tests using Jest & Playwright
ただ、現在のPlaywrightでは、Jestのようなテストランナーがなくても、Playwright単独でテストコードを書いたり実行できます。
基本的な書き方
初めてのテストを書いてみる
Playwrightのドキュメントには First test
があります。これを参考に手元のDjangoアプリでも書いてみます。
ここで使うHTMLはこんな感じで、 http://localhost:8000/playwright/locator
で提供されているとします。
<p data-p="1" id="id_locator">by id</p>
このページに id
が id_locator
なものがあり、テキストが by id
であることを確認するテストを書くとこんな感じになります。
import { test, expect } from '@playwright/test'; test.describe('最初のテスト', () => { test('idをキーに取得できること', async ({ page }) => { // ページへ遷移 await page.goto('http://localhost:8000/playwright/locator') // locatorオブジェクトを取得 const result = page.locator('#id_locator') // テキストを持っているか確認 await expect(result).toHaveText('by id') }) })
テストコードは以下の流れになっています。
test('idをキーに取得できること', async ({ page }) => {}
が1つのテスト({ page })
で、テストコードの中で使える事前に用意されている値 (fixture) を取得- pytestのfixtureのようなものという印象
- https://playwright.dev/docs/api/class-fixtures
- pageオブジェクトの
goto()
メソッドで、該当のページへ遷移 - pageオブジェクトの
locator()
メソッドで、検証したい要素を取得 - LocatorAsserionsの
toHaveText()
で、要素にあるかを検証
過去にSeleniumをさわっているのであれば、似たような感じテストコードが書けそうなでした。
before/afterについて
テスティングフレームワークでは、「各テストの実行前後に何か処理を行いたい」という時に、 before
や after
のようなものが用意されています。
Playwrightでも、 beforeAll
や afterAll
、 afterAll
や afterEach
が用意されています。
- https://playwright.dev/docs/api/class-test#test-before-all
- https://playwright.dev/docs/api/class-test#test-before-each
そこで、初めてのテストで書いていた page.goto()
を beforeEach
に抜き出してみます。
test.describe('beforeEachを使う', () => { test.beforeEach(async ({ page }) => { await page.goto('http://localhost:8000/playwright/locator') }) test('idをキーに取得できること', async ({ page }) => { const result = page.locator('#id_locator') await expect(result).toHaveText('by id') }) })
これで、各テストの実行前に指定のページヘと遷移することができるようになり、各テストでの記述が不要になりました。
Locatorについて
初めてのテストでは page.locator()
で、検証対象の要素(Locator
)を取得していました。
Locatorを取得するには locaotr()
メソッドの他、Quick Guideで書かれているメソッド群(例: getGByRole()
や getByText()
)が使えます。
https://playwright.dev/docs/locators#quick-guide
また、 locator()
メソッドでは、CSSやXPath、experimentalながらReact locatorなども指定できます。
https://playwright.dev/docs/other-locators
なお、deprecated なメソッド $()
や $eval()
があることに注意します。
https://playwright.dev/docs/api/class-page#deprecated
例えば、以下のようなHTMLがあった場合、
<div id="locators"> <p data-p="1" id="id_locator">by id</p> <p data-p="2" class="class_locator">by class</p> <p data-p="3" data-testid="test_id_locator">by test id</p> <p data-p="4" role="note">by ARIA role</p> <p data-p="5">by Text</p> <form> <label for="label_locator">by label</label> <input type="text" id="label_locator" value="ラベル" /> <label for="placeholder_locator">by Placeholder</label> <input type="text" id="placeholder_locator" placeholder="紅玉" value="シナノゴールド" /> </form> </div>
次のようなPlaywrightのコードでLocatorを取得・検証ができます。
test.describe('Locatorを一致で取得', () => { test.beforeEach(async ({ page }) => { await page.goto('http://localhost:8000/playwright/locator') }) test('idをキーに取得できること', async ({ page }) => { const result = page.locator('#id_locator') await expect(result).toHaveText('by id') }) test('classをキーに取得できること', async ({ page }) => { const result = page.locator('.class_locator') await expect(result).toHaveText('by class') }) test('test-idをキーに取得できること', async ({ page }) => { const result = page.getByTestId('test_id_locator') await expect(result).toHaveText('by test id') }) test('ARIAのroleをキーに取得できること', async ({ page }) => { const result = page.getByRole('note') await expect(result).toHaveText('by ARIA role') }) test('data属性をキーに取得できること', async ({ page }) => { const result = page.getByText('by Text') await expect(result).toHaveAttribute('data-p', '5') }) test('ラベルをキーに取得できること', async ({ page }) => { const result = page.getByLabel('by Label') await expect(result).toHaveValue('ラベル') }) test('placeholderをキーに取得できること', async ({ page }) => { const result = page.getByPlaceholder('紅玉') await expect(result).toHaveValue('シナノゴールド') }) })
また、キーを指定する以外にも、メソッドによっては正規表現を指定することも可能です。
例えば、 getByTestId()
は以下のようにも書けます。
test.describe('Locatorを正規表現で取得', () => { test('test-idをキーに取得できること', async ({ page }) => { await page.goto('http://localhost:8000/playwright/locator') const result = page.getByTestId(/test_.*tor/) await expect(result).toHaveText('by test id') }) })
他に、 locator
メソッドでは、optionとしてHTML構造を考慮した取得も可能です。
https://playwright.dev/docs/api/class-page#page-locator
例えばこんなHTMLがあったとします。
<div class="self"> 自身 <div id="child1" class="child"> 子の要素1 <div class="grandchild">子1の子(孫)の要素1</div> <div class="grandchild">子1の子(孫)の要素2</div> </div> <div id="child2" class="child"> 子の要素2 <div>子2の子(孫)の要素1</div> <div>子2の子(孫)の要素2</div> </div> <div id="child3" class="child">子の要素3</div> </div>
この場合、子や孫の要素の状態を考慮しての絞り込みができます。
test.describe('Locatorを親子関係で絞って取得', () => { test.beforeEach(async ({ page }) => { await page.goto('http://localhost:8000/playwright/locator') }) test('何も絞り込んでいないこと', async ({ page }) => { const result = page.locator('.child') await expect(result).toHaveCount(3) }) test('hasでの絞り込みができていること', async ({ page }) => { const result = page.locator('.child', { has: page.locator('.grandchild') }) await expect(result).toHaveCount(1) await expect(result).toHaveId('child1') }) test('hasTextでの絞り込みができていること', async ({ page }) => { const result = page.locator('.child', { hasText: '子2の子(孫)の要素2' }) await expect(result).toHaveCount(1) await expect(result).toHaveId('child2') }) })
CSSでの非表示とLocatorについて
CSSで非表示にする場合、 display
・ visibility
・ opacity
などが使えます。
display:none; VS visibility:hidden; VS opacity:0; - Qiita
そこで、Playwrightでも非表示の扱いがブラウザと同じかどうかを確認します。
例えば以下のHTMLがあったとします。
<div id="viewable_by_css"> <button id="none_style" onclick="handleClick('None Style')">None Style</button> <button id="display_none" onclick="handleClick('Display None')" style="display: none">Display None</button> <button id="visibility_hidden" onclick="handleClick('Visibility Hidden')" style="visibility: hidden">Visibility Hidden</button> <button id="opacity_zero" onclick="handleClick('Opacity zero')" style="opacity: 0">Opacity zero</button> </div> <script> function handleClick(text) { alert(text) } </script>
このHTMLに対しPlaywrightでクリックできるか試すテストを書いてみたところ、ブラウザと同じ結果になりました。
test.describe('ブラウザで見えないものの検証', () => { test.beforeEach(async ({ page }) => { await page.goto('http://localhost:8000/playwright/locator') }) test('なにもないときはクリックできること', async ({ page }) => { page.on('dialog', async dialog => { expect(dialog.message()).toEqual('None Style') await dialog.accept() }) const button = page.locator('#none_style') await button.click() }) test('display: noneではクリックできないこと', async ({ page }) => { page.on('dialog', async dialog => { expect(dialog.message()).toEqual('Display None') await dialog.accept() }) const button = page.locator('#display_none') test.skip(true, 'click()でtimeoutするため') await button.click() }) test('visibility: hiddenではクリックできないこと', async ({ page }) => { page.on('dialog', async dialog => { expect(dialog.message()).toEqual('Visibility Hidden') await dialog.accept() }) const button = page.locator('#visibility_hidden') test.skip(true, 'click()でtimeoutするため') await button.click() }) test('opacity: 0ではクリックできること', async ({ page }) => { page.on('dialog', async dialog => { expect(dialog.message()).toEqual('Opacity zero') await dialog.accept() }) const button = page.locator('#opacity_zero') await button.click() }) })
Assertionについて
PlaywrightではJestの expect
libraryを使ってアサーションをしているようです。
そのため、Jestのアサーションの他、 LocatorAssertions
などのPlaywrightで拡張されたアサーションも使えます。
https://playwright.dev/docs/api/class-locatorassertions
また、 .not
というNegating Matchersも用意されているため、以下のような「存在しないこと」も簡単に記述できます。
https://playwright.dev/docs/test-assertions#negating-matchers
test('否定形による確認もできること', async ({ page }) => { await page.goto('http://localhost:8000/playwright/locator') const result = page.locator('.class_locator') // 指定したTextを持っていないこと await expect(result).not.toHaveText('by test id') })
fixtureについて
初めてのテストでは page
というfixtureを使っていましたが、他にもテスト中で使えるfixtureとして browser
や context
などが用意されています。
また、独自のfixtureも作成できるようです。
https://playwright.dev/docs/test-fixtures#creating-a-fixture
Webアプリの挙動確認
ここまででPlaywrightの書き方を見てきました。
次は、Webアプリで見かける機能についてPlaywrightではどのようにテストできるか見ていきます。
Basic認証があるページを検証する
Basic認証があるページを検証したい場合の書き方について、Djangoで実装しながら見ていきます。
Djangoでの実装
DjangoでBasic認証を行いたい場合ミドルウェアを書くなどいろいろな方法がありますが、今回は
- アプリ全体ではなく、一部ページのみBasic認証をしたい
- ライブラリに任せたい
ということで、 django-basicauth
ライブラリを使います。
hirokiky/django-basicauth: Basic auth utilities for Django.
READMEを見ると、今回のPython3.10やDjango4.1でのテストはされてなさそうな記載になっています。ただ、手元では動作したため使ってみます。
pip installして、Viewとurls.py、およびsettings.pyを実装します。
READMEにある通り、Class based Viewで使う場合はこんな感じになります。
@method_decorator(basic_auth_required, name='dispatch') class BasicAuthView(TemplateView): template_name = 'playwright/basic_auth.html'
settings.pyにはBasic認証のユーザーとパスワードの組を設定します。
BASICAUTH_USERS = { 'foo': 'pass', }
Playwrightの実装
Basic認証なため、
https://username:password@www.example.com/
のように、URLに埋め込む形で page.goto()
に渡すこともできます。
ただ、Mozillaには
これらの URL の使用は推奨されていません。 Chrome ではセキュリティ上の理由から、URL の username:password@ 部分が削除されます。 Firefox ではサイトが実際に認証を要求するかどうかをチェックし、そうでない場合 Firefox はユーザーに「“www.example.com” というサイトに “username” というユーザー名でログインしようとしていますが、このウェブサイトは認証を必要としません。これはあなたを騙そうとしている可能性があります。」と警告します。
とあることから、Playwrightで用意されている別の方法を使います。
fixture「browser」とNetworkの「HTTP Authentication」により、テストコードレベルで設定
Playwrightのドキュメントによると、Networkの HTTP Authentication
を使うことでBasic認証ができそうでした。
https://playwright.dev/docs/network#http-authentication
browser.newContext()
を使えば良さそうだったため、 fixture browser
と組み合わせて書くことにします。
import {test, expect} from "@playwright/test"; // https://playwright.dev/docs/network#http-authentication test.describe('Basic認証', () => { // 引数のfixtureオブジェクトから browser を取り出す // pageオブジェクトは差し替えるので、browserだけで良い // https://playwright.dev/docs/api/class-fixtures test('Basic認証が必要なページを開けること', async ({ browser }) => { // Basic認証付きのブラウザに差し替え const context = await browser.newContext({ httpCredentials: { username: 'foo', password: 'pass' } }) const page = await context.newPage() // 差し替えたpageオブジェクトを使って検証 await page.goto('http://localhost:8000/playwright/basic-auth') const result = page.locator('p') await expect(result).toHaveText('show Basic auth page') }) })
TestOptionsにより、ファイルレベルで設定
Playwrightには Test Options
という機能があり、ファイルレベルで設定を適用することもできます。
https://playwright.dev/docs/api/class-testoptions
そこで、TestOptionsによるBasic認証のテストコードも書いてみます。
import {test, expect} from "@playwright/test"; // ファイルレベルでの設定変更 // https://playwright.dev/docs/api/class-testoptions test.use({ httpCredentials: { username: 'foo', password: 'pass' } }) test.describe('Basic認証でTestOptionを使う場合', () => { test('Basic認証が必要なページを開けること', async ({ page }) => { await page.goto('http://localhost:8000/playwright/basic-auth') const result = page.locator('p') await expect(result).toHaveText('show Basic auth page') }) })
playwright.config.tsにより、テストスイート全体で設定
playwright.config.ts
ファイルにBasic認証の設定を記載することで、テストスイート全体での設定が有効になりそうです。
- https://playwright.dev/docs/test-configuration
- https://playwright.dev/docs/test-configuration#network
今回はテストスイート全体では設定されなくて良いので、ためすのは省略します。
リダイレクトを検証する
Webアプリでは何かを処理した後にリダイレクトをすることがあります。
そこで、Playwrightではリダイレクトに自動追随するのか試してみます。
ここでは、Django(バックエンド)とReact(フロントエンド)、それぞれでリダイレクトを発生させてみて追随できるかを確認します。
Djangoで発生した場合
Djangoの実装
RedirectView
を使って、 urls.py を設定します。
https://docs.djangoproject.com/en/4.1/ref/class-based-views/base/#redirectview
今回は playwright
アプリでの検証なため、名前空間も考慮して pattern_name
などを設定します。
https://docs.djangoproject.com/ja/4.1/topics/http/urls/#url-namespaces
urlpatterns = [ path('redirect-from', RedirectView.as_view(pattern_name='playwright:redirected')), path('redirect-to', TemplateView.as_view(template_name='playwright/redirected.html'), name='redirected'), ]
Playwrightの実装
以下のコードで試したところ、テストがパスしました。
page.goto()
でリダイレクトが発生しても自動追随しているようです。
test.describe('Djangoでリダイレクトが発生した場合', () => { test('リダイレクトに対応できること', async ({ page }) => { await page.goto('http://localhost:8000/playwright/redirect-from') // この時点でリダイレクト先(redirect-to)へと遷移している const result = page.locator('p') await expect(result).toHaveText('Redirected by Django') }) })
Reactで発生した場合
Reactの実装
ReactのルーティングにはReact Routerを使っているため、 Navigate
コンポーネントを使って実装します。
https://reactrouter.com/en/main/components/navigate
<Route path="redirect" > <Route path="from" element={<Navigate to="/playwright/react/redirect/to" replace />} /> <Route path="to" element={<AfterRedirect />} /> </Route>
Playwrightの実装
Reactの場合も以下のテストコードがパスしたため、自動追随するようです。
test.describe('Reactでリダイレクトが発生した場合', () => { test('リダイレクトに対応できること', async ({ page }) => { await page.goto('http://localhost:8000/playwright/react/redirect/from') // この時点でリダイレクト先(redirect-to)へと遷移している const result = page.locator('p') await expect(result).toHaveText('Redirected by react router') }) })
新しいタブを開く
Webアプリでは新しいタブを開くこともあるため、Playwrightでの検証を確認してみます。
Djangoの実装
TemplateView
で、以下のテンプレートをレンダリングするようにします。
なお、 rel
には、以下を参考に noreferrer
と noopener
の両方を設定しました。
noopener と noreferrer の整理、結局どっちを使えば良いのか | blog.ojisan.io
<p> <a id="new_tab" href="{% url 'playwright:new-tab' %}" target="_blank" rel="noreferrer noopener"> 新しいタブを開く </a> </p>
Playwrightの実装
必要な機能は以下を参考にして実装します。
- aタグをクリックするには、Locatorの
click
を使う - 新しく開いたタブのハンドリング
test('新しいタブが開かれること', async ({ page, context }) => { await page.goto('http://localhost:8000/playwright/open-tab') // タブの中身を確認 // https://playwright.dev/docs/pages#handling-new-pages const pagePromise = context.waitForEvent('page') const el = page.locator('#new_tab') await el.click() const newPage = await pagePromise await newPage.waitForLoadState() const result = newPage.locator('p') await expect(result).toHaveText('New Tab!') })
ファイル
Webアプリではファイルを扱うこともあるため、ダウンロード・アップロードをそれぞれ試してみます。
なお、今回は検証を容易にするため、CSVファイルのダウンロード・アップロードとします。
ファイルダウンロード
Djangoの実装
TemplateViewで以下のテンプレートを用意し、
<a id="download" href="{% url 'playwright:file-download' %}" target="_blank" rel="noopener">CSVダウンロード</a>
aタグをクリックしたら、以下のViewにリクエストが飛んでダウンロードできるようにします。
class FileDownloadView(View): def get(self, request, *args, **kwargs): response = HttpResponse(content_type='text/csv') response['Content-Disposition'] = 'attachment; filename="download.csv"' writer = csv.writer(response) writer.writerow(['1', 'シナノゴールド']) writer.writerow(['2', '秋映']) return response
Playwrightの実装
公式ドキュメントに従い実装します。
https://playwright.dev/docs/downloads
なお、Downloadオブジェクトのメソッドを使えば色々検証できそうですが、今回はファイル名のみの検証にしておきます。
https://playwright.dev/docs/api/class-download
test('ファイルのダウンロードに成功すること', async ({ page }) => { await page.goto('http://localhost:8000/playwright/file-index') const downloadPromise = page.waitForEvent('download') await page.locator('#download').click() const download = await downloadPromise const fileName = download.suggestedFilename() expect(fileName).toEqual('download.csv') })
ファイルアップロード
Djangoの実装
フォームからファイルをアップロードし、成功したらフラッシュメッセージを表示するようなテンプレートを作成します。
{% if messages %} {% for message in messages %} <p id="message-{{ forloop.counter }}">{{ message }}</p> {% endfor %} {% endif %} <form action="{% url 'playwright:file-upload' %}" method='POST' enctype="multipart/form-data"> {% csrf_token %} <input id="upload" type="file" name="upload_file"> <button type="submit">アップロード</button> </form>
Viewではアップロードされたファイルのバリデーションなどは行わず、フラッシュメッセージを詰め込むだけにします。
class FileUploadView(View): def post(self, request, *args, **kwargs): data = io.TextIOWrapper(request.FILES['upload_file'].file, encoding='utf-8') for row in csv.reader(data): print(row) messages.success(request, 'アップロードしました') redirect_path = reverse("playwright:file-index") return redirect(redirect_path)
Playwrightでの確認
PlaywrightではLocatorの setInputFiles()
メソッドを使って、 input type="file"
な要素にファイルを添付できます。
ファイルはテスト機のローカルから取得する他、メモリ上のデータを使うこともできます。今回はメモリのデータを設定します。
- https://playwright.dev/docs/input#upload-files
- https://playwright.dev/docs/api/class-locator#locator-set-input-files
今回のテストコードでは、レスポンスのステータスコードとフラッシュメッセージを確認してみます。
test('ファイルのアップロードができること', async ({ page }) => { await page.goto('http://localhost:8000/playwright/file-index') await page.locator('#upload').setInputFiles({ name: 'upload.csv', mimeType: 'text/csv', buffer: Buffer.from('foo, bar') }) const responsePromise = page.waitForResponse('http://localhost:8000/playwright/file-upload') await page.locator('button').click() const response = await responsePromise expect(response.status()).toEqual(302) // この時点ですでに遷移済 // フラッシュメッセージを確認 await expect(page.locator('#message-1')).toHaveText('アップロードしました') })
alert
Playwrightでは、 alert
などのJavaScriptによるダイアログも扱えます。
- https://playwright.dev/docs/api/class-dialog
- https://playwright.dev/docs/api/class-page#page-event-dialog
今回のアプリはReactで作っていることもあり、Reactに alert
を組み込んでみて試してみます。
Reactの実装
Show Alert
ボタンをクリックするとJavaScriptのアラートを表示するとともに、画面にも done alert
を表示します。
import {useState} from "react"; export const Alert = () => { const [isAlert, setIsAlert] = useState<boolean>(false) const handleClick = () => { alert('show alert') setIsAlert(true) } return ( <> {isAlert && <p id="result">done alert</p>} <button id="show_alert" onClick={handleClick}>Show Alert</button> </> ) }
Playwrightの実装
page
の Dialog
イベントをハンドリングするような実装とします。
- https://playwright.dev/docs/api/class-page#page-event-dialog
- How to handle Javascript Alert, Confirm & Prompt in Playwright | TesterDock
test.describe('JavaScript alertの動作確認', () => { test('alertのハンドリングに成功すること', async ({ page }) => { await page.goto('http://localhost:8000/playwright/react/alert') page.on('dialog', async (dialog) => { expect(dialog.message()).toEqual('show alert') await dialog.accept() // ここが検証コード await expect(page.locator('#result')).toHaveText('done alert') }) await page.locator('#show_alert').click() }) })
クリップボードへの書き込み
ボタンを押したらクリップボードへ書き込む機能がある場合でも、Playwrightではクリップボードの中身を検証できます。
Reactの実装
Clipboard.writeText()
を使い、ボタンを押したら data from clipboard
というテキストがクリップボードに書き込まれるようにします。
https://developer.mozilla.org/ja/docs/Web/API/Clipboard/writeText
import {useState} from "react"; export const Clipboard = () => { const [isSave, setIsSave] = useState<boolean>(false) const handleClick = async () => { await navigator.clipboard.writeText('data from clipboard') setIsSave(true) } return ( <> {isSave && <p id="result">saved!</p>} <button id="copy_clipboard" onClick={handleClick}>Save Clipboard</button> </> ) }
Playwrightで権限の追加と実装
Playwrightでクリップボードの読み書きを行う場合、 browser context に対して権限の追加が必要です。
https://playwright.dev/docs/api/class-browsercontext#browser-context-grant-permissions
そこで、テストコードでは、権限を追加しつつクリップボードの中身を検証します。
test('クリップボードにコピーされていること', async ({ context, page }) => { // 権限を追加する context.grantPermissions([ 'clipboard-write', // ブラウザの操作で必要 'clipboard-read', // テストでの検証で必要 ]) await page.goto('http://localhost:8000/playwright/react/clipboard') // クリップボードへコピー await page.locator('#copy_clipboard').click() // コピー後のメッセージが表示されているか確認 await expect(page.locator('#result')).toHaveText('saved!') // クリップボードの中身を取得して検証 const result = await page.evaluate(async () => { return await navigator.clipboard.readText() }) expect(result).toEqual('data from clipboard') })
参考:Playwrightによる、クリップボードへの書き込み・読み取りのエミュレートについて
現時点ではショートカットキーのみ対応しており、一連の操作をいい感じにするメソッドは無いようです。
[Feature]: Copy & Paste Clipboard emulation · Issue #8114 · microsoft/playwright
Reactの挙動を検証
ここまでも一部Reactでの実装を行っていましたが、ここからはReactの機能やライブラリを使ったときの検証をしていきます。
stateの変更の確認
Reactの useState
を使って、画面上の表示が変わるようなときも検証できるかをみてみます。
Reactの実装
ボタンを押したら、画面のカウンターが +1 されるReactコンポーネントを用意します。
import {useState} from "react"; export const Increment = () => { const [count, setCount] = useState<number>(0) return ( <> <p>Counter: <span id="counter">{count}</span></p> <button id="increment" onClick={() => setCount(count + 1)}>Increment</button> </> ) }
Playwrightの実装
Webアプリの検証と同様な書き方で、state変更も検証できます。
test('React stateの確認ができること', async ({ page }) => { await page.goto('http://localhost:8000/playwright/react/state') // クリック前の状態を確認 await expect(page.locator('#counter')).toHaveText('0') // クリック await page.locator('#increment').click() // クリック後の状態を確認 await expect(page.locator('#counter')).toHaveText('1') })
i18n対応
Reactでi18n対応するときも検証できるか試してみます。
なお、今回は @shopify/react-i18n
を使ってi18n化対応をしてみます。
quilt/README.md at main · Shopify/quilt
Reactの実装
ライブラリのREADMEに従い、コンポーネントを作成します。
まずはi18n化したいコンポーネントを囲むためのコンポーネントを用意します。
import {I18nManager, I18nContext} from "@shopify/react-i18n"; import {I18nText} from "./I18nText"; export const I18nMain = () => { const locale = navigator.language const i18nManager = new I18nManager({ locale }) return ( <> <I18nContext.Provider value={i18nManager}> <I18nText /> </I18nContext.Provider> </> ) }
続いて、 I18nText
コンポーネントで、実際にi18n対応をします。
ブラウザの locale
が ja
の場合は シナノゴールド
を、そうでない場合は shinano gold
を表示します。
import {useI18n} from "@shopify/react-i18n"; const ja = { "Apple": { "text": 'シナノゴールド' } } const en: typeof ja = { "Apple": { "text": 'shinano gold' } } export const I18nText = () => { const [i18n] = useI18n({ id: 'Apple', translations(locale) { if (locale === 'ja') { return ja } return en } }) return ( <p id="result">{i18n.translate('Apple.text')}</p> ) }
Playwrightの実装
Playwrightでブラウザの locale
を設定するには、
browser
fixtureのnewContext()
メソッドで、適切なlocale
をセット- その状態で新しい
context
やpage
を使う
とすれば良さそうです。
https://playwright.dev/docs/api/class-browser?_highlight=newcontext#browser-new-context
test('ブラウザが日本語の場合、日本語で表示されること', async ({ browser }) => { const context = await browser.newContext({ locale: 'ja' }) const page = await context.newPage() await page.goto('http://localhost:8000/playwright/react/i18n') await expect(page.locator('#result')).toHaveText('シナノゴールド') }) test('ブラウザが英語の場合、英語で表示されること', async ({ browser }) => { const context = await browser.newContext({ locale: 'en' }) const page = await context.newPage() await page.goto('http://localhost:8000/playwright/react/i18n') await expect(page.locator('#result')).toHaveText('shinano gold') })
MUIとの組み合わせ
ReactでUIを作る場合、UIライブラリを使うこともあります。
そこで今回は、MUIのコンポーネントの中でも動きがあるものに対してPlaywrightで検証できるか試してみます。
なお、MUIのドキュメントに従い、MUIをインストールしておきます。
https://mui.com/material-ui/getting-started/installation/
$ npm install @mui/material @emotion/react @emotion/styled
Snackbar
通知を表示するようなコンポーネントです。
React Snackbar component - Material UI
Reactの実装
MUIのSnackbarのサンプルにある Simple snackbars をベースに、 id
などを付与しました。
ボタンを押したらSnackbarを表示します。また、閉じるボタンをクリックすると、Snackbarが閉じます。
import {Button, IconButton, Snackbar} from "@mui/material"; import {SyntheticEvent, useState} from "react"; import CloseIcon from '@mui/icons-material/Close' export const MuiSnackbar = () => { const [open, setOpen] = useState<boolean>(false) const handleClick = () => { setOpen(true) } const handleClose = (event: SyntheticEvent | Event, reason?: string) => { if (reason === 'clickaway') { return } setOpen(false) } const action= ( <IconButton id="close_snackbar" size="small" aria-label="close" color="inherit" onClick={handleClose} > <CloseIcon fontSize="small" /> </IconButton> ) return ( <> <Button id="show_snackbar" onClick={handleClick}>Show Snackbar</Button> <Snackbar id="snackbar" open={open} autoHideDuration={6000} onClose={handleClose} message="Hello snackbar" action={action} /> </> ) }
Playwrightの実装
Snackbarの表示と閉じるを検証します。
test('MUI Snackbarの動作確認ができること', async ({ page }) => { await page.goto('http://localhost:8000/playwright/react/mui') // snackbarが表示できること await page.locator('#show_snackbar').click() await expect(page.locator('#snackbar')).toHaveText('Hello snackbar') // snackbarが閉じること await page.locator('#close_snackbar').click() await expect(page.locator('#snackbar')).toHaveCount(0) })
Modal
いわゆるモーダルのコンポーネントです。
React Modal component - Material UI
Reactの実装
MUIのサンプル Basic modal をベースに、ボタンを押したらモーダルが開くようにしています。
export function MuiModal() { const [open, setOpen] = useState(false) const handleOpen = () => setOpen(true) const handleClose = () => setOpen(false) return ( <div> <Button id="show_modal" onClick={handleOpen}>Open modal</Button> <Modal open={open} onClose={handleClose} aria-labelledby="modal-modal-title" aria-describedby="modal-modal-description" > <Box id="modal_content" sx={style}> Hello Modal </Box> </Modal> </div> ) }
Playwrightの実装(キーを押してモーダルを閉じる)
Basic modalには閉じるボタンがなく、モーダル以外の場所をクリックすることで、モーダルが閉じるようになっています。
そこで、Playwrightでは page.keyboard.press()
メソッドで特定のキーを押すことができることを利用し、Escapeキーを押したらモーダルが閉じることを検証します。
https://playwright.dev/docs/api/class-keyboard
test('MUI Modalの動作確認ができること', async ({ page }) => { await page.goto('http://localhost:8000/playwright/react/mui') // Modalが表示されること await page.locator('#show_modal').click() await expect(page.locator('#modal_content')).toHaveText('Hello Modal') // Modalが閉じること // 特定のelementをclickできないので、Escキーで閉じる await page.keyboard.press('Escape') await expect(page.locator('#modal_content')).not.toBeVisible() await expect(page.locator('#modal_content')).toHaveCount(0) })
DataGrid への対応は容易ではなさそう
DataGridは MUI X なため、別途インストールします。
npm install @mui/x-data-grid
DataGridには色々な機能があり複雑なため、
- Playwrightでテストコードを自動生成
- 自動生成されたテストコードが動くか
を試してみます。
以下は自動生成されたコードに加え、最後にDataGridの内容を確認できるようスクリーンショットの撮影も追加しています。
https://playwright.dev/docs/screenshots
test('test', async ({ page }) => { await page.goto('http://localhost:8000/playwright/react/mui-datagrid'); await page.getByRole('button', { name: 'Menu' }).click(); await page.getByRole('menuitem', { name: 'Filter' }).click(); await page.getByRole('combobox', { name: 'Operator' }).selectOption('>='); await page.getByPlaceholder('Filter value').click(); await page.getByPlaceholder('Filter value').fill('30'); await page.getByPlaceholder('Filter value').press('Enter'); await page.getByRole('columnheader', { name: 'Last name' }).getByRole('button', { name: 'Sort' }).click(); await page.getByRole('button', { name: 'Go to next page' }).click(); await page.getByRole('button', { name: 'Go to previous page' }).click(); // スクショを撮って確認 await page.screenshot({ path: 'screenshot.png' }); });
このテストコードを動かしてみたところ、以下の行で waiting となり、最終的にテストが失敗しました。
await page.getByRole('button', { name: 'Menu' }).click()
内容をあらためて見ると、「DataGridのどの列のメニューをクリックしているか」がはっきりしません。
おそらくDataGridを解析していけばテストコードが書けるとは思います。
ただ、解析するには時間がかかりそうなことから、今回は「容易ではない」という結論にしておきます。
なお、テストを一括で流した時にテストが失敗しないよう、 test.skip()
しておきます。
https://playwright.dev/docs/api/class-test#test-skip-3
test.skip(true, 'DataGridの解析が必要なのでskipする')
React Hook Formとの組み合わせ
Reactでフォームを扱う場合、フォームライブラリが便利です。
今回は React Hook Form
を使ってフォームを作成します。
https://react-hook-form.com/
インストールしておきます。
$ npm install react-hook-form
ここでは、フォームの要素ごとに
- フォームの要素への値を設定
- フォームをsubmitしたときの値を検証
を行ってみます。
なお、Reactの実装は長くなってしまうため、記事での記載は省略します。詳細は後述のソースコードをご確認ください。
また、Reactでフォームを作ったとしてもDjangoと組み合わせる場合は CSRF tokenをフォームに入れる必要があります。今回は以下を参考に CSRFToken
コンポーネントを作成しました。
python - How to use csrf_token in Django RESTful API and React? - Stack Overflow
MUI TextField について
TextFieldは type="text"
なinput要素を作ります。
https://mui.com/material-ui/react-text-field/
TextFieldへ入力し、検証する
TextFieldへの入力はLocatorオブジェクトの fill()
メソッドを利用します。
- https://playwright.dev/docs/input#text-input
- https://playwright.dev/docs/api/class-locator#locator-fill
また、フォームをsubmitしたときの値を検証するには、Pageオブジェクトの waitForRequest()
メソッドを使って Request
オブジェクトを取得し、検証を行います。
- https://playwright.dev/docs/api/class-page#page-wait-for-request
- https://playwright.dev/docs/api/class-request
test.beforeEach(async ({ page }) => { await page.goto('http://localhost:8000/playwright/react/mui') }) test('TextFieldに入力した値がリクエストにあること', async ({ page }) => { // TextField await page.locator('#mui_textfield').fill('シナノゴールド') // 送信ボタンをクリックした時に発生する、axios.post先へのリクエストをwaitする const requestPromise = page.waitForRequest('http://localhost:8000/api/playwright/mui/') // 送信ボタンをクリック await page.locator('#submit').click() const request = await requestPromise expect(request.postDataJSON()).toEqual({ muiCheck: false, muiTextSelect: '', muiSelect: '', muiTextField: 'シナノゴールド', }) })
TextFieldへ追記し、検証する
上記の通り fill()
メソッドを使うとTextFieldの値が差し替わります。
もし、値の末尾に追記したい場合は type()
メソッドを使うと良さそうです。
https://playwright.dev/docs/api/class-locator#locator-type
test('TextFieldに追記した値がリクエストにあること', async ({ page }) => { const textField = page.locator('#mui_textfield') await textField.fill('王林') // typeで追記 await textField.type('秋映') const requestPromise = page.waitForRequest('http://localhost:8000/api/playwright/mui/') await page.locator('#submit').click() const request = await requestPromise expect(request.postDataJSON()).toEqual({ muiCheck: false, muiTextSelect: '', muiSelect: '', muiTextField: '王林秋映', }) })
TextFieldの値をクリアし、検証する
値をクリアする場合は、 clear()
メソッドが使えます。
https://playwright.dev/docs/api/class-locator#locator-clear
test('TextFieldをクリアした値がリクエストにあること', async ({ page }) => { await page.locator('#mui_textfield').clear() const requestPromise = page.waitForRequest('http://localhost:8000/api/playwright/mui/') await page.locator('#submit').click() const request = await requestPromise expect(request.postDataJSON()).toEqual({ muiCheck: false, muiTextSelect: '', muiSelect: '', }) })
MUI Radio Group を検証する
いわゆるラジオボタンを作成します。
https://mui.com/material-ui/react-radio-button/
Playwrightでは、選択したいラジオボタンで click()
すればよさそうです。
test('Radioで選択した値がリクエストにあること', async ({ page }) => { await page.locator('#mui_radio_red').click() const requestPromise = page.waitForRequest('http://localhost:8000/api/playwright/mui/') await page.locator('#submit').click() const request = await requestPromise expect(request.postDataJSON()).toEqual({ muiCheck: false, muiTextSelect: '', muiSelect: '', muiRadio: 'red', }) })
MUI Select を検証する
いわゆるドロップダウンです。
https://mui.com/material-ui/react-select/
ドロップダウンで値を選択するには、
- input の要素をクリック
- 表示される選択肢をクリック
に2段階で操作する必要があります。
test('Selectで選択した値がリクエストにあること', async ({ page }) => { // セレクトボックスをクリックした後、ドロップダウンのものをクリックする await page.locator('#mui_select').click() await page.locator('#mui_select_household').click() const requestPromise = page.waitForRequest('http://localhost:8000/api/playwright/mui/') await page.locator('#submit').click() const request = await requestPromise expect(request.postDataJSON()).toEqual({ muiCheck: false, muiTextSelect: '', muiSelect: 'household', }) })
MUI TextField select を検証する
MUIでドロップダウンを実現するもう一つの方法としては、 TextFieldの select
propを使う方法があります。
https://mui.com/material-ui/react-text-field/#select
なお、Playwrightによる操作の流れは、MUI Selectと同じになります。
test('TextField select で選択した値がリクエストにあること', async ({ page }) => { // セレクトボックスをクリックした後、選びたいものをクリックする await page.locator('#mui_textfield_select').click() await page.locator('#mui_textfield_select_farmersMarket').click() const requestPromise = page.waitForRequest('http://localhost:8000/api/playwright/mui/') await page.locator('#submit').click() const request = await requestPromise expect(request.postDataJSON()).toEqual({ muiCheck: false, muiTextSelect: 'farmersMarket', muiSelect: '', }) })
MUI Checkbox を検証する
いわゆるチェックボックスです。
https://mui.com/material-ui/react-checkbox/
チェックボックスの場合は、 setChecked()
メソッドを使うことで、 true
と false
を明示的に設定できます。
https://playwright.dev/docs/api/class-locator#locator-set-checked
test('Checkboxのチェックがリクエストにあること', async ({ page }) => { await page.locator('#mui_checkbox').setChecked(true) const requestPromise = page.waitForRequest('http://localhost:8000/api/playwright/mui/') await page.locator('#submit').click() const request = await requestPromise expect(request.postDataJSON()).toEqual({ muiCheck: true, muiTextSelect: '', muiSelect: '', }) })
MUI TextArea Autosize を検証する
TextAreaで高さを自動調整してくれるコンポーネントです。
https://mui.com/material-ui/react-textarea-autosize/
TextAreaで改行を入れる場合は、 \n
を入れてあげると良いです。
test('TextAreaの改行がリクエストにあること', async ({ page }) => { await page.locator('#mui_textarea').fill('改行前\n改行後') const requestPromise = page.waitForRequest('http://localhost:8000/api/playwright/mui/') await page.locator('#submit').click() const request = await requestPromise expect(request.postDataJSON()).toEqual({ muiCheck: false, muiTextSelect: '', muiSelect: '', muiTextAreaAutosize: '改行前\n改行後' }) })
MUI X DataTimePickerは、headlessではうまく検証できず
日時選択をするコンポーネントとして、 MUI X には DateTimePicker
があります。
https://mui.com/x/react-date-pickers/getting-started/
DateTimePickerには input タグがあるため、直接入力することができます。
ただ、Playwrightでは、ブラウザを表示する場合はテストがパスするのですが、表示しない(ヘッドレスモード)とテストが失敗します。
test('うまくいかない', async ({ page }) => { await page.goto('http://localhost:8000/playwright/react/mui') const datetimePicker = page.locator('#mui_datetime_picker') await datetimePicker.fill('2023/01/07 17:01:02') const requestPromise = page.waitForRequest('http://localhost:8000/api/playwright/mui/') await page.locator('#submit').click() const request = await requestPromise expect(request.postDataJSON()).toEqual({ muiCheck: false, muiTextSelect: '', muiSelect: '', muiDateTimePicker: '2023-01-07T08:01:02.000Z' }) })
ヘッドレスの場合、ここで失敗します。inputでの入力ができないようです。
await datetimePicker.fill('2023/01/07 17:01:02')
スクリーンショットを撮ったところ、ヘッドレスの場合はDateTimePickerの日付選択ダイアログが表示されてしまっています。
また、ダイアログの形もヘッドレスとそうでない場合とで異なっていました。
表示する場合
ヘッドレスの場合
これ以上の追求には時間がかかりそうだったため、今のところは headless ではうまくいかないという結論にします。
Playwrightの便利な機能について
ここまででDjangoやReactでの挙動をみてきました。
ここからはPlaywrightの便利な機能について記載します。
スクリーンショットを撮る
PlaywrightではPageオブジェクトの screenshot()
メソッドで、ブラウザで表示している内容をスクリーンショットとして残せます。
https://playwright.dev/docs/api/class-page#page-screenshot
MUI DataGridの検証でもあった通り、以下のように使います。
await page.screenshot({ path: 'screenshot.png' })
ビデオを撮る
今回は試す機会がありませんでしたが、Playwrightの挙動についてビデオに残すこともできます。
https://playwright.dev/docs/videos
.envファイルの内容を環境変数に反映する
上記のBasic認証のテストコードでは、認証に使うユーザーとパスワードをテストコードにハードコーディングしていました。
ただ、現実的にはハードコーディングするのではなく、環境変数などから値を読み込んでテストを書きたいです。
Playwrightでは、 dotenv
パッケージを使って .env
ファイルの内容を環境変数に反映することができます。
https://playwright.dev/docs/test-parameterize#env-files
例えば、 .env
ファイルに
LOCATOR_URL=http://localhost:8000/playwright/locator
と、アクセス先のURLを定義した場合、テストコードではこんな感じで読み込んで使えます。
test.describe('.envファイルからの読み込みを確認', () => { test('.envファイルに記載されたURLにアクセスしていること', async ({ page }) => { await page.goto(process.env.LOCATOR_URL) const result = page.locator('#id_locator') await expect(result).toHaveText('by id') }) })
HTTPリクエスト/レスポンスの確認
ここまでの例でも出てきていましたが、PlaywrightではHTTPリクエスト/レスポンスを監視して、内容を検証できます。
https://playwright.dev/docs/network#network-events
ここでは、HTTPリクエスト/レスポンスの検証について、それぞれためしてみます。
例:バックエンドにaxiosでリクエストしたときの内容を確認
この記事では、フォームでの検証を使っていました。再掲します。
const requestPromise = page.waitForRequest('http://localhost:8000/api/playwright/mui/') await page.locator('#submit').click() const request = await requestPromise expect(request.postDataJSON()).toEqual({ muiCheck: false, muiTextSelect: '', muiSelect: '', muiRadio: 'red', })
ここでは waitForRequest
を使ってリクエストを監視しています。
https://playwright.dev/docs/api/class-page#page-wait-for-request
監視した内容は await
することで取り出し、検証することができます。
const request = await requestPromise
リクエストの中身については、 Request
オブジェクトのドキュメントに記載があります。
https://playwright.dev/docs/api/class-request
なお、レスポンスの内容も response()
メソッドから取り出せます。
https://playwright.dev/docs/api/class-request#request-response
例:新しいタブを開いたときのHTTPレスポンスの中身を確認
新しいタブを開いたときに、そのタブを描画するためのHTTPレスポンスの中身を確認してみます。
新しいタブの場合、 page
fixtureとは別のページになるため、 page.waitForResponse()
が使えません。
https://playwright.dev/docs/api/class-page#page-wait-for-response
そこで、ブラウザ全体で監視できる context.on('response')
を使います。
https://playwright.dev/docs/api/class-browsercontext#browser-context-event-response
test('新しいタブのURLやレスポンスヘッダの中身を確認できること', async ({ page, context }) => { await page.goto('http://localhost:8000/playwright/open-tab') // responseが発生したときのイベントを設定する context.on('response', async response => { // リクエストが飛んだ先の検証 expect(response.url()).toEqual('http://localhost:8000/playwright/new-tab') // ヘッダの検証 const headers = response.headers() expect(headers['content-type']).toEqual('text/html; charset=utf-8') }) await page.locator('#new_tab').click() })
レスポンスの中身については、 Response
オブジェクトのドキュメントに記載があります。
https://playwright.dev/docs/api/class-response
APIレスポンスの差し替え
Playwrightではネットワークに介在してリクエスト/レスポンスの差し替えができます。
https://playwright.dev/docs/network
そのため、「E2Eテスト環境にデータが入ってしまっているため、データがない場合の表示が確認しづらい」という場合にもPlaywrightだけで対応できます。
今回は、Django製APIのレスポンスを1件もデータが取得できなかったように差し替え、その時のReactの表示を確認してみます。
https://playwright.dev/docs/network#modify-responses
指定した route
に対し、 fulfill
メソッドで差し替え後のデータを設定すれば良いです。
test('taskが1件も存在しない時は、存在しない旨を表示すること', async ({ page }) => { // レスポンスが0件になるよう差し替え await page.route('http://localhost:8000/api/tasks/', async route => { // fulfill()にて、差し替え後のデータを設定 route.fulfill({ status: 404, body: JSON.stringify({}) }) }) await page.goto('http://localhost:8000/tasks/') const message = page.locator('#message') await expect(message).toHaveCount(1) await expect(message).toHaveText('task not found') })
なお、レスポンスの差し替えについては、以下の記事にある通り差し替えできるものとできないものがあります。
Playwright 1.29の新機能 Backend API Mockingは request.fetchでは効かないよ。 - Qiita
その他ドキュメントなど
今回はPlaywrightの操作や検証を中心に書きました。
ただ、Playwrightにはまだまだ機能があります。幸いなことに、Playwrightはドキュメントが充実しています。
まずは Docs
や API
のドキュメントを見れば良いと思います。
日本語の記事については、以下が参考になりました。
E2Eテストの考え方などは以下が参考になりました。
ソースコード
Githubに上げました。
Django + Reactアプリ側です。
- https://github.com/thinkAmi-sandbox/react_todo_app_with_django_vite_for_playwright
- https://github.com/thinkAmi-sandbox/react_todo_app_with_django_vite_for_playwright/pull/2
Playwright側です。