ブラウザを使った Python Web アプリケーションのテスト

## はじめに

最近趣味で Python の Web フレームワーク（Django や Flask みたいなやつ）を開発しているのですが，その際 Cookie や CORS などのテストを行う必要が出てきました．その類のテストは **Fetch API などのブラウザの JavaScript 用 API を使用したテスト**であるため，通常の Python を実行して行うユニットテストではなく，ブラウザに何らかの方法で JavaScript を実行させてテストする必要があります．このようなテスト方法は（おそらくあるとは思うのですが）ググってもあまり良い前例が見つからなかったので，色々試行錯誤してある程度形になったものをここにまとめたいと思います．

### 注意点

今回は Web フレームワークとして[私が個人開発しているもの](https://github.com/jjj999/bamboo)を使用しますが，他のものを使用したとしても流れとしては同じですので，そこらへんは工夫してみてください．

### 用語について

以下ではサーバーという用語を使用しますが，物理的なマシンのことではなく，実行可能なサーバーアプリケーションという意味で使用するので注意してください（この辺の用語のニュアンスは極めて曖昧で厄介なので困ります）．

## テスト方法

### 登場人物

このテストには3つの登場人物がいます：

1. ブラウザ（ユーザーエージェント；ここでテストが実行される）
2. Web サーバー（ブラウザへ HTML 等を提供）
3. App サーバー（Python で書かれた Web アプリケーション；テストしたいアプリケーション）

※ 場合によっては 2, 3 は同一のアプリケーションとして実行されるものもありますが，今回は別々であるとします．

![test-diagram.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/453304/b03cec61-f83d-ddfb-0f0b-6c92a8fdb983.png)

上図の矢印では以下のような通信が発生しています：

1. Web サーバーからブラウザへ HTML や関連ファイルが送信される
2. 1 で送信された HTML 内のスクリプトが実行され，App サーバーへテスト用のリクエストを行う

今回はこのような登場人物と通信が行われるテストを，出来る限り簡単に実行できる方法（例えば1つのコマンド）について述べます．

### マルチプロセス

このテストでの登場人物の数をプロセス数と一致させるのであれば，このテストを行う際には**3つのプロセスが同時に実行されている必要があります**．ということで，マルチプロセスでテストを実行する方法を取ることにします．今回はブラウザに関しては Python 内で直接的に操作することせず，標準ライブラリである [webbrowser](https://docs.python.org/ja/3/library/webbrowser.html) を使ってブラウザを起動するところまでで留めようと思います．

### QUnit

[QUnit](https://qunitjs.com/) は比較的簡単に導入できる JavaScript テストフレームワークです．私は JavaScript の世界にあまり詳しくないので，導入が楽で助かりました．この記事では QUnit の使い方については述べませんが，[公式ドキュメント](https://qunitjs.com/)を見ればある程度理解できると思います．

### テストの流れ

以上のテスト方法をまとめると，テストは次のような手順で行います：

1. 子プロセスで App サーバーを起動
1. 子プロセスで Web サーバーを起動
1. 親プロセスで Web サーバーのテスト用エントリーポイントを指定しブラウザを起動
1. ブラウザが Web サーバーからテスト用エントリーポイントを受け取る
1. テスト用スクリプトが順に読み込まれテストが実行される

ここで，**テスト用エントリーポイントとはテスト用の JavaScript を埋め込んだ HTML ファイル**のことです．また，上述したようにテスト用スクリプトでは QUnit を使用してテストを行います．

## 実装

実装の方法は様々ですが，ここではその一例を示します．

### ディレクトリ構造

アプリケーションの規模によってプロジェクトのディレクトリ構造は様々ですが，ここでは簡単のために以下のように設定します．

```
|- web/
|    |- tests/
|    |    |- bye.test.js
|    |    |- hello.test.js
|    |- index.html
|- app.py
|- main.py
```

### App サーバー

App サーバーは Python で書かれた実際にテストしたい Web アプリケーションです．ここでは，一例として適当に実装します（実際には認証や複雑な API ロジックが実装されるかと思います）．エンドポイントは以下の2つです：

- /hello (GET, POST)
- /bye (GET)

実装自体は自作のフレームワークで行っておりますが，Flask などの Web フレームワークを使ったことがある方であれば，コードを見ればなんとなく内容を理解できると思います．内部ロジックは特に難しいことはしておらず，短いバイナリデータを送信したり，JSON データを受信したりしているだけです．この記事の目的は，このアプリケーションをブラウザを使ってテストすることなので，これ以上の説明は割愛します．

```python:app.py
from bamboo import (
    BinaryApiData,
    HTTPStatus,
    JsonApiData,
    WSGIApp,
    WSGIEndpoint,
)
from bamboo.sticky.http import (
    add_preflight,
    allow_simple_access_control,
    set_cache_control,
    data_format,
)


app = WSGIApp()


class TestRequest(JsonApiData):

    account_id: str
    email_addr: str
    age: int


@app.route("hello")
@add_preflight(
    allow_methods=["GET", "POST"],
    allow_origins=[],
    add_arg=False,
)
class HelloEndpoint(WSGIEndpoint):

    @set_cache_control(no_cache=True)
    @data_format(input=None, output=None)
    def do_GET(self) -> None:
        self.send_body(b"Hello, Client!")

    @data_format(input=TestRequest, output=None)
    def do_POST(self, req: TestRequest) -> None:
        print(req.dict)
        self.send_only_status(HTTPStatus.ACCEPTED)


@app.route("bye")
class ByeEndpoint(WSGIEndpoint):

    @set_cache_control(no_cache=True)
    @allow_simple_access_control(add_arg=False)
    @data_format(input=None, output=BinaryApiData)
    def do_GET(self) -> None:
        self.send_body(b"Bye, Client!")
```

### テスト用 Web サーバー

こちらはブラウザさんに実行してもらうテストを実装する必要があります．そのためには，とりあえずブラウザのエントリーポイントとしての HTML を用意し，そこに埋め込むテスト用のスクリプトを書けば OK です．つまり今回の場合は，最低限用意すべきファイルは

- web/index.html --> テスト実行用のエントリーポイント
- web/tests/*.js --> 実際に実行したいテスト用スクリプト

ということになります（テスト用スクリプトの名前は何でもいいです）．

#### テスト実行用エントリーポイント

まずはテスト実行用のエントリーポイントを用意します．こちらの実装はそのプロジェクトがどういうものであるかによって異なりますが，今回のように単純に通信のみのテストを行いたい場合には，以下のようなテンプレの HTML ファイルを用意するだけで事足ります．

```html:web/index.html
<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <title>Browser tests</title>
    <link rel="stylesheet" href="https://code.jquery.com/qunit/qunit-2.15.0.css">
  </head>
  <body>
    <div id="qunit"></div>
    <div id="qunit-fixture"></div>

    <!-- QUnit -->
    <script src="https://code.jquery.com/qunit/qunit-2.15.0.js"></script>

    <!-- Test scripts -->
    <script src="./tests/hello.test.js"></script>
    <script src="./tests/bye.test.js"></script>
    <!-- テスト用のスクリプトが増えればここに追加する -->
  </body>
</html>
```

#### テスト用スクリプト

次に実際にブラウザさんに実行してもらうテスト用スクリプトを用意します．このスクリプトの実装では，上述したように QUnit を使用します．QUnit の具体的な使用方法については公式ドキュメントに譲りますが，基本的なテストの流れは「リクエスト --> レスポンスの検証」の流れになるでしょう．

**テスト用スクリプトのファイルの分割方法**については任意ですが，個人的にしっくりした分割方法は**「1つのエンドポイントにつき1つのテストスクリプト」に基づいた分割方法**です．これについては好みの問題なので，色々試行錯誤してみるといいかもしれません．

まずは「/hello」への通信テストを行うためのテストスクリプトを書きます．

```javascript:web/tests/hello.test.js
QUnit.module("/hello");

const uriHello = "http://localhost:9000/hello";

QUnit.test("GET", (assert) => {
  const done = assert.async();

  fetch(uriHello, { method: "GET" })
  .then((res) => {
    assert.true(res.ok);

    res.text()
    .then((body) => {
      assert.equal(body, "Hello, Client!");
    })
    .catch((err) => {
      throw err;
    });
  })
  .catch((err) => { throw err; })
  .finally(() => { done(); });
});

QUnit.test("POST", (assert) => {
  const done = assert.async();
  const data = {
    account_id: "hogehoge",
    email_addr: "hogehoge@hoge.com",
    age: 99,
  };

  fetch(uriHello, {
    method: "POST",
    body: JSON.stringify(data),
    headers: { "Content-Type": "application/json" },
  })
  .then((res) => {
    assert.true(res.ok);
  })
  .catch((err) => {
    throw err;
  })
  .finally(() => {
    done();
  });
});
```

次に「/bye」への通信テストを行うためのテストスクリプトを書きます．

```javascript:web/tests/bye.test.js
QUnit.module("/bye");

const uriBye = "http://localhost:9000/bye";

QUnit.test("GET", (assert) => {
  const done = assert.async();

  fetch(uriBye, { method: "GET" })
  .then((res) => {
    assert.true(res.ok);

    res.text()
    .then((body) => {
      assert.equal(body, "Bye, Client!");
    })
    .catch((err) => {
      throw err;
    });
  })
  .catch((err) => { throw err; })
  .finally(() => { done(); });
});
```

### テストランナー

最後にテストランナー（全てのテストを実行するためのスクリプト）を実装します．上述したように，今回はブラウザ，Web サーバー，App サーバーを使ってテストを行うので，マルチプロセスでテストを実行します．具体的には，2つの子プロセスを生成し，そこで Web サーバーと App サーバーを起動します．起動後，親プロセス側で Web サーバーの URI を指定してブラウザを起動させ，そのブラウザ上でテストを実行します．今回は特別なサードパーティ製のライブラリは使用せず，標準ライブラリのみで実装しています．

```python:main.py
from http.server import SimpleHTTPRequestHandler
from multiprocessing import Process
from pathlib import Path
import socket
from socketserver import (
    BaseRequestHandler,
    BaseServer,
    TCPServer,
)
import time
import typing as t
import webbrowser

from bamboo import WSGITestExecutor

from app import app


HOST_WEB = "localhost"
PORT_WEB = 8000
HOST_APP = "localhost"
PORT_APP = 9000
DIR_WEB = str(Path(__file__).absolute().parent / "web")
SLEEP_TIME_SEVER_INIT = 0.05


class WebHTTPRequestHandler(SimpleHTTPRequestHandler):
    """ドキュメントルートを指定するためのリクエストハンドラ"""

    def setup(self) -> None:
        super().setup()

        self.directory = DIR_WEB


class WebServer(TCPServer):
    """アドレスを再利用するために改造した TCPServer"""

    def __init__(
        self,
        server_address: t.Tuple[str, int],
        RequestHandlerClass: t.Callable[..., BaseRequestHandler],
        bind_and_activate: bool = True,
    ) -> None:
        BaseServer.__init__(
            self,
            server_address,
            RequestHandlerClass,
        )
        self.socket = socket.socket(
            self.address_family,
            self.socket_type,
        )

        # この部分が TCPServer と異なる
        self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)

        if bind_and_activate:
            try:
                self.server_bind()
                self.server_activate()
            except:
                self.server_close()
                raise


def run_app_server() -> None:
    """App サーバーを起動"""
    WSGITestExecutor.debug(app, HOST_APP, PORT_APP)


def run_web_server() -> None:
    """Web サーバーを起動"""
    with WebServer(
        (HOST_APP, PORT_WEB),
        WebHTTPRequestHandler,
    ) as server:
        server.serve_forever()


def run_servers(sleep_server_init: float) -> t.Tuple[Process, Process]:
    """2つのサーバーを子プロセスで起動"""
    ps_app = Process(target=run_app_server)
    ps_app.start()
    ps_web = Process(target=run_web_server)
    ps_web.start()

    # サーバー起動までスリープ（サーバーの起動時間によって調整）
    time.sleep(sleep_server_init)

    return (ps_app, ps_web)


def main() -> None:
    """テストを実行"""
    ps_app, ps_web = run_servers(SLEEP_TIME_SEVER_INIT)
    webbrowser.open_new(f"http://{HOST_WEB}:{PORT_WEB}")

    try:
        while True:
            time.sleep(10000)
    except KeyboardInterrupt:
        print()
    finally:
        ps_app.terminate()
        ps_web.terminate()
        ps_app.join()
        ps_web.join()
        ps_app.close()
        ps_web.close()


if __name__ == "__main__":
    main()
```

## 実行

`main.py` を実行することでテストを実行できます：

```
$ python main.py
```

テストを実行するとブラウザが起動され，無事成功すると下図のように表示されます．もし失敗すればその原因が表示されます（ブラウザのコンソールも見た方がいいです）．

![test-qunit.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/453304/9d7bf505-de84-8b2e-0b2c-298444e53991.png)

ブラウザを閉じても上記のコマンドを実行したターミナルはブロックされたままですが，それは今回の実装では Python 側がブラウザが閉じたことを認識出来ないためです．`Ctrl-C` を押してテストの実行を終了させましょう．

## おわりに

本記事は Python で実装した Web アプリケーションをブラウザの JavaScript 用 API を使用してテストする手法（我流）についてまとめてみました．最終的にテストが1つのコマンドに収まったので，個人的には最小限これでいいかなという感じです．ただ，Python で扱えるブラウザ操作用のツールとして [Playwright for Python](https://playwright.dev/python/docs/intro/) などがあるので，これらを使って同じようなことが出来るのならそちらを採用した方が良いかもしれません．