テストを5倍速にする

f:id:feiz:20191219181232p:plain

この記事はBASE Advent Calendar 2019の20日目の記事です。

devblog.thebase.in

PAY株式会社でテックリードを務める東と申します。 主にバックエンド全般に広く携わっています。最近はサーバーアプリばかり書いていますがインフラもわりとやります。


当ブログの読者の方には弊社のことをご存じない方もたくさんいらっしゃるかと思いますので、簡単に社の紹介をさせていただきます。

PAY株式会社はBASE株式会社の100%子会社で、オンライン決済サービス「PAY.JP」とID決済サービス「PAY ID」などの決済サービスを開発・運営している会社です。 「支払いのすべてをシンプルに」をミッションに掲げ、お金を扱うすべての事業者・個人がもっと豊かな生活ができることを目指しています。


さて、決済というミッションクリティカルなテーマを扱うにあたって、品質保証は最も重要な課題です。 弊社のメインプロダクトたるオンライン決済サービス「PAY.JP」にも5000ケース程度の自動テストが記述されており、常時CIやローカルで実行され続けています。

自動テストはその実行時間がネックとなることが多々あります。今年の9月頃まで、PAYのテストはCircleCIで15分程度かかっていました。

テストが遅いと開発のテンポが落ち、CIが詰まりはじめ、そのストレスが限界を超えればお金で解決することを余儀なくされます。 しかもいくらお金を積んだところでcpu1コアあたりのクロックは頭打ちであり、1テストあたりの実行時間はそう簡単には短くはなりません。 そのままテスト実行時間が育ち続ければ、早晩 一人三勤制 の世界が到来することでしょう。

幸いにして昨今は個人の開発マシンですら8core16coreの時代です。これを生かさない手はありません。 今回は、並列化を軸にしたPAYのテスト高速化の取り組みについてお話させていただきます。

構成

言語
python3
フレームワーク
pyramid
テストランナー
pytest
主要なミドルウェア
PostgreSQL, Redis
ローカルテスト実行環境
Docker, docker-compose

今回の内容にはpyramidフレームワーク固有の事情はほぼ出てこないため、python3+pytest全般のtipsとして利用できるかと思います。

テストランナーを並列実行に対応させる

まずはpytestの並列化拡張を探すところから始めました。 2年ほど前に検討した時点では pytest-xdist という拡張が存在していましたが、並列タスクを実行する場所を色々選べる反面制約が多く、弊社の用途では使い物になりませんでした。

一方去年現れた pytest-parallel はpytestの実行をpythonのthreading/multiprocessingで分散するシンプルなもので、まさに我々の求めているものでした。

pytest-parallelの導入

pytest-parallel自体はインストールして--workers=NUMのようなオプションをつけるだけで並列化できるのですが、とりあえずテスト実行をしてみたところ実行が10倍以上遅くなりました。 調べたところfixtureのscope(どのような単位でfixtureを再生成するかの設定)機能が効かなくなり、すべてfunction scope扱いになってしまっているようでした。 PAYのテストでは、FunctionalテストのためにpyramidのWSGIアプリケーションインスタンスを作る巨大なfixtureがsession scopeで用意されています。そしてscope判定が壊れているせいでこれがテストケース実行のたびに再生成されているようです。

さすがにこれが直らないことには話がはじまらないということで、直したものがこちらになります。

https://github.com/feiz/pytest-parallel/tree/pass-nextitem-to-runtest_protocol

これを導入することでテストランナーの並列化対応は解決しました。1

multithread vs multiprocess

pytest-parallelはマルチスレッド/マルチプロセスともに対応していますが、SQLAlchemyがスレッドセーフでない、そもそも後付けでスレッドセーフにするのは難易度が高いなどの問題により、マルチプロセスのみを使うことにしました。 割り切ることはたいせつです。

データベース

次にデータベースです。自動テスト用のデータベースを準備する機能自体はもとからsession fixtureとして実装されていましたが、マルチプロセス化するとこれらが各プロセスでそれぞれ実行されてしまい、同じ名前のデータベースを生成しようとしてクラッシュします。 また、同一のdbに複数のプロセスからテストを実行する場合、トランザクションで詰まってパフォーマンスに悪影響があるということも考えられます。

そこで、db名末尾にpidを付与してプロセスごとに独立したデータベースを参照するように手を加えました。

    - DB_URI = "postgres://db:5432/test_payjp"
    + import os
    + DB_URI = f"postgres://db:5432/test_payjp_{os.getpid()}"

これは簡略化した擬似コードですが、PAYの設定ファイルはpythonファイルになっているため、割と簡単に実現できました。

また、テスト環境のPostgresイメージをpostgres-ramに変更することで、すこしばかりのパフォーマンス改善も行いました。 本当はin-memory SQLiteに差し替え可能にできれればよかったのですが、PAYはPostgresに依存しているところが多く、残念ながら実行できていません。

ソケット枯渇問題

テストのために64coreマシンで128並列実行などをして遊んでいたところ、テスト実行中に突然DBコネクションが張れなくなる問題が発生しました。 察しのいい方ならピンときそうなトラブルですが、TIME_WAITなコネクションが増殖してアプリケーションコンテナ側のポートが食いつぶされてしまったのが原因でした。2 これはテスト中だけSQLAlchemy側のコネクションプールを有効にすることで解消できました。

余談

並列化の実験にはGCPのプリエンプティブルインスタンスが安くて手軽で非常に便利でした。個人のアカウントで64coreぐらいのマシンを立ち上げて実験してすぐ落とすぐらいの使い方であれば月300円ぐらいで十分に遊べます。 並列化後にボトルネックがどういうところに現れるのかの肌感を掴んでおくためにも、一度はやっておくとよいでしょう。

Redis

ストレージ整備の一環としてRedisのテスト環境も見直しました。これまではdocker-composeで用意された本物のRedisにredis-pyでそのまま接続していましたが、これをすべてin-memory実装である FakeRedis に置き換えられるように改修しました。

cpuコアと同様、昨今の環境ではメモリも大量に余っていることが多いため、やれるものをinmemoryにしてしまうのは手っ取り早いテスト高速化の手法です。3 過去の話ですが、djangoを使っていた頃は storage apiinmemorystorage でファイルシステムを触るテストコードを高速化する手法はお気に入りでした。

テスタビリティ全般に通じる話ですが、重要なのはミドルウェアや外部環境との接続部分をラップして差し替えが自由にできる構造を最初から組んでおくことです。

テストコードの順番依存

テスト環境の改善により実行はできるようになりましたが、不可解なFAILがランダムかつ大量に発生するようになりました。 これまでのテストは直列かつ同一の実行順で実行されていましたが、マルチプロセス分散されたことで実行順が不安定になり、前提条件が満たされていない実行が発生してしまうようになってしまったようです。

PAYではテストケースごとにDBをrollbackするようになっていないため、このような問題が起こりえます。実行の前提条件構築がテストケース間で正しく分離されていない悪いテストを書いてしまっているということです。これはひどい。

2ヶ月程度をかけて、これをしらみ潰しに改善しました。 残念ながらここについては一般的な解法はなさそうに思います。参考までに、原因を特定する手順として社内ドキュメントに書いた内容をかいつまんでご紹介します。

1. エラーの直接の原因を把握する
大抵、常識的に考えて当然(ある|ない)はずのデータが(ない|ある)といった不可解なエラーとして表出します。混乱せずにまずは何が起こったのかを正しく読み解きます。
2. できるだけ狭い範囲で再現させる
テストランナーの個別実行機能を使ってできるだけ狭い範囲で100%再現できるように発生条件を絞り込みます。
  • 調査中は並列実行をやめる
  • エラー内容から原因にアタリをつけ、標準の実行ケース指定機能で範囲を絞る pytest tests/integration/api/test_charge.py::TestCharge
  • randomize拡張(pytest-randomなど)でランダム実行を繰り返す
  • 失敗するパターンを見つけたら、ランダムシードを記録して追試する(どこのrandomize拡張にもあると思います)
  • 再現したら、デバッガを仕込んで個別に追う -> 原因特定
3. 対処する
多くの場合、前提データの前処理や後処理が悪いことが原因のため、テストデータ生成基盤に手を加えて改善することになります。 pytestの[yield fixture](https://docs.pytest.org/en/latest/fixture.html#fixture-finalization-executing-teardown-code) はsetUp/tearDownメソッドのような手法よりもfixture個別の前後処理の記述がやりやすいため、積極的に活用するとよいでしょう。

本来の文章では、実際に直したケースを再現できるよう、私が実際に調査・修正した過程をchangeset idを添えて記載していました。

完成

以上のような改善を経て、pytest-parallelの導入を決意してからおよそ7ヶ月、並列化を志したときから数えれば実に1年と1ヶ月をかけてちゃんと動く並列化を実現できました。

結果として、CircleCIの自動テストタスク実行時間が15分34秒 -> 2分53秒に改善しました。(5.3倍!)

before
before

after
after

テスト基盤を整えよう。今すぐ。

私の経験上、テストはアプリケーション本体以上にコードが腐敗しやすい場所に思えます。 もし、あなたがこれからテストコードを書き始めるか、プロジェクトのテストコードがまだ育っていないなら、今すぐにでもお使いのテストランナーのrandomizeオプションをオンにしてみるべきです。それだけで今回一番つらかった問題(順番依存)が起きてしまうリスクを大幅に低減できます。 必要に駆られる前から並列化を行っておくこともおすすめできます。本来テストケースは分離されていて当然のものであり、きれいなテストを書いていれば簡単に並列化できるものだからです。 これらは不健全なテストをふるいにかけ、テストコードを健全に保つ大きな助けになります。

ミドルウェアへの直接的な依存もよく吟味すべきです。昨今はdockerの台頭でテスト環境にミドルウェアそのものを用意することのハードルが非常に低くなっているため、手を抜きがちな部分ではあります。 しかし、テストダブルで依存を自由に管理できないとそもそもテストを書くのが面倒極まりないですし、書くのが面倒なテストは書かれなくなるか、コピペが横行して加速度的に腐敗します。

残念ながらすでに問題が起きているのなら、諦めて粛々と改善しましょう。それが一番の近道です。

おわりに

テストに実行速度の問題が表出するようなタイミングでは、対処しようにも他の問題と複合していて手が出せない状態になっている可能性が高いです。このような問題に拘って本来やるべき開発に支障がでたり、CircleCIに無限に予算を吸われてしまうようになる前に、ぜひテスト実行基盤を見直してみることをおすすめします。

この記事が、みなさんの快適な開発の助けになれば幸いです。

明日は

明日は BASE Owners Growthチームの大木さんと、PAY セキュリティエンジニアのクリスです。


  1. 使用には耐えるものですが、魔改造の粋を超えられていない(スレッド実行機能が死んでいる)こともあり、本家にPRは送れていません。すみません。;(

  2. PAYの本番環境にはPgBouncerが居るため、アプリケーション側ではコネクションプーリングは行わない設定になっていました。

  3. 手っ取り早いのは確かなのですが、「本来使うものとは違うもの」でテストしているのもまた確かなので、場合によっては速度を度外視して本来のものに近い環境でテストするステージを設けることも検討するべきです。