Playwright for Elixir で Web UI を操作、 E2E テストを実行する

## はじめに

Playwright は JavaScript でブラウザを操作し、 E2E (End to End) のテストを自動化するフレームワークです

スクリーンショットを撮ることで直接的に画面の状態が確認できます

https://playwright.dev/

"Playwright" = 「脚本家」という意味で、ブラウザに「こういう操作して」という脚本を書くイメージですね

https://github.com/mechanical-orchard/playwright-elixir

まだプレビューバージョンではありますが、 Playwright for Elixir があったので Livebook から実行してみます

https://livebook.dev/

実装したノートブックはこちら

https://github.com/RyoWakabayashi/elixir-learning/blob/main/livebooks/playwright/playwright.livemd

:::note warn
Playwright のインストールには Node.js が必要です

先にインストールしておきましょう
:::

## セットアップ

Livebook で新しいノートブックを開きます

以下のコードを実行し、必要なモジュールをインストールします

- [Kino](https://github.com/livebook-dev/kino): Livebook の UI/UX (Playwright 自体には不要)
- [Image](https://github.com/elixir-image/image): スクリーンショットを扱うために使用

```elixir
Mix.install([
  {:playwright, "~> 1.44.0-alpha.2"},
  {:kino, "~> 0.13"},
  {:image, "~> 0.53.0"}
])
```

実行環境上で初めて Playwright を使用する場合、以下のコードでブラウザなどをインストールします

```elixir
Playwright.SDK.CLI.install()
```

初回実行時のみ、以下のように [Cromium](https://www.chromium.org/chromium-projects/) がインストールされます

```
10:47:17.879 [info] Installing playwright browsers and dependencies

10:47:54.918 [info] Downloading Chromium 125.0.6422.26 (playwright build v1117) from https://playwright.azureedge.net/builds/chromium/1117/chromium-mac-arm64.zip
|                                                                                |   0% of 134.6 MiB
|■■■■■■■■                                                                        |  10% of 134.6 MiB
|■■■■■■■■■■■■■■■■                                                                |  20% of 134.6 MiB
...
```

## Web サイトの表示

ブラウザを起動します

画面サイズを指定することで、モバイルで表示したときなど、レスポンシブ対応の UI も確認できます

```elixir
# Chromium ブラウザを起動
{:ok, browser} = Playwright.launch(:chromium, %{})

# 新しいページを開く
page = Playwright.Browser.new_page(browser)

# 画面サイズの設定 (iPhone SE)
Playwright.Page.set_viewport_size(page, %{width: 375, height: 667})
```

`Playwright.Page.goto` で Web サイトの URL を指定してアクセスします

```elixir
# Web サイトにアクセス
Playwright.Page.goto(page, "https://qiita.com/")
```

`Playwright.Page.screenshot` で画面のスクリーンショットを取得し、 Base64 の文字列として返します

```elixir
# スクリーンショットを取得
base64 = Playwright.Page.screenshot(page)
```

実行結果

```elixir
"iVBORw0KGgoAAAANSUhEUgAAAXcAAAKbCAIAAACBzF3NAAAgAElEQVR4n" <> ...
```

画像を Livebook 上で表示してみましょう

```elixir
tensor =
  base64
  |> Base.decode64!()
  |> Image.from_binary!()
```

![スクリーンショット 2024-07-28 15.46.12.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/c577388c-97f6-f1a2-2cd1-ba8525dd7e11.png)

このように、特定の操作をした状態の画面を残せるため、出力を含めてノートブックを保存しておけば、そのまま E2E テストのエビデンスとして使えます

## 要素の指定

`Playwright.Page.query_selector` で JavaScript や CSS と同じように要素を指定できます

指定した部分だけのスクリーンショットを取得することも可能です

```elixir
page
|> Playwright.Page.query_selector("nav[aria-label='Global']")
|> Playwright.ElementHandle.screenshot()
```

実行結果

![nav.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/79fdb9b9-ded3-1caf-7e06-b22c3a817c55.png)

指定した要素内のテキストを取得する場合は `Playwright.ElementHandle.text_content` を使用します

```elixir
page
|> Playwright.Page.query_selector("nav[aria-label='Global'] ol li:nth-child(2) a")
|> Playwright.ElementHandle.text_content()
```

実行結果

```elixir
"Question"
```

## ブラウザ操作

### スクロール

`Playwright.ElementHandle.scroll_into_view` によって、指定した要素が画面内に見えるまでスクロールします

```elixir
page
|> Playwright.Page.query_selector("nav[aria-label='Global'] ol li:last-child a")
|> Playwright.ElementHandle.scroll_into_view()

page
|> Playwright.Page.screenshot()
|> Base.decode64!()
|> Image.from_binary!()
```

実行結果

![org.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/46212c55-d9ef-f265-90b5-8823a81983e0.png)

```elixir
page
|> Playwright.Page.query_selector("footer nav dl:last-child")
|> Playwright.ElementHandle.scroll_into_view()

page
|> Playwright.Page.screenshot()
|> Base.decode64!()
|> Image.from_binary!()
```

実行結果

![footer.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/f7ce80d1-457c-b05b-0278-68c199caee7c.png)

### クリック

`Playwright.Page.click` で指定要素をクリックします

```elixir
Playwright.Page.click(page, "nav[aria-label='Global'] ol li:nth-child(2) a")

page
|> Playwright.Page.screenshot()
|> Base.decode64!()
|> Image.from_binary!()
```

![questions.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/56de7fc9-876b-af0e-ce13-07e563db5d6a.png)

`Playwright.Page.url` で現在の URL を取得できます

```elixir
Playwright.Page.url(page)
```

実行結果

```elixir
"https://qiita.com/question-feed"
```

DOM 上には存在していても、 UI 上で表示されていない要素（`display:none` や `height:0` になっている要素）は操作できません

```elixir
# 検索アイコンをクリックしないと表示されない要素
Playwright.Page.click(page, "form[aria-label='Search'] input")
```

操作できない要素を操作しようとすると、以下のようなエラーが発生します

```elixir
{:error, %Playwright.SDK.Channel.Error{message: "Timeout 30000ms exceeded."}}
```

操作のデフォルトのタイムアウトが 30 秒に設定されているため、 30 秒経過してからエラーになります

即時エラーを返して欲しい場合、　第３引数に `%{force: true}` を指定します

その場合、以下のようなエラー文言になります

```elixir
15:30:05.868 [error] GenServer #PID<0.2060.0> terminating
** (RuntimeError) %Playwright.SDK.Channel.Error{message: "Element is not visible"}
...
```

また、操作のレスポンスに 30 秒以上かかるような場合、　第３引数に `%{timeout: 60_000}` のようにタイムアウト時間を指定します

要素が見えているかどうかは `Playwright.ElementHandle.is_visible` で判定します

```elixir
page
|> Playwright.Page.query_selector("form[aria-label='Search']:nth-child(2) input")
|> Playwright.ElementHandle.is_visible()
```

操作した後、特定の要素が表示されるまで待つ場合は `Playwright.Page.wait_for_selector` を使います

```elixir
Playwright.Page.click(page, "header button")
Playwright.Page.wait_for_selector(page, "form[aria-label='Search']:nth-child(2) input")

page
|> Playwright.Page.screenshot()
|> Base.decode64!()
|> Image.from_binary!()
```

![search.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/070e684c-852f-c3a2-0f0e-e96f9516301d.png)

### テキスト入力

`Playwright.Page.fill` で指定した要素にテキストを入力します

```elixir
Playwright.Page.fill(page, "form[aria-label='Search']:nth-child(2) input", "Elixir")

page
|> Playwright.Page.screenshot()
|> Base.decode64!()
|> Image.from_binary!()
```

![input.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/ade7f67e-09ea-11d6-c2d3-f1d7be0a7abe.png)

`Playwright.Page.press` で任意のキーを入力できます

```elixir
Playwright.Page.press(page, "form[aria-label='Search']:nth-child(2) input", "Enter")

page
|> Playwright.Page.screenshot()
|> Base.decode64!()
|> Image.from_binary!()
```

![elixir.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/2d09c421-206a-c0c5-d595-f47e86cbebc0.png)

## E2E テスト

ExUnit を使った単体テストと同じように E2E テストを記述することができます

https://hexdocs.pm/ex_unit/ExUnit.html

Livebook から実行する場合、必ずテストモジュールの定義前に `ExUnit.start` を実行してください

```elixir
ExUnit.start()
```

テストモジュールを定義し、 `assert` などを使って検証します

モジュールの先頭で `use ExUnit.Case, async: true` と `use PlaywrightTest.Case` を実行しておくことが重要です

テストケースの最後に `Playwright.Page.close` することも忘れないようにしましょう

```elixir
defmodule Sample.TabsTest do
  use ExUnit.Case, async: true
  use PlaywrightTest.Case

  describe "Click tabs" do
    test "click all tabs", %{browser: browser} do
      page = Playwright.Browser.new_page(browser)
      Playwright.Page.set_viewport_size(page, %{width: 375, height: 667})

      [
        %{
          tab_index: 1,
          text: "Trend",
          title: "Qiita",
          url: "https://qiita.com/"
        },
        %{
          tab_index: 2,
          text: "Question",
          title: "Question - Qiita",
          url: "https://qiita.com/question-feed"
        },
        %{
          tab_index: 3,
          text: "Official Event",
          title: "Official Events - Qiita",
          url: "https://qiita.com/official-events"
        },
        %{
          tab_index: 4,
          text: "Official Column",
          title: "Official Columns - Qiita",
          url: "https://qiita.com/official-columns"
        },
        %{
          tab_index: 5,
          text: "signpostCareer",
          title: "Qiita x Findy エンジニアのキャリアを支援するコラボレーションページ - Qiita",
          url: "https://qiita.com/opportunities/findy"
        },
        %{
          tab_index: 6,
          text: "Organization",
          title: "organization一覧 - Qiita",
          url: "https://qiita.com/organizations"
        }
      ]
      |> Enum.map(fn map ->
        Playwright.Page.goto(page, "https://qiita.com/")

        tab_selector = "nav[aria-label='Global'] ol li:nth-child(#{map.tab_index}) a"

        assert page
               |> Playwright.Page.query_selector(tab_selector)
               |> Playwright.ElementHandle.text_content() == map.text

        Playwright.Page.click(page, tab_selector)

        assert Playwright.Page.title(page) == map.title
        assert Playwright.Page.url(page) == map.url

        page
        |> Playwright.Page.screenshot()
        |> Base.decode64!()
        |> then(&File.write!("/tmp/tab-#{map.tab_index}-clicked.png", &1))
      end)

      Playwright.Page.close(page)
    end
  end
end
```

定義したテストを実行します

```elixir
ExUnit.run()
```

実行結果

```
Running ExUnit with seed: 862499, max_cases: 16

.
Finished in 10.6 seconds (10.6s async, 0.00s sync)
1 test, 0 failures
%{total: 1, failures: 0, excluded: 0, skipped: 0}
```

失敗した場合は以下のようになります

```
Running ExUnit with seed: 862499, max_cases: 16



  1) test Click tabs click all tabs (Sample.TabsTest)
     tmp/livebook_data/dev/autosaved/2024_07_28/02_05_z3kp/playwright.livemd#cell:fjwi4eh2n3cjxkuf:6
     Assertion with == failed
     code:  assert page |> Playwright.Page.query_selector(tab_selector) |> Playwright.ElementHandle.text_content() ==
              map.text
     left:  "Official Column"
     right: "Official Columns"
     stacktrace:
       tmp/livebook_data/dev/autosaved/2024_07_28/02_05_z3kp/playwright.livemd#cell:fjwi4eh2n3cjxkuf:53: anonymous fn/2 in Sample.TabsTest."test Click tabs click all tabs"/1
       (elixir 1.17.2) lib/enum.ex:1703: Enum."-map/2-lists^map/1-1-"/2
       (elixir 1.17.2) lib/enum.ex:1703: Enum."-map/2-lists^map/1-1-"/2
       tmp/livebook_data/dev/autosaved/2024_07_28/02_05_z3kp/playwright.livemd#cell:fjwi4eh2n3cjxkuf:48: (test)


Finished in 7.8 seconds (7.8s async, 0.00s sync)
1 test, 1 failure

%{total: 1, failures: 1, excluded: 0, skipped: 0}
```

テスト中に出力しておいたスクリーンショットを表示して確認します

```elixir
1..6
|> Enum.map(fn index ->
  Image.open!("/tmp/tab-#{index}-clicked.png")
end)
|> Kino.Layout.grid(columns: 3)
```

![スクリーンショット 2024-07-28 17.37.09.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/4f04210f-dc56-a41e-69ee-c321ee9fd7cb.png)

## まとめ

Playwright for Elixir を使うことで、 E2E テストが実装できました

Phoenix.ConnTest でも UI 操作をテストできますが、 Playwright では JavaScript や CSS の動作含めテストできます

https://hexdocs.pm/phoenix/Phoenix.ConnTest.html

Alpine.js の動作を確認したいときなど、いろいろなケースで活用できそうです