弁護士ドットコム株式会社 Creators’ blog

弁護士ドットコムがエンジニア・デザイナーのサービス開発事例やデザイン活動を発信する公式ブログです。

サービス運用10年以上の重み、弁護士ドットコムのユニットテスト速度改善

GitLab CI パイプライン

初めまして、弁護士ドットコムでエンジニアをやっている @namizatork です。

第1回目で @komtaki さんが書いた 「弁護士ドットコムサービスのビジネスと共にみるマイクロサービスの進化」 がプチバズりしていたので第2回目となる今回、少し 滑らないか 心配ですが頑張って書きます。

本記事では、サービス開始から10年以上が経過したサービスのユニットテストを改善したという内容をお話しします。

ユニットテスト改善チームの立ち上げ

弊社では普段のプロジェクトとは別に毎週金曜日 TFD (Tech Focus Day) という、技術的負債と向き合う場を用意しています。
簡単に言うと、Googleの提唱する20%ルールのような取り組みで、業務の20%を使い普段のチームとは別にプロジェクトを立ち上げて技術的負債を返済しようというものです。

その取り組みの中で、ユニットテストの実行に時間が掛かりすぎているという問題が挙げられました。
弁護士ドットコムは10年以上続いているサービスで、ファイル数も膨大です。テストに時間が掛かるのは多少仕方ない部分もあります。

とはいえ、ものには限度があります。現在、ユニットテストの実行時間 (正確には、ユニットテストを含むGitLab CIパイプライン全体の実行時間) は20分以上掛かっていることも珍しくなく、リリース時にはパイプラインが通るまでに20分以上の待ち時間が発生してしまいます。
本来、リリース時は問題が起きないかどうか見守るべきですが、待ち時間が長くなると、どうしても別のことをしてしまいます。そうなると障害を見過ごすリスクが高まるため、エンジニアの中でも課題となっていました。

そこで、ユニットテスト実行時間を少しでも減らすために、TFDの場を使ってユニットテスト改善チームを立ち上げました。

環境

- PHP 7.4
- PHPUnit 9.5
- GitLab CI

改善前に必要なことを明確化

まずは課題に取り組む前に 概要(なにをやるのか)理由(なぜやるのか) を明確にする必要があります。
ある程度、解決案の目星が付いていればそれも合わせて記述しておくことでチーム内での役割分担もしやすくなります。

それらを誰でも閲覧できるように共有しておけば、チームが路頭に迷うことはなくなりますね。

今回の場合の例

# 概要(なにをやるの?)
時間の掛かっているユニットテストを洗い出し、改善する。

# 理由(なぜやるの?)
- (恐らく)一部のユニットテストによって、ユニットテスト全体の時間が掛かっている。
- ユニットテストが遅いと、GitLab CI パイプライン実行に時間が掛かる。
- パイプラインを通過しないとデプロイできない為、結果としてデプロイに時間が掛かる。
- デプロイに時間が掛かると、リリース後の確認までの待ち時間が発生する。
- 待ち時間が長いと放置してしまったりして、障害を見過ごすリスクが高まる。
- また、デプロイに時間が掛かるとデプロイへの心理的障壁が高くなり、デプロイ頻度が低くなる。

# 達成条件
パイプライン上のPHPUnit実行時間が対象
🥉 ~ 5分以内
🥈 ~ 3分以内
🥇 ~ 2分以内

# 解決案
- 時間の掛かっているユニットテストを特定する
- 遅い原因がデータベースアクセスなどであれば、モック化する
- ユニットテストを並列実行する(方法を考える)

キックオフ

現在時点での弊社のmasterデプロイのJobから最新10件を抽出してユニットテストに掛かっている時間の平均を算出した結果、約9分26秒 でした。
他にもビルドする時間なども含めるとパイプラインの実行に20分弱は掛かる計算です。
この数字をもとに、いかに短くできるのか、ユニットテスト改善チームで改善を回していきます。


やるべきことは既に「改善前に必要なことを明確化」で明確にしているので、チーム内でキックオフミーティングなどはあえて行わず、各々が解決案を元に特定し、Slackでやり取りをしながら、必要に応じてGoogle Meetなどを使って進めることになりました。

そこで改善チームでやったことを以下の3つに分けて書いていきます。

ユニットテストを並列実行する

現状ではパイプライン上でユニットテストを実行する際、tests/配下のファイルを上から順に1つずつ実行し、実行が終わってから次のテストに進みます。 そのため、1つのテストに時間が掛かっていた場合でも次の処理に進めず時間が掛かってしまいます。

そのユニットテストを複数のディレクトリに分割して、それらを並列実行することでパイプラインの実行時間を大幅に削減できるのではないかと考え、取り入れることにしました。

GitLab parallel

最初に私たちのチームでは、 How to run over 30k tests in under 5 minutes を参考に、記事の中で紹介されている ParaTest という PHPUnit を並列実行できる拡張機能をローカル環境で試してみました。

しかし、いざ実行してみると一部のテストに順序依存が発生しているのか実行するたびに、エラーの数が変動し、原因の特定が難しく断念しました。
代わりに弊社が利用している GitLab CI で parallel:matrix という並列実行の機能が用意されていたのでそちらを使用しました。

https://docs.gitlab.com/ee/ci/yaml/#parallel

並列実行の数を増やしすぎても今度は GitLab CI パイプラインのジョブが大量に実行され、 Gitlab Runner のリソースを食い尽くし、GitLab 自体の負荷が高まってしまうため、 ディレクトリを大きく modules, models, others の3つに分割して、並列で実行するようにしました。

PHPUnitでテストスイートを3つに分割した例

...
  <testsuites>
    <testsuite name="models-testsuite">
      <directory suffix=".php">../unit/models</directory>
    </testsuite>
    <testsuite name="modules-testsuite">
      <directory suffix=".php">../unit/modules</directory>
    </testsuite>
    <testsuite name="others-testsuite">
      <directory suffix=".php">../unit</directory>
      <exclude>../unit/models</exclude>
      <exclude>../unit/modules</exclude>
    </testsuite>
  </testsuites>
...

GitLab CI での並列実行を設定した例

phpunit test:
  parallel:
    matrix:
    - PHPUNIT_TEST_SUITE:
      - models
      - modules
      - others
  variables:
    TEST_OPT: "testsuite=${PHPUNIT_TEST_SUITE}"
      ...
...

この取り組みにより、GitLab CI のパイプライン上で9分強掛かっていたユニットテストが約3〜4分で実行できるようになりました👏

時間の掛かっているユニットテストを特定する

全体で時間の掛かるテストをむやみやたらに探すのは非効率です。
私たちのチームでは Speedtrap という PHPUnit の遅いテストケースを羅列してくれるプラグインをローカル環境で導入して対応しました。

phpunit-speedtrap

実行方法

インストール

composer require --dev johnkary/phpunit-speedtrap
composer dump-autoload

phpunit.xmlの修正

SpeedtrapのREADME でもあるように閾値を設定することもできる。 ※デフォルトは時間の掛かるテストの閾値が500ms、テストケースに羅列する数が10件

<phpunit bootstrap="vendor/autoload.php">
...
    <listeners>
        <listener class="JohnKary\PHPUnit\Listener\SpeedTrapListener" />
    </listeners>
</phpunit>

あとはテストを実行すると以下のように時間の掛かっているテストケースを上から順に羅列してくれます。※テストケースは例です。

15825ms to run ×××Test::ログインに成功している
15414ms to run ×××Test::ユーザーにメールが送信できる
14236ms to run ×××Test::ユーザーがメール拒否の場合、送信できない
10012ms to run ×××Test::ユーザーに通知を送ることができる
...

テストが遅い原因を特定&解決

時間の掛かっているユニットテストを特定する で特定したテストケースを元にテストが遅い原因を調査します。
調査する場合はチーム内で誰がどのテストケースを対応しているかひと目で分かるように、スプレッドシートでシートを作成して管理するようにしました。
普段は Jira を使ったチケット管理も行っていますが、テストケース単位だと数が多すぎて管理が難しいと判断して、スプレッドシートを使用しました。

時間の掛かっているテストケースの管理シート

管理シートを作成したところで、テストケースが遅い原因を考えてみます。 大きく分けて以下の3つのパターンが想定できます。

  • (1) 多くのループ処理やスリープ処理を利用している
  • (2) 誤ったsetUpやtearDownの使い方をしている
  • (3) DBに依存しており、アクセスに時間が掛かっている

(1) 多くのループ処理やスリープ処理を利用している

このケースの場合、テストを修正するというよりは、メソッドそのものやデータ構造を変更するなどの必要多いため判断が難しいところです。
以下のように基準を設けてそこからあぶれたテストだけ対応するなどの工夫が必要です。

  • foreach() を3つ以上利用していることが原因でテストが遅い場合は、そもそもforeach() を3つ以上利用する必要があるかを見極める
  • sleep() を使用しているところの処理を追ってみて、本当にsleepが必要かどうかを見極める
    • 使用する必要があった場合でも数回呼ばれている場合、回数または秒数を減らすことができないかも合わせて確認する

弊社の場合だと、一部のテストで100件処理するごとにsleep(3)で3秒間処理が中断するというテストケースがあり、そのテストケースだけで1分以上の時間が掛かっていました。

そのテスト内容を見直すと100件という数字とsleep(3)の3秒という数字は、境界値で正常に処理が行われているかが保証できていればテストとして問題ないと判断し、以下のように修正しました。

- 100件 → 10件
  - (テストでは境界値、つまり今回の修正で11件目で処理が正常に行われていればOK)
- sleep(3) → 任意の数字
  - (数字を引数で受け取れるように修正、テストケース毎に指定した数字を扱えればOK)

この修正で、1分以上掛かっていたこのテストケースが 10秒以下 で実行できるようになりました👏

(2) 誤ったsetUpやtearDownの使い方をしている

まず、前提知識として、PHPUnitの setup()tearDown() の実行タイミングについて説明すると、これらはそれぞれ、テストケースの に都度実行されます。

そしてユニットテストでは、単体での動作を保証するために setup()tearDown() いずれかを使用してテーブルをリセットする必要があります。 弊社が利用しているYiiというフレームワークでテストデータを用意する yii-factory-girl (Rubyのfactory_botの旧verのようなもの) という機能では、テーブルを初期化する際に factorygirl->flush() のように記述します。
こちらのメソッドの使用について、ライブラリ内のコメントによると以下のように記述されています。(日本語翻訳)

factory->create()を呼び出して作成されたDBレコードをクリーンアップします。 副作用を回避するために、テストのtearDown()メソッドで呼び出す必要があります。

しかし、多くの既存のテストで tearDown() のみで呼び出せば十分なところを、setup() でも使用していました。 この場合、1つのテストメソッドの実行が終了し、テーブルの初期化が実行された後、次のテストメソッドの実行前に、再度テーブルの初期化が実行されることになります。

それらを setUp()tearDown() のどちらかに統一することで、無駄なDBへのアクセスを削減でき、実行時間が大きく改善しました。

またテストケース毎に行う必要はないが、クラス内で共通した値(データ)を利用したい場合はテストクラス実行前に1度だけ実行する setUpBeforeClass() や実行後に1度だけ実行する tearDownAfterClass() があるので、そちらもケースに合わせて利用すると改善につながるかも知れません。

(3) DBに依存しており、アクセスに時間が掛かっている

上でも少し触れたとおり、弊社ではテストデータの作成に yii-factory-girl を使用しており、それらはテストケースごとに使用するテーブルのデータを作成・削除を繰り返す必要があります。 setUp()tearDown() でテストケースごとにそれらを繰り返す場合、1つのテストケース毎に約300ms掛かっていることが分かりました。 上の対応で、 setUp() に統一したとしても、テストケースが多ければその分 factorygirl->flush() を使ったテーブルの初期化が実行されます。

そのため、DBからのデータ取得が不要と判断したケースは、DBにアクセスせず、テストコード内でインスタンスやモックを直接生成するように修正しました。

$user = Yii::app()->factorygirl->create(User::class, ['id' => 1]);
$lawyer = Yii::app()->factorygirl->create(Lawyer::class, ['userId' => $user->id]);

// 弁護士のユーザーidが取得できる
$this->assertSame(1, $lawyer->getUserId());

これをインスタンスで代替するとこのような記述になります。

$user = new User();
$user->id = 1;

$lawyer = new Lawyer();
$lawyer->userId = $user->id;

// 弁護士のユーザーidが取得できる
$this->assertSame(1, $lawyer->getUserId());

記述こそ長くなってしまいますが、これらをすべてのテストケースごとに修正すれば setUp()tearDown() でデータを作成・削除する必要が無くなり、 テストケース1つに対してテーブルを用意する時間の 300ms が削減でき、仮に一つのテストに10個のテストケースがあった場合だと 約3秒(3,000ms)の短縮になります👏

成果

実はこの記事執筆時の2022年2月でもまだこの取り組みはまだ終わっていないため、最終の成果とは言えませんが、
現時点で3つに分割したユニットテストは約3〜4分台で実行が完了しているため、
実行時間が約 1/2以下 に抑えられることができました🎉

まとめ

サービスが大きくなれば、その分機能も増えて、テストも増える。
それ自体とても良いことですが、それと同時に負債も溜まっていく。ということを頭に入れておいて、弊社が取り組んでいる TFD の取り組みを導入するとまではいかずとも一度時間を取ってテストを見直すというのも1つの手かもしれません。

弊社では採用も行っているので、弊社の取り組みが面白そう、チャレンジしてみたい!という方の募集をお待ちしております。