E2Eテストの実行時間を1/2とか1/4に縮めた
前回も触れたけど、普段Seleniumを使ってpythonでE2Eテストを書いている。
E2Eテストの接続先サーバはdocker-composeを使って必要なものをガッと立ち上げていて、 テスト内でredisやDBに直接接続してデータの初期化をしながらやっている。
使っているdocker-compose.yml
はだいたい次のような感じ。
version: '2' services: web: container_name: "test-web" image: nginx:1.11 ports: - "8080:80" volumes: - /etc/localtime:/etc/localtime - ./build:/usr/share/nginx/html - ./fixture/nginx.conf:/etc/nginx/conf.d/default.conf depends_on: - app app: container_name: "test-app" image: myapp volumes: - /etc/localtime:/etc/localtime - ./fixture/run.sh:/myapp/run.sh - ./fixture/production.ini:/myapp/production.ini depends_on: - redis - db redis: container_name: "test-redis" ports: - "6379:6379" volumes: - /etc/localtime:/etc/localtime image: redis db: container_name: "test-db" image: postgres:9.5 ports: - "5432:5432" volumes: - /etc/localtime:/etc/localtime - ./fixture/postgresql/initdb:/docker-entrypoint-initdb.d environment: - POSTGRES_PASSWORD=postgres - POSTGRES_USER=postgres
webサーバとしてnginxがあり、WEB APIのためのappサーバへのリクエストをリバースプロキシする。 appサーバはAPIのリクエストが来たら、dbやredisに対してアレコレする。
これをdocker-compose up -d
したあとにpy.test test
して実行する...という感じにやっていた。
あるアプリケーションで、機能が増えるに連れてだんだんとテストの量が増えていき、 現在は150件くらいのテストを実行するのに15分近くかかるようになっていた。
DBコンテナの/var/lib/postgresql/data
をtmpfsにし、selenium.webdriver.support.ui.WebDriverWait
のpoll_frequency
パラメータ(UIの変化を待機する条件をチェックする周期)をデフォルトの0.5秒から小さくして、
トータル時間12分位までには縮まったものの、劇的な効果は得られなかった。
そこで以前から考えていたpytest-xdistを使ったテストの並列化を試してみた。
pytest-xdistとdocker-composeの複数起動
pytest-xdistを使うとpy.test -n 2 test
のようにして、-n
オプションでワーカーの数を指定してテストを並列に実行できるようになる。
ただし、E2Eテストでこれをそのままやってしまうと、あるワーカーがテストを行っている最中に別のワーカーがDBの初期化をしてしまうので、 ワーカー毎に接続先のサーバ側を分ける必要があり、そのままでは使えない。
docker-composeは-p
オプションでプロジェクト名を指定して複数起動することができるので、
docker-compose.yml
に環境変数で各種ポートを指定できるように修正を加えて、プロジェクトごとにポートを変えて起動できるようにした。
修正したdocker-compose.yml
ファイルは次のような感じになった。
version: '2.1' services: web: container_name: "test-web-${ID:-1}" image: nginx:1.11 ports: - "${WEB_PORT:-8080}:80" volumes: - /etc/localtime:/etc/localtime - ./build:/usr/share/nginx/html - ./fixture/nginx.conf:/etc/nginx/conf.d/default.conf depends_on: - app app: container_name: "test-app-${ID:-1}" image: myapp volumes: - /etc/localtime:/etc/localtime - ./fixture/run.sh:/myapp/run.sh - ./fixture/production.ini:/myapp/production.ini depends_on: - redis - db redis: container_name: "test-redis-${ID:-1}" ports: - "${REDIS_PORT:-6379}:6379" volumes: - /etc/localtime:/etc/localtime image: redis db: container_name: "test-db-${ID:-1}" image: postgres:9.5 ports: - "${DB_PORT:-5432}:5432" tmpfs: - /var/lib/postgresql/data volumes: - /etc/localtime:/etc/localtime - ./fixture/postgresql/initdb:/docker-entrypoint-initdb.d environment: - POSTGRES_PASSWORD=postgres - POSTGRES_USER=postgres
コンテナ名が被らないように名称の末尾に付けるためのID
と各ポートWEB_PORT
、DB_PORT
、REDIS_PORT
を環境変数で渡せるようにした。
これで次のようにコマンドを叩けば同じ構成の環境をポートを変えて複数立ち上げられる。
$ ID=1 WEB_PORT=8080 DB_PORT=54320 REDIS_PORT=63790 docker-compose -p s1 up -d $ ID=2 WEB_PORT=8081 DB_PORT=54321 REDIS_PORT=63791 docker-compose -p s2 up -d
pytest-xdistによって、テスト実行中のワーカーにはPYTEST_XDIST_WORKER
環境変数が渡される。
ここから、gw0
やgw1
といった自分のワーカーIDを取得できる。
このワーカーIDと接続するサーバのポートを関連付けて使うようにすれば、ワーカー毎に異なるサーバへ接続して互いに干渉することなくテストできる。
ワーカーと接続サーバの関連付け
docker-composeの環境変数に渡す値と、ワーカー内で使用するポートの値を関連付けて管理するために、次のようなe2e.config.yml
設定ファイルを用意した。
workers: - web_port: 8080 redis_port: 63790 db_port: 54320 - web_port: 8081 redis_port: 63791 db_port: 54321 - web_port: 8082 redis_port: 63792 db_port: 54322 - web_port: 8083 redis_port: 63793 db_port: 54323
ワーカーごとにweb_port
、redis_port
、db_port
を記述できるようにした。
そして、この設定ファイルを読み込んで使うためのAPIをenvironment.py
として用意した。
import yaml import subprocess import os def load_e2e_config(filepath): with open(filepath, 'r') as f: data = yaml.load(f, Loader=yaml.SafeLoader) config = dict() workers = data.get('workers', []) for i, w in enumerate(workers): w['id'] = i + 1 config['gw%d' % i] = w return config def docker_compose_up(config): for name, c in config.items(): env = os.environ.copy() env['ID'] = str(c['id']) env['WEB_PORT'] = str(c['web_port']) env['REDIS_PORT'] = str(c['redis_port']) env['DB_PORT'] = str(c['db_port']) subprocess.run(['docker-compose', '-p', name, 'up', '-d'], env=env) def docker_compose_down(config): for name, c in config.items(): subprocess.run(['docker-compose', '-p', name, 'down'])
load_e2e_config
関数はe2e.config.yml
を読み込んでワーカーIDをキーとした設定オブジェクトに変換する。
docker_compose_up
関数はload_e2e_config
関数で作った設定オブジェクトを利用して、必要な数だけdocker環境を立ち上げる。
docker_compose_down
関数は立ち上げていたdocker環境を停止するために使う。
これを使ってサーバ起動用スクリプトをstartup-servers
として用意した。
#!/usr/bin/env python3 from myapp.environment import load_e2e_config, docker_compose_up if __name__ == '__main__': config = load_e2e_config('./e2e.config.yml') docker_compose_up(config)
同じくサーバ停止用スクリプトをhalt-servers
として用意した。
#!/usr/bin/env python3 from todo.environment import load_e2e_config, docker_compose_down if __name__ == '__main__': config = load_e2e_config('./e2e.config.yml') docker_compose_down(config)
テストケースのコードではsetUpメソッドでload_e2e_config
関数を使って設定ファイルを読み込み、PYTEST_XDIST_WORKER
環境変数から自身のワーカーIDを取得して、接続先のWeb、DB、Redisのポートを取り出して使うようにした。
def setUp(self): target_port = '8080' db_port = '5432' redis_port = '6379' worker = os.environ.get('PYTEST_XDIST_WORKER') if worker is not None: config = load_e2e_config(E2E_CONFIG_PATH) c = config[worker] target_port = str(c['web_port']) db_port = str(c['db_port']) redis_port = str(c['redis_port'])
テストの実行
用意したスクリプトと設定で、テストの実行を行った。
$ ./startup-servers $ HEADLESS=1 py.test -n 4 test $ ./halt-servers
並列化なしで12分位だった実行時間が、ワーカー数2つの並列化で6分位、ワーカー数4つで3分位で終わるようになった。
ちなみに使ったWebDriverはChromeだけで、FirefoxとIE、Edgeは試していない。 Firefoxは大丈夫そうな気がするけど、IEとEdgeは駄目なんじゃないかと予想している。
まとめ
並列化して実行時間をいい感じに縮められた。
前回の参考リポジトリにも反映しておいたけど、こちらはそもそもDBとか使わないやつなのであまり恩恵はない。 とりあえずテンプレート用に入れておいた。
こちらは並列化なしの結果が次のような感じ。
$ HEADLESS=1 py.test todo/ ============================================================== test session starts =============================================================== platform linux -- Python 3.7.1, pytest-4.2.1, py-1.8.0, pluggy-0.9.0 Using --randomly-seed=1551505883 rootdir: /work/my-todo-2019-early/e2e, inifile: plugins: xdist-1.26.1, randomly-1.2.3, forked-1.0.2 collected 5 items todo/specs/todos_test.py ..... [100%] =========================================================== 5 passed in 16.32 seconds ============================================================
並列化あり(ワーカー数4つ)だと結果が次のような感じになる。
$ HEADLESS=1 py.test -n 4 todo/ ============================================================== test session starts =============================================================== platform linux -- Python 3.7.1, pytest-4.2.1, py-1.8.0, pluggy-0.9.0 Using --randomly-seed=1551505959 rootdir: /work/my-todo-2019-early/e2e, inifile: plugins: xdist-1.26.1, randomly-1.2.3, forked-1.0.2 gw0 [5] / gw1 [5] / gw2 [5] / gw3 [5] ..... [100%] ============================================================ 5 passed in 6.59 seconds ============================================================