/var/log/jsoizo

メモ帳 技術とか趣味とか

Playwright Javaをマルチスレッド環境下で平行に動かす(Kotlinで)

注: これが正解かはわかっていない

一度にいくつかのe2eテストを高速に実行する目的で並列にしたくて、かつブラウザのコンテキストつまりCookieなどをテスト間で共有したくない場合がある。
たとえば、並列実行したい2つのテストがそれぞれwebサービスのログイン情報が異なるみたいな時などが挙げられる。

残念ながらPlaywright Javaはスレッドセーフではないので、そういったことをするには少々の工夫が必要になってしまう。。。

今回は並列に実行するためのアプローチとしてこのような対応方法を考えた。

  1. 並列に実行したいテストのプロセスを分ける
  2. 複数のスレッドを使い、スレッドごとにPlaywright Javaインスタンスを生成する

今回はgauge-javaと併せて使いたいという事情があり、gauge-javaがスレッドを複数利用することでテストを並列実行するという要件だったので、今回は2のパターンを頑張る必要があった。

実装

Playwrightの Browser BrowserContext Page あたりの値をスレッドごとに管理するシングルトンを用意し、そこからPageを取得するようにしてみる。
BrowserContext, Pageに関してはテストケースごとに都度初期化して利用したい。ゆえにこのような関数が必要になる。

object BrowserManager {
    // ブラウザ設定
    private val launchOptions = BrowserType.LaunchOptions().apply {
        headless = false
    }

    // スレッドごとにBrowser, Context, Pageを保持する
    private val threadLocalBrowser: ThreadLocal<Browser> = ThreadLocal()
    private val threadLocalContext: ThreadLocal<BrowserContext> = ThreadLocal()
    private val threadLocalPage: ThreadLocal<Page> = ThreadLocal()

    fun initPage(): Unit {
        println("[${Thread.currentThread().name}] initPage")
        // すでにスレッドに対応するPageが存在する場合はそれを返す
        val page: Page? = threadLocalPage.get()
        page ?: run {
            // Pageが存在しない場合は作成する
            val browser = threadLocalBrowser.getOrSet {
                val playwright = Playwright.create()
                playwright.chromium().launch(launchOptions)
            }
            val context = browser.newContext()
            val newPage = context.newPage()
            threadLocalContext.set(context)
            threadLocalPage.set(newPage)
        }
    }
    
    fun getPage(): Page {
        println("[${Thread.currentThread().name}] getPage")
        // Pageが存在していることを前提として取得する
        threadLocalPage.get()?.let {
            return it
        } ?: run {
            throw IllegalStateException("Page is not initialized")
        }
    }

    fun removePage() {
        println("[${Thread.currentThread().name}] removePage")
        // BrowserContext, Pageが存在していたら破棄する
        threadLocalPage.get()?.let {
            it.close()
            threadLocalPage.remove()
        }
        threadLocalContext.get()?.let {
            it.close()
            threadLocalContext.remove()
        }
    }

    fun removeBrowser() {
        println("[${Thread.currentThread().name}] removeBrowser")
        threadLocalBrowser.get()?.let {
            it.close()
            threadLocalBrowser.remove()
        }
    }
}

並行動作サンプル

並行動作している例はこのように実装した。
実際はgaugeで実行することになるが、紙面の都合で簡易的に自分で2スレッド生成してBrowserManagerを呼び出している。

fun main() {
    println("start")
    val thread1 = Thread {
        // BeforeSpec想定
        BrowserManager.initPage()

        // Spec想定
        val page = BrowserManager.getPage()
        page.navigate("https://www.google.com")
        println("[${Thread.currentThread().name}] Page: ${page.title()}")
        page.navigate("https://www.yahoo.co.jp")
        println("[${Thread.currentThread().name}] Page: ${page.title()}")

        // AfterSpec想定
        BrowserManager.removePage()

        // AfterAll想定
        BrowserManager.removeBrowser()
    }
    val thread2 = Thread {
        // BeforeSpec想定
        BrowserManager.initPage()

        // Spec想定
        val page = BrowserManager.getPage()
        page.navigate("https://www.google.com")
        println("[${Thread.currentThread().name}] Page: ${page.title()}")
        page.navigate("https://www.yahoo.co.jp")
        println("[${Thread.currentThread().name}] Page: ${page.title()}")

        // AfterSpec想定
        BrowserManager.removePage()

        // AfterAll想定
        BrowserManager.removeBrowser()
    }
    thread1.start()
    thread2.start()

    thread1.join()
    thread2.join()
    println("end")
    exitProcess(0)
}

結果として、出力はこのようになる。
2スレッドで並行してブラウザインスタンスが生成され、スレッド数ぶんのChromiumのプロセスを立ち上げてwebアクセスしていることがわかる。

start
[Thread-0] initPage
[Thread-1] initPage
[Thread-1] getPage
[Thread-0] getPage
[Thread-1] Page: Google
[Thread-0] Page: Google
[Thread-1] Page: Yahoo! JAPAN
[Thread-1] removePage
[Thread-1] removeBrowser
[Thread-0] Page: Yahoo! JAPAN
[Thread-0] removePage
[Thread-0] removeBrowser
end

ThreadLocal使えば良いんじゃないの的な提案をChatGPTに教えてもらったのでマジで便利だなっていうのと、ThreadLocal の使い方を知らなかったので非常に勉強になった。
AIつかってプログラムの改善をすることで自分の知識が深まって引き出しが増えてよいですね。