DockerでPlaywrightを使う方法

はじめに

Webアプリケーションのテストは、品質保証の観点で欠かせないステップです。しかし、異なるブラウザやデバイス間での動作確認は非常に手間となる作業です。

そこで活躍してくれるのが、マイクロソフトが提供するエンドツーエンドのテスト自動化ツール「Playwright」です。加えて Docker を活用することで、テスト環境の構築や依存関係の管理が簡潔となり、一貫性のあるテスト実行が可能となります。本記事では、Docker 上で Playwright をセットアップし、ブラウザテストを自動化する方法とプロセスについて具体例を用いてご紹介します。

 

Playwright について

Playwright はマイクロソフトが開発したオープンソースのブラウザ自動化ツールです。主な特徴として以下が挙げられます。

  • マルチブラウザ対応 
    Chromium, Firefox, Webkitの全てのブラウザをサポートしており、複数のブラウザで一貫したテストを行うことができます。
  • ヘッドレスモード 
    ブラウザを表示せずにバックグラウンドで動作させることができ、効率的なテストやスクレイピングが可能です。
  • モダンなAPI 
    非同期操作をサポートし、効率的なスクリプト記述が可能です。JavaScript/TypeScript、Python、C#など複数の言語に対応しています。
  • クロスプラットフォーム対応 
    Windows、macOS、Linuxなど、異なるオペレーティングシステムで一貫して動作します。

同じくテスト自動化ツールとしてポピュラーなものとしてSeleniumがあります。

以下ではPlaywrightとSeleniumの主な違いを挙げます

  • パフォーマンス
    Playwright:
    Playwrightはパフォーマンスに優れており、非同期操作を利用した効率的なスクリプト実行が可能 
    Selenium: Seleniumはシンクロナス操作が基本で、パフォーマンスがやや劣る場合がある
  • ブラウザサポート 
    Playwright: 特定のブラウザエンジン(Chromium、Firefox、WebKit)のサポートに特化 

    Selenium: より広範なブラウザサポートがある(Chrome、Firefox、Safari、Edge、Internet Explorer etc.)
  • セットアップの簡便さ 
    Playwright: 簡単にセットアップでき、特にモダンな開発環境に適している 

    Selenium: より多くのセットアップ手順が必要
  • テストの安定性 
    Playwright: テストの安定性が高く、特に動的なWebアプリケーションのテストに適している

    Selenium: ページのロードやタイミングの問題でテストが不安定になることがある

DockerでPlaywrightを使う準備

Dockerイメージ

https://playwright.dev/java/docs/docker

上記 playwright公式サイト に記載のあるものを利用

今回はJavaを使用するため、下記のDockerイメージを使用します

mcr.microsoft.com/playwright/java:v1.45.0-jammy

その他Node.js, Python用のイメージも用意されているので、使用する言語に合わせて選択してください

Node.js

mcr.microsoft.com/playwright:v1.45.1-jammy

Python

mcr.microsoft.com/playwright/python:v1.45.0-jammy

Dockerfile


FROM mcr.microsoft.com/playwright/java:v1.44.0-jammy

# ロケールとエンコード locale and encoding
RUN apt-get update && apt-get install -y \
  locales \
  fonts-ipafont-gothic \
  fonts-ipafont-mincho \
  && locale-gen ja_JP.UTF-8 \
  && dpkg-reconfigure locales \
  && echo "export LANG=ja_JP.UTF-8" >> ~/.bashrc

# ロケールとエンコーディングの設定
ENV LANG ja_JP.UTF-8
ENV LANGUAGE ja_JP:ja
ENV LC_ALL ja_JP.UTF-8

RUN localedef -f UTF-8 -i ja_JP ja_JP.utf8

WORKDIR /app


COPY . /app

RUN chmod +x ./gradlew
RUN ./gradlew clean shadowJar

CMD ["tail", "-f", "/dev/null"]

設定解説

FROM mcr.microsoft.com/playwright/java:v1.44.0-jammy

ここではベースとなるDockerイメージとして上記で述べたmcr.microsoft.com/playwright/java:v1.44.0-jammyを指定します。このイメージにはPlaywrightのJava環境があらかじめセットアップされています。

 

RUN apt-get update && apt-get install -y \
  locales \
  fonts-ipafont-gothic \
  fonts-ipafont-mincho \
  && locale-gen ja_JP.UTF-8 \
  && dpkg-reconfigure locales \
  && echo "export LANG=ja_JP.UTF-8" >> ~/.bashrc

ここでは、APTパッケージマネージャを使用してロケールと日本語フォントをインストールし、日本語ロケールを生成しています。また、環境変数LANGに日本語のロケールを設定するため、.bashrcファイルに設定を追加しています。

 

ENV LANG ja_JP.UTF-8
ENV LANGUAGE ja_JP:ja
ENV LC_ALL ja_JP.UTF-8
RUN localedef -f UTF-8 -i ja_JP ja_JP.utf8

ここではDockerコンテナの環境変数として日本語ロケールを設定します。

その後localedefコマンドを使用して日本語ロケールを定義しています。これにより、コンテナ内で日本語の文字エンコーディングが正しく機能するようになります。

Dockerコンテナ内で日本語を使用する場合、上記2つの設定がないと正常に日本語を読み込んでくれません。

特に今回はサイト内の挙動を自動化するため、HTML内の日本語も読み込めなくてはならず、これらの設定が必須となります。

 

WORKDIR /app

コンテナ内の作業ディレクトリを/appに設定しています。以降のコマンドはこのディレクトリ内で実行されます。

 

COPY . /app

RUN chmod +x ./gradlew
RUN ./gradlew clean shadowJar

ホストマシンからコンテナの/appディレクトリにアプリケーションのファイルをコピーし、その後Gradleのビルドツールに実行権限を与え、./gradlew clean shadowJar コマンドを実行してアプリケーションをビルドしています

 

CMD ["tail", "-f", "/dev/null"]

最後にコンテナが起動した際に実行されるデフォルトのコマンドを指定しています。この場合、tail -f /dev/null コマンドを実行することで、コンテナがすぐに終了しないようにしています。

これによりデバッグや追加のコマンド実行を容易になります。

デモ

では実際にDocker上でPlaywrightを実行してみたいと思います。

デモ用プログラム

今回は Playwrightを用いて 法人番号公表サイトから指定した都道府県の法人番号ファイルをダウンロードしてくるプログラムを用意しました。

以下が今回用意したコードです。

Main.java

import com.microsoft.playwright.*;

import java.io.IOException;
import java.nio.file.Paths;
import java.util.Arrays;
import java.util.List;

import static org.model.FileHandler.deleteFiles;
import static org.model.FileHandler.unzip;
import static org.model.PlayWrightCommonTools.targetParentEl;

public class Main {
   private static final String path = "/app/src/main/resources/corporate_num_csv/";
   private static String downloadedFile;

   public static void main(String[] args) throws IOException {
       try (Playwright playwright = Playwright.create()) {
           Browser browser = playwright.chromium().launch();
           Page page = browser.newPage();

           for(String pref : args) {
               downloadCorporateNum(page, path, pref);
               unzip(path, path);
               deleteFiles(path, Arrays.asList(".asc", ".zip"));
           }
       }
   }

   public static void downloadCorporateNum(Page page, String path, String pref) {
       page.navigate("https://www.houjin-bangou.nta.go.jp/download/zenken/");

       List<ElementHandle> targetEls = targetParentEl(page, pref, ".tbl02:nth-of-type(2) tr td", "dt").querySelectorAll("a");
       for(ElementHandle targetEl : targetEls) {
           if (targetEl != null) {
               ElementHandle finalTarget = targetEl;
               Download download = page.waitForDownload(() -> {
                   finalTarget.click();
               });

               downloadedFile = download.suggestedFilename();

               download.saveAs(Paths.get(path, downloadedFile));
               System.out.println("Downloaded file saved to " + path + downloadedFile);
           }
       }
   }
}

DateUtils.java

import java.time.LocalDateTime;
 import java.time.format.DateTimeFormatter;

public class DateUtils {
   public static String getCurrentTime() {
       // 現在時刻を取得
       LocalDateTime now = LocalDateTime.now();

       // フォーマッタを作成
       DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy/MM/dd HH:mm:ss");

       // フォーマットされた現在時刻を取得
       return now.format(formatter);
   }
}

FileHandler.java

import java.io.*;
import java.util.List;
import java.util.stream.Stream;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;

import static org.model.LogUtil.*;

public class FileHandler {

   /**
    * zipファイルを展開
    *
    * @param zipDirectory
    * @param destDirectory
    * @throws IOException
    */
   public static void unzip(String zipDirectory, String destDirectory) throws IOException {
       File zipDir = new File(zipDirectory);
       if(!zipDir.exists() || !zipDir.isDirectory()) {
           throw new IllegalArgumentException("The provided path is not a valid directory");
       }

       File destDir = new File(destDirectory);
       if(!destDir.exists()) {
           destDir.mkdirs();
       }

       File[] zipFiles = zipDir.listFiles((dir, name) -> name.toLowerCase().endsWith(".zip"));
   if(zipFiles != null) {
           for(File zipFile : zipFiles) {
               unzipFile(zipFile, destDirectory);
           }
       }
   }

   private static void unzipFile(File zipFile, String destDirectory) throws IOException {
       try (ZipInputStream zipIn = new ZipInputStream(new FileInputStream(zipFile))) {
           ZipEntry entry = zipIn.getNextEntry();
           // すべてのエントリをループ処理する
           while (entry != null) {
               String filePath = destDirectory + File.separator + entry.getName();
               if (!entry.isDirectory()) {
                   // エントリがファイルの場合、ファイルを抽出する
                   extractFile(zipIn, filePath);
               } else {
                   // エントリがディレクトリの場合、ディレクトリを作成する
                   File dir = new File(filePath);
                   dir.mkdirs();
               }
               zipIn.closeEntry();
               entry = zipIn.getNextEntry();
           }
       }
       timelog("Successfully unzipped file: " + zipFile.getName());
   }

   /**
    * ファイルを抽出するメソッド
    *
    * @param zipIn
    * @param filePath
    * @throws IOException
    */

   private static void extractFile(ZipInputStream zipIn, String filePath) throws IOException {
       try (BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream(filePath))) {
           byte[] bytesIn = new byte[4096];
           int read;
           while ((read = zipIn.read(bytesIn)) != -1) {
               bos.write(bytesIn, 0, read);
           }
       }
   }
 /**
    * 引数のpathがディレクトリであれば、配下の指定拡張子に該当するファイルを全て削除対象とする
    * 引数のpathがファイルであれば、そのファイルを削除対象とする
    * @param path
    * @param extensions
    */
   public static void deleteFiles(String path, List<String> extensions) {
       File directoryOrFile = new File(path);

       if(directoryOrFile.exists() && directoryOrFile.isDirectory()) {
           File[] files = directoryOrFile.listFiles();

           if(files != null) {
               Stream.of(files)
                       .filter(file -> extensions.stream().anyMatch(file.getName()::endsWith))
                       .forEach(file -> {
                           if(file.delete()) {
                               timelog("Successfully removed " + file.getName());
                           } else {
                               timeErrLog("File removal failed:" + file.getName());
                           }
                       }
               );
           }
       } else if (directoryOrFile.isFile()) {
           if (directoryOrFile.delete()) {
               timelog("Successfully removed " + directoryOrFile.getName());
           } else {
               timeErrLog("File removal failed: " + directoryOrFile.getName());
           }
       } else {
           timeErrLog("The specified path is neither a directory nor a file: " + path);
       }
   }
}

PlaywrightCommonTools.java

import com.microsoft.playwright.ElementHandle;
import com.microsoft.playwright.Page;

import java.util.List;

public class PlayWrightCommonTools {

   /**
    * 指定したワード(str)を含むparent selector要素を取得できる
    *
    * @param page
    * @param str
    * @param parent  selector
    * @param child   selector
    * @return
    */
   public static ElementHandle targetParentEl(Page page, String str, String parent, String child) {
       ElementHandle targetEl = null;

       System.out.println(page.content());

       List<ElementHandle> areaEls = page.querySelectorAll(parent);
       for(ElementHandle area : areaEls) {
           ElementHandle targetFrame = area.querySelector(child);
           if(targetFrame != null && targetFrame.innerText().startsWith(str)){
               targetEl = area;
               break;
           }
       }

       return targetEl;
   }
}

build.gradle

plugins {
   id 'java'
   id 'com.github.johnrengelman.shadow' version '7.1.2'
}

group = 'org.example'
version = '1.0-SNAPSHOT'

repositories {
   mavenCentral()
}

dependencies {
   testImplementation platform('org.junit:junit-bom:5.9.1')
   testImplementation 'org.junit.jupiter:junit-jupiter'
   implementation group: 'com.microsoft.playwright', name: 'playwright', version: '1.44.0'
   implementation group: 'org.apache.commons', name: 'commons-csv', version: '1.11.0'
}

test {
   useJUnitPlatform()
}

jar {
   manifest {
       attributes 'Main-Class': 'org.main.Main'
   }
}

shadowJar {
   archiveBaseName.set('playwright-docker')
   archiveVersion.set('1.0-SNAPSHOT')
   archiveClassifier.set('')
}

task runMain(type: JavaExec) {
   main = 'org.main.Main' // Mainクラスのパッケージ付きの完全修飾名
   classpath sourceSets.main.runtimeClasspath
}

ディレクトリ構成図

.

├── build/

│   ├── classes

│   ├── generated

│   ├── libs/

│   │   └── playwright-docker-1.0-SNAPSHOT.jar

│   ├── resources

│   └── tmp

├── docker/

│   └── Dockerfile

├── src/

│   └── main/

│       └── java/

│           └── org/

│               ├── main/

│               │   └── Main.java

│               ├── util/

│               │   ├── DateUtils.java

│               │   ├── FileHandler.java

│               │   ├── LogUtil.java

│               │   └── PlaywrightCommonTools.java

│               └── resources/

│                   └── ダウンロードされたファイル

└── build.gradle

shadowJarでbuildしてjarを作成

実行

デモとして2パターンの実行方法を試したいと思います

①ロケール、エンコーディング設定なし

まずはDockerfileからロケール、エンコーディングの設定を抜いた状態で実行してみます

  • イメージをビルド
docker build -t playwright-docker -f docker/Dockerfile .

*-t : イメージ名を付与
*-f : Dockerfileの所在を記す

  • コンテナを起動
docker run playwright-docker
  • 新しいターミナルのウィンドウでコンテナ内に入る

実行中のコンテナを確認

docker ps

CONTAINER ID   IMAGE               COMMAND               CREATED         STATUS         PORTS     NAMES 2e6dd2ec4ed5   playwright-docker   "tail -f /dev/null"   4 minutes ago   Up 4 minutes             dazzling_almeida
docker exec -it dazzling_almeida bash

* コンテナ名はランダム

  • 準備ができたので、コードを実行 

build/libs配下のjarを実行します

java -jar playwright-docker-1.0-SNAPSHOT.jar 東京

(引数に渡した都道府県のファイルをダウンロードする)

-結果-

Exception in thread "main" java.lang.NullPointerException: Cannot invoke "com.microsoft.playwright.ElementHandle.querySelectorAll(String)" because the return value of "org.model.PlayWrightCommonTools.targetParentEl(com.microsoft.playwright.Page, String, String, String)" is null
        at org.main.Main.downloadCorporateNum(Main.java:34)
        at org.main.Main.main(Main.java:24)

取得したHTML内の日本語が全て文字化けしてしまいエラー終了してしまいました。

 

②ロケール、エンコーディング設定あり

  • Dockerfileにロケール、エンコーディングの設定を付与し、上記と同様にdockerコンテナを立ち上げる
  • jarを実行 
java -jar playwright-docker-1.0-SNAPSHOT.jar 東京

-結果-

正常にHTML内の日本語を取得できています。

Downloaded file saved to /app/src/main/resources/corporate_num_csv/13_tokyo_all_20240628_01.zip
Downloaded file saved to /app/src/main/resources/corporate_num_csv/13_tokyo_all_20240628_02.zip
2024/07/12 05:22:27| Successfully unzipped file: 13_tokyo_all_20240628_01.zip
2024/07/12 05:22:28| Successfully unzipped file: 13_tokyo_all_20240628_02.zip
2024/07/12 05:22:28| Successfully removed 13_tokyo_all_20240628_01.csv.asc
2024/07/12 05:22:28| Successfully removed 13_tokyo_all_20240628_02.csv.asc
2024/07/12 05:22:28| Successfully removed 13_tokyo_all_20240628_01.zip
2024/07/12 05:22:28| Successfully removed 13_tokyo_all_20240628_02.zip

エラーが出ることもなく正常にダウンロードができました。

まとめ

本記事では、Playwright を使用して Web アプリケーションのテストを自動化する方法について、Docker 環境でのセットアップ方法と共に解説しました。特に、日本語のロケールとエンコーディング設定が重要であることを具体的な例を通じて紹介しました。

記事内で紹介したポイントを押さえることで、Playwright を活用したブラウザテストの自動化がスムーズに行えるようになります。特に、多言語対応のテスト環境を構築する際には、ロケールとエンコーディング設定を忘れずに行うことが重要です。

Docker と Playwright を組み合わせることで、効率的で再現性の高いテスト環境を構築し、品質の高い Web アプリケーションを提供できるようになります。この記事が、皆さんのテスト自動化プロセスに役立つことを願っています。

関連記事

カテゴリー:

ブログ

情シス求人

  1. チームメンバーで作字やってみた#1

ページ上部へ戻る