React + DjangoなWebアプリに対して、PlaywrightでいろいろなE2Eのテストコードを書いてみた

前回の記事で、Playwrightを使ってDjango + ReactのE2Eテストができると分かりました。
WSL2 + Playwrightな環境にて、codegen機能によりReactアプリやDjango管理サイト向けのテストコードを自動生成してみた - メモ的な思考的な

前回は自動生成したテストコードを使っていたことから、今度は自分でPlaywrightによるE2のテストコードを書いてみたくなりました。

そこで色々と試したときのメモを残します。

 
なお、この記事ではPlaywrightの書き方に着目するため、Djangoアプリの実装については解説が必要な部分のみとしています。

もしソースコード全体を参照したい場合は、記事の末尾にあるソースコードを合わせてご確認ください。

 
また、この記事はPlaywrightの使い方が中心です。そのため、要素の特定については、深く考えずにid属性を使用しています。

ただ、実際のE2Eテストでは、要素の特定にid属性を使用するのではなく、「テキスト」や「data-testid などのdata-*属性」を使用したほうが良いようです。

そのため、E2Eテストの設計段階にて、要素の特定には何を使うか検討・認識合わせが必要でしょう。

 
目次

 

環境

Django・React・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>

 
このページに idid_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')
  })
})

 
テストコードは以下の流れになっています。

 
過去にSeleniumをさわっているのであれば、似たような感じテストコードが書けそうなでした。

 

before/afterについて

テスティングフレームワークでは、「各テストの実行前後に何か処理を行いたい」という時に、 beforeafter のようなものが用意されています。

Playwrightでも、 beforeAllafterAllafterAllafterEach が用意されています。

 
そこで、初めてのテストで書いていた 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() メソッドでは、CSSXPath、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で非表示にする場合、 displayvisibilityopacity などが使えます。
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として browsercontext などが用意されています。

 
また、独自のfixtureも作成できるようです。
https://playwright.dev/docs/test-fixtures#creating-a-fixture

 

Webアプリの挙動確認

ここまででPlaywrightの書き方を見てきました。

次は、Webアプリで見かける機能についてPlaywrightではどのようにテストできるか見ていきます。

 

Basic認証があるページを検証する

Basic認証があるページを検証したい場合の書き方について、Djangoで実装しながら見ていきます。

 

Djangoでの実装

DjangoBasic認証を行いたい場合ミドルウェアを書くなどいろいろな方法がありますが、今回は

  • アプリ全体ではなく、一部ページのみ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” というユーザー名でログインしようとしていますが、このウェブサイトは認証を必要としません。これはあなたを騙そうとしている可能性があります。」と警告します。

URL 内の認証情報を使用したアクセス | HTTP 認証 - HTTP | MDN

とあることから、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認証の設定を記載することで、テストスイート全体での設定が有効になりそうです。

 
今回はテストスイート全体では設定されなくて良いので、ためすのは省略します。

 

リダイレクトを検証する

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 には、以下を参考に noreferrernoopener の両方を設定しました。
noopener と noreferrer の整理、結局どっちを使えば良いのか | blog.ojisan.io

<p>
  <a id="new_tab" href="{% url 'playwright:new-tab' %}" target="_blank" rel="noreferrer noopener">
    新しいタブを開く
  </a>
</p>

 

Playwrightの実装

必要な機能は以下を参考にして実装します。

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" な要素にファイルを添付できます。

ファイルはテスト機のローカルから取得する他、メモリ上のデータを使うこともできます。今回はメモリのデータを設定します。

 
今回のテストコードでは、レスポンスのステータスコードとフラッシュメッセージを確認してみます。

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によるダイアログも扱えます。

 
今回のアプリは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の実装

pageDialog イベントをハンドリングするような実装とします。

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対応をします。

ブラウザの localeja の場合は シナノゴールド を、そうでない場合は 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 をセット
  • その状態で新しい contextpage を使う

とすれば良さそうです。
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)
})

 

いわゆるモーダルのコンポーネントです。
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() メソッドを利用します。

また、フォームをsubmitしたときの値を検証するには、Pageオブジェクトの waitForRequest() メソッドを使って 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/

 
ドロップダウンで値を選択するには、

  1. input の要素をクリック
  2. 表示される選択肢をクリック

に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() メソッドを使うことで、 truefalse を明示的に設定できます。
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だけで対応できます。

 
今回は、DjangoAPIのレスポンスを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はドキュメントが充実しています。

まずは DocsAPI のドキュメントを見れば良いと思います。

 
日本語の記事については、以下が参考になりました。

 
E2Eテストの考え方などは以下が参考になりました。

 

ソースコード

Githubに上げました。

Django + Reactアプリ側です。

 
Playwright側です。