ひまネタNEWS(^o^) | スキマ時間にちょっとしたネタを提供スキマ時間に、話題のまとめを一気読み 全サイト自動巡回中
HIMA NETA ORIGINAL READER
本文だけモード Zenn IT・ガジェット 約11分で読めます

単体テストの実行時間を6割程度削減してみた

読みやすさ この端末に設定を保存します
書体
文字
背景

広告・ランキング・関連記事を除き、配信元の安全な文字装飾だけを残しています。

はじめに

こんにちは、Dress Code 株式会社でプロダクトエンジニアをやっている津田です。

AI が実装の大半を担ってからずいぶんと経ち、単体テストによってある程度の動作の保証をしてくれるようになった一方で、いつの間にか単体テストの実行時間が伸びていました。CI環境で50件計測し、p90 で 16分14秒 にもなっていました。これには GitHub Actions の setup 等も含め、ですが、単体テストの実行時間だけでも14分30秒かかっていました。
CIに時間がかかると、そのまま開発体験の悪さにも直結します。軽微な不具合修正であったとしてもリードタイムとしてその程度以上かかってしまう問題を引き起こします。

今回はそれを改善しました、というお話です。
あくまでも我々の置かれた状況での改善ですが、何か1つでも参考になるものがあれば幸いです。

背景

  • モジュラーモノリスを採用しており、全エンジニアが1つのリポジトリにコードを書きにくる
  • openspecを用いた仕様駆動開発をやっており、受け入れ条件に単体テストを書くことが多々ある
  • 約2,900ファイル、約3万ケースを全件実行していた
  • 技術スタック
    • ランタイム: Node.js v24
    • フレームワーク: NestJS v11
    • ORM: PrismaJS v7
    • ランナー: Vitest v4
    • CI環境: EC2 の r8gd.2xlarge で動く Self Hosted Runner

結論

  • 16分14秒 -> 5分59秒 に改善(≒63%)
  • 取り組み
    • 使用していないカバレッジレポートの停止
    • 重い import の特定と軽量化
    • forks -> threads 置き換え
    • 不要なテストの整理

アプローチ

パフォーマンス改善のプロセスは、以下のループを回して行うことが基本になりそうです。

  1. 計測し、
  2. ボトルネックを特定し、
  3. 改善を図る

処理速度や負荷をツールを用いたりログを埋め込んで数字として出し、そこから数値を悪化させているボトルネックを特定あるいは仮説を立て、改善策を並べ適用し再度計測する流れですね。

が、ここって別に人間が頑張らなくて良い領域だと思うんですよね。
計測に関しては、何を計測するかがズレなければAIでも十分できます。
ボトルネックの特定に関しては、何を所与の条件として捉えるかの判断が必要にはなりますが、今のAIの性能と指示の工夫や人間の介入で精度を高められそうです。
改善の図り方もいくつか考えられますが、仮説構築と検証のTry & Errorを高速に回す、と言う意味では人間がやらずともAIに試させるのは十分価値があると判断しました。

ということで、実作業はAIにやらせてます。私が介入したのは最初の目標値の設定と、アプローチに対する助言程度です。

特定された課題と対策

ループを回しながらボトルネックの特定を行っていたので、結果的に、と言う話にはなりますが、特定できた課題は以下の通りでした。

  • 利用していないカバレッジの収集・出力に7分かかっている
  • テスト実行時の import で時間がかかっている
  • 起動コスト
  • 重複しているテストケースが散見された

利用していないカバレッジの収集・出力に7分かかっている

カバレッジレポートはとりあえず取っていたものの、カバレッジの目標値を決めていなければそれ自体を何かしらの判断に使うこともありませんでした。
また、単体テストとは別に統合テストとしてDBやministackと実際に疎通するテストも用意しており、そちらで担保するケースが多くありました。

AIがゴリゴリ実装しているので(良い・悪いはあれど)、コードの量も絶対的に増えて、カバレッジ収集対象のコードが増えていた、ということもありそうです。開発当初はそこまで圧迫しないが、コードの絶対量が増えるにつれて徐々に時間がかかっていきますしね。

いずれにせよ、そもそも無駄な処理をずっとやり続けていた、と言うことになります。
対策としては背景を知っていそうな人にカバレッジの用途を聞いて、特になさそうなのでCIから排除しただけです。

これだけで7分程度改善できました。
正直改善効果のほとんどはここにありました・・。

テスト実行時の import で時間がかかっている

ORMとしてPrismaを利用しているが、1つのDBに多くのモデルが集まっているため、型定義を含む生成コードも肥大化していました。
1つのenum値を取り出すために Prisma が生成したクライアントをすべて読み込んでしまっている箇所では必要以上の読み込みが生じていました。

Prisma 7への移行時にprisma-client generatorを使うようにしており、Clientはnode_modules/@prisma/clientではなくsrc/generated/prisma/へ生成しています。
利用側は、それを再exportする共通ファイルを経由していました。

// src/shared-kernel/extension/prisma/client.ts
export * from '~/generated/prisma/client';

今回変更したのは、この共通ファイルからのimportです。
さらに生成先のclient.tsを見ると、enumのほかにClientのクラス生成やPrismaのnamespaceもつながっています。

// src/generated/prisma/client.ts の抜粋
import * as runtime from '@prisma/client/runtime/client';
import * as $Class from './internal/class';
import * as Prisma from './internal/prismaNamespace';

export * from './enums';
export const PrismaClient = $Class.getPrismaClientClass();

名前を限定してimportしても、この実行経路ではモジュールのトップレベルが評価されます。
つまり、enumが欲しいだけでもgetPrismaClientClass()まで実行される構成でした。

我々の生成物ではinternal/class.tsだけで約10MiBあり、スキーマ情報やデータモデルの初期化処理を含んでいます。
DBへのクエリを1本も発行しなくても、コードを読み込み、変換し、評価するコストは発生します。
さらにテストファイル間の分離を有効にしているため、この依存を読む負担がファイルごとの実行環境で繰り返されていました。

なので、enum専用の入口を作りました。

// src/shared-kernel/extension/prisma/enums.ts
export * from '~/generated/prisma/enums';

こちらの生成先はenumの値と型だけを公開し、Clientのruntimeへは依存しません。
利用側では、enumだけを必要とするimportを変更しています。
実際の差分の一つが以下です。

- import { OperationEngineNodeType } from '~/shared-kernel/extension/prisma/client';
+ import { OperationEngineNodeType } from '~/shared-kernel/extension/prisma/enums';

同じ64ファイル、705ケースで、その他条件を固定し、前→後、後→前、前→後の3組で比較しました。
平均実時間は16.52秒から14.41秒、CPU時間は約13%減少しています。
Client側の重い依存を読むファイルも39から20へ減り、テストはすべて成功しました。

enumが必要だった箇所からClientの初期化まで切り離せたことで、読み込みも時間も減りました。
ただし、残り20ファイルには別のimport経路があるので、これだけでPrismaへの依存を全部なくせたわけではありません。

起動コスト

元々 vitest 自体の起動オプションは特に指定せず起動していました。
指定しない場合は forks として動作します。
ref. https://vitest.dev/config/pool

forksは子プロセスを起動し、threadsは同じプロセス内のWorker Threadsでテストを動かします。
Vitestのドキュメントでもthreadsはメインとの通信が速いとされており、プロセスの起動と通信にかかる負担を減らせる可能性があると考えました。

Communication between tests and main process is not as fast as with threads pool.

同時に、threadsには制約があり、process.chdir()などのプロセス単位のAPIが使えず、native moduleによっては複数threadで動かすとsegfaultが起きることがあるようです。
公式にもPrisma、bcrypt、canvasなどへの注意があり、互換性の面ではforksが有利です。

Some libraries written in native languages, such as Prisma, bcrypt and canvas, have problems when running in multiple threads and run into segfaults.

同一条件で比較したところ threadsのほうが速く、平均実時間は16.00秒から14.29秒、CPU時間も約8.7%減少しました。
この計測で確認できたのは実行方式全体の効果で、短縮分を起動と通信のどちらに何秒と割り当てるところまではできていません。

変更後は単体テストを全件実行し、成功することを確認したうえで採用しました。

重複しているテストケースが散見された

前述した通り、DBやEmulatorと疎通をする統合テストもゴリゴリに書いてます。
ある程度どのレイヤにどんなテストを書くべきか、というrulesは用意してはいるものの、100%守れず、また、incrementalに増えていくとレビューもすり抜けてしまいます。
その結果、統合テストですでに担保している振る舞いを、DBをモックしたusecase層やrepository層の単体テストでも重ねて確認しているケースが散見されました。

ここは重複したケースを削るだけでなく、どのレイヤーで何を担保するのかも整理したいところです。DBを含む振る舞いを統合テストで確認するのか、ロジックを単体テストで確認するのか。我々のテスト方針に照らして、テストの置き場所と役割から見直していきます。

こちらに関しては件数自体が多かったのと、1件あたりの実行はそこまで支配的ではなかったので順次対応していくようにしています。

おわりに

Artraくんにぶん投げてそれなりの数の改善が為されたので試みとしては成功だったかなと思います。完全に自律したループで回す、というわけではなかったですが、私が介入した回数も数回程度だったので、コスパよく実践できました。

ボトルネックの特定が一番大事にはなりますが、単体テストの実行時間が伸びてきた、と感じる方は、カバレッジの収集に時間がかかっていないかを見るのが最初のアプローチとしてはコスパが良さそうです。
特にAIが細々(あるいは大胆に)積み上げてきた単体テストは、徐々に我々の開発者体験を悪化させている可能性があります。CI環境の実行時間が徐々に伸び、リードタイムに影響が出るし、お財布にもよろしくないですよね。また無駄なテストが増えると保守するコードの総量が増え、保守工数にも開発者体験にも、AIのコンテキストにも影響が出そうです。

そう言う意味で、単体テスト自体も定期的に棚卸しをしてあげられると良さそうです。

本筋とは外れますが、デプロイ速度を改善する話も弊社のメンバーが書いているので、もし気になる方がいれば読んでいただけますと嬉しいです。
https://zenn.dev/dress_code/articles/d08a9c00af39ca

もし、同じような問題で困っている方の一助になれましたら幸いです。

DRESS CODE TECH BLOG

DRESS CODEのProduct & Technologyチームによるテックブログです! プロダクトマネジメントからモデリング、アーキテクチャ、フロントエンド、バックエンド、SRE、セキュリティなどなど様々なテーマで情報を発信しています!

Discussion

元のページを確認する

必要な場合のみ、広告などを含む配信元ページをフレームで表示できます。