ミルク色の記録

やったこと、やってみたこと

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.WebDriverWaitpoll_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_PORTDB_PORTREDIS_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環境変数が渡される。 ここから、gw0gw1といった自分のワーカー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_portredis_portdb_portを記述できるようにした。

そして、この設定ファイルを読み込んで使うためのAPIenvironment.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だけで、FirefoxIE、Edgeは試していない。 Firefoxは大丈夫そうな気がするけど、IEとEdgeは駄目なんじゃないかと予想している。

まとめ

並列化して実行時間をいい感じに縮められた。

前回の参考リポジトリにも反映しておいたけど、こちらはそもそもDBとか使わないやつなのであまり恩恵はない。 とりあえずテンプレート用に入れておいた。

github.com

こちらは並列化なしの結果が次のような感じ。

$ 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 ============================================================