pytestを爆速にする10の方法 @ PyCon JP 2025

shimizukawa 0 views 50 slides Sep 27, 2025
Slide 1
Slide 1 of 50
Slide 1
1
Slide 2
2
Slide 3
3
Slide 4
4
Slide 5
5
Slide 6
6
Slide 7
7
Slide 8
8
Slide 9
9
Slide 10
10
Slide 11
11
Slide 12
12
Slide 13
13
Slide 14
14
Slide 15
15
Slide 16
16
Slide 17
17
Slide 18
18
Slide 19
19
Slide 20
20
Slide 21
21
Slide 22
22
Slide 23
23
Slide 24
24
Slide 25
25
Slide 26
26
Slide 27
27
Slide 28
28
Slide 29
29
Slide 30
30
Slide 31
31
Slide 32
32
Slide 33
33
Slide 34
34
Slide 35
35
Slide 36
36
Slide 37
37
Slide 38
38
Slide 39
39
Slide 40
40
Slide 41
41
Slide 42
42
Slide 43
43
Slide 44
44
Slide 45
45
Slide 46
46
Slide 47
47
Slide 48
48
Slide 49
49
Slide 50
50

About This Presentation

https://2025.pycon.jp/ja/timetable/talk/ZFYREY

テストが爆速になると、色々なメリットがあります。
分かりやすいところでは、手元で実行するのに「待たなくて良い」ので、気軽に再実行できます。
また、CIの実行待ち行列が解消し�...


Slide Content

pytestを爆速にする 10
の方法
ハヤイヨ~
Takayuki ShimizukawaPyCon JP 2025

●Pythonプログラマー (2003, Py2.3 ~

●Pythonコミュニティー運営
○Python mini Hack-a-thon
○Sphinx users JP
○PyCon JP Association; accounting director

●(株) BePROUD; IT Architect
○自社サービス : TRACERY 開発
○受託開発

●Books 翻訳/執筆 (13冊)
●OSS 開発者
○sphinx
○sphinx-intl
○django-redshift-backend
about.me/shimizukawa

UnitTestの実行、十分速いですか?

もし10倍の速さでテストが終われば、

使い方が変わります

[BEFORE]:pytestの実行に、 5分
①②
③④
1.pytest コマンドを実行
2.実行結果待ち( 5分〜)
○☕ コーヒーを淹れに行く
3.コード修正
4.pytest コマンドを実行
5.実行結果待ち( 5分〜)
○社内のSlackを眺めたり
○?????? メールを読んだり
○そのまま忘れる

[AFTER]:pytestの実行に、 0.5分
1.pytest コマンドを実行
2.実行結果待ち( 30秒)
○次にやることを考える( 10秒)
3.コード修正
4.pytest コマンドを実行
5.実行結果待ち( 30秒)
○次にやることを考える( 10秒)
6.コード修正
(以下繰り返し)

blog Making PyPI's test suite 81% faster - The Trail of Bits Blog より
施策(今日紹介します)
1.DBの準備時間を圧縮する
2.pytest-xdist を使って、並列実行する
3.coverage では sysmon を使う
4.pytest の探索パスをしぼる
5.テスト起動時の不要な import を止める
PyPIでの結果
●163 秒が 30 秒 に
●81.59 %削減!(5.4倍速)
PyPIの事例(ケース数 : 3900~4700)
前提
●Pyramid
●SQLAlchemy
●PostgreSQL

環境
●vCPU 32
●メモリ 32x8GB

PyPIの改善 + いくつかの改善
施策(今日紹介します)
6.pytest-profiling でボトルネックを探る
7.DBトランザクションテストをやめる
8.DBのデータ量を絞る
9.テスト時はsqlite3のメモリDBを使う
10.時間のかかるテストを探す

TRACERYでの結果
●351秒 → 33秒!
●90% 削減!(10倍速!!)
弊社 TRACERYの事例(ケース数 : 1850~2500)
10倍速 = 90%削減 成功!!
前提
●Django
●MySQL

テスト環境
●vCPU 4
●メモリ 16GB

???????????? 爆速にした結果

●待ち時間が減った
○☕ ブレイクする暇が無くなった

●テストを書くのが楽しくなった
○書いて、確認して、また書いて、サクサクサイクル

●テストの品質が上がった
○組み合わせテストを追加する心理的負担がない

●GitHub Actions の占有時間が 80%削減された
○Teamプランの無料枠は組織全体で 3000分/月

●(多分)チームおよび組織の生産性が向上した
○社内に共有、別プロジェクトへ適用
???????????? 爆速にした結果

10の方法

1. DBの準備時間を圧縮する

1. DBの準備時間を圧縮する (SQLAlchemy+Alembic)
blog Making PyPI's test suite 81% faster - The Trail of Bits Blog より
●「DBマイグレーションのオーバーヘッド解消を検討。
PyPIでは alembic (SQLAlchemy) の多数のマイグレーションがある」

●「概念実証では、テスト実行時間を 13%短縮」



●「しかし、管理のデメリットが、時間的なメリットを上回ったため、
採用しませんでした。」
PyPIでの結果:
13%削減

1. DBの準備時間を圧縮する (Django)
[pytest]
addopts =
--reuse-db
--no-migrations
[BEFORE]
1851 passed in 351.76s (0:05:51)

[AFTER]
1851 passed in 172.67s (0:02:52)

TRACERY での結果
351秒 -> 172秒!(51% 削減)
tox.ini
pytest-django プラグインのオプションを利用し DB準備時間を短縮。
Database access — pytest-django documentation

●--resuse-db: テスト終了時に DBインスタンスを削除しない
●--no-migrations: マイグレーションを使用せず Modelの最新状態を反映

2. pytest-xdist を使って、並列実行する

2. pytest-xdist を使って、並列実行する
blog Making PyPI's test suite 81% faster - The Trail of Bits Blog より
「pytest-xdist でCPUコア数分だけ並列化して実行する( PyPIの場合32コア)」

$ pip install pytest-xdist
$ pytest -n auto

「しかし課題が発生した」( blogより)
1.データベース処理の競合
2.coverage結果の統合
3.テスト出力の読みやすさ

2. pytest-xdist を使って、並列実行する
データベース処理の競合は、各 pytestインスタンスが DBを更新して発生。
●対策「pytestのdatabase fixtureで並列インスタンス毎に別の DBを使う」





conftest.py

2. pytest-xdist を使って、並列実行する
[pytest]
addopts =
--numprocesses=auto
[BEFORE]
1851 passed in 168.36s (0:02:48)

[AFTER]
1851 passed in 114.13s (0:01:54)

TRACERY での結果
168秒 -> 114秒!(32% 削減)
tox.ini
TRACERYプロジェクト(CPU 4コア)で実行しました。

2. pytest-xdist を使って、並列実行する【副作用 1】
coverage結果の統合は、各 pytestインスタンス毎に coverage結果が出力される
●対策「coverage documentation で解説されていた
sitecustomize.py ファイルの追加で解決」( blogより)

try:
import coverage
coverage.process_startup()
except ImportError:
pass

TRACERYプロジェクトの場合
●pytest-cov で対応してくれていたため影響がなかった

※ その後 coverage==7.10.0 (2025-07-24)のpatchオプションでも解決可能に。

2. pytest-xdist を使って、並列実行する【副作用 2】
「テスト出力が、 pytest-xdist で並列化してぐちゃぐちゃになった」
●対策「pytest-sugarを導入して解決」

$ pip install pytest-sugar

3. coverage では sysmon を使う

3. coverage では sysmon を使う
blog Making PyPI's test suite 81% faster - The Trail of Bits Blog より
●「カバレッジ測定はテストの品質にとって不可欠である。
 従来の実行監視は、実行に かなりのオーバーヘッド が追加される。」

●「Python-3.12 でより軽量な実行監視方法である
sys.monitoring (PEP 669: Low Impact Monitoring for CPython)を導入、
coverage==7.4.0 からサポートを開始。

PyPIでの実行時間
 58秒 → 27 秒(53%削減)

3. coverage では sysmon を使う
[project]
requires-python = "=>3.12"

[dependency-groups]
"coverage=>7.7",

[tool.coverage.run]
core = "sysmon"
[BEFORE]
1851 passed in 114.13s (0:01:54)

[AFTER]
1851 passed in 91.14s (0:01:31)

TRACERY での結果
114秒 -> 91秒!(20% 削減)

pyproject.toml
coverage==7.7.0 からオプション [run] core = "sysmon" を利用可能
xdist = 4

4. pytest の探索パスをしぼる

4. pytest の探索パスをしぼる
blog Making PyPI's test suite 81% faster - The Trail of Bits Blog より
●「pytestはtestpathsで指定されたディレクトリ以下のテストのみ探す。
testpaths を指定するだけで、ムダなスキャンを省けます。」

●「PyPIのテストは単一ディレクトリ以下にまとまっているため、
1行の単純な追加だけで効果を発揮します。」
PyPIでのスキャン時間
 7.84秒 → 2.6 秒(66%削減)
TRACERYプロジェクト では、30ディレクトリあり、、、断念。
(絶対設定漏れすると思う)
[tool.pytest.ini_options]
testpaths = ["tests/"]
pyproject.toml

5. テスト起動時の不要なインポートを止める

5. テスト起動時の不要なインポートを止める
blog Making PyPI's test suite 81% faster - The Trail of Bits Blog より
●「Python の -X importtime オプションを使用して、
 インポート時間を プロファイリング しました。」

●「テストで使用しない ddtrace モジュールのインポートにかなりの時間が
 かかっていたことが分かり、取り除いた。」





PyPIでの実行時間
 29秒 → 28秒(3.4%削減)
TRACERYプロジェクト では上記は 実施せず。
ただしプロファイリングに着目 し、別のアプローチを採りました。

6. pytest-profiling で
ボトルネックを探る

TRACERYプロジェクトでは、テスト実行のボトルネックを調査しました。

$ pip install pytest-profiling
$ apt install dot
$ pytest --profile-svg

これで、めっちゃ広い SVG画像が出ます。
これを追っていくとボトルネックが
見つかりそうです。
6. pytest-profilingでボトルネックを探る

6. pytest-profilingでボトルネックを探る
出力をじっと眺めていて気付いたこと、 1
●DBのレコード作成が重い( 22%)

●レコード作成はテスト全体にちらばっている
ので、全体的に うっすら重く なっていそう

●大量のレコード を作成しているテストは、 特
に重いかも しれない

6. pytest-profilingでボトルネックを探る
出力をじっと眺めていて気付いたこと、 2
●DBのロールバック処理が重い( 37%)

●テスト単位 でデータリセットするため、内部で
ロールバック を行っている

●トランザクション使っているコード の
テストでは、 さらに重い かもしれない

7. DBトランザクションテストをやめる

7. DBトランザクションテストをやめる
DBトランザクション完了後の処理がある場合。
transaction.on_commit(func)
例えば、通知作成のトランザクション後に非同期処理を呼び出している場合。
funcは呼ばれて欲しいので、 transactionテストをしていた。

def create_notify(...):

def func():
notification_tasks.notify.delay(...)

transaction.on_commit(func)
signals.py
@pytest.mark.django_db(transaction=True)
def test_create(self):
notification = NotificationFactory()

[BEFORE] test_signals.py

7. DBトランザクションテストをやめる
[BEFORE]
1851 passed in 91.14s (0:01:31)

[AFTER]
1851 passed in 50.75s

TRACERY での結果
91秒 -> 50秒!(45% 削減)

UnitTest中、 transaction.on_commit(func) 自体のテストは不要。
funcは呼ばれるように mockしてしまえば OK.
xdist = 4
@pytest.mark.django_db(transaction=True)
def test_create(self):
with patch(
"django.db.transaction.on_commit",
side_effect=lambda fn: fn()
):
NotificationFactory()
[AFTER] test_signals.py
※ 上記コードは汎用。 django_capture_on_commit_callbacks でも同様に出来ます。

8. DBのデータ量を絞る

8. DBのデータ量を絞る
多数のデータを扱う機能をテストするために、多数のデータを用意していた。
●ページングのテスト
○for _ in range(100):
NotificationFactory()

●N+1検出テスト
○for _ in range(10):
cat = CategoryFactory()
for _ in range(10):
PageFactory(category=cat)

●データ変換ロジックの多数の組み合わせパターンをテスト
○変換ロジックの実装で、 DBからデータを取得していた

8. DBのデータ量を絞る
データ量を絞る対策
●ページングのテスト
○ページングサイズのデフォルト値 50をpatchして2に
○for _ in range(3):
NotificationFactory()

●N+1検出テスト
○N>1 なら検出可能。 N=3 程度でテスト
○for _ in range(3):
cat = CategoryFactory()
# for _ in range(3): # 不要
PageFactory(category=cat)

●データ変換ロジックの組み合わせテスト
○変換ロジックで DBに触らなければDBレコードも不要
○必要な箇所は Factory.stub() で工夫
[BEFORE]
1851 passed in 50.75s

[AFTER]
1851 passed in 46.64s

TRACERY での結果
50秒 -> 46秒!( 8% 削減)

xdist = 4

9. sqlite3のメモリDBを使う

9. sqlite3のメモリDBを使う
ここまで、DBに触らないように頑張ってきました。
それでも profile では、まだDB関連が時間の多くを占めています。
それなら、テストは sqlite3 でやればいいんじゃないか?
検討したこと
●?????? Django等のORMを使っていれば、大体は RDBMS非依存のはず
●??????N+1検出は時間ではなくクエリ発行数でテストしている
●❌ 複雑な結合、固有の関数利用、トランザクション分離、実行計画 ...
●❌CIテストが通っていても本番 DB同等ではない
とりあえず、やってみた。

9. sqlite3のメモリDBを使う
[BEFORE]
1851 passed in 46.64s

[AFTER]
1851 passed in 35.77s

TRACERY での結果
46秒 -> 35秒!(23% 削減)

sqliteに :memory: を指定して、オンメモリで扱う
xdist = 4 [AFTER] settings/test.py
※ 安全のため、マージ時に本番同等の DBでテスト、自動結合テスト等を別途用意しましょう
※ db router を使って特定のテストは MySQLのまま、
  も可能です

10. 時間のかかるテストを探す

10. 時間のかかるテストを探す
それでも残っている時間のかかるテストを探す --durations




0.5秒以上、上位 30件

重いテストの傾向を眺めます。約 1秒ちょうどに集中している ...?
[pytest]
addopts =
--durations=30
--durations-min=0.5
tox.ini

10. 時間のかかるテストを探す
●1秒スリープを発見
○time.sleep(1)
○バッチ処理などの外部連携処理に潜んで居るぞ




●別サービスへの接続、も発見
○requests で通信、elasticsearch / opensearch に通信
○これもpatchして回避
@pytest.fixture(scope="module", autouse=True)
def sleep():
with patch("my_module.sleep"):
yield

10. 時間のかかるテストを探す
[BEFORE]
1851 passed in 35.77s

[AFTER]
1851 passed in 33.03s

TRACERY での結果
35秒 -> 33秒!(7% 削減)

地味な作業を繰り返して、削減した結果
xdist = 4

動作比較 Before / After !
(デモ)

動作比較 Before / After !

動作比較 Before / After (もうちょっと分かりやすく)

まとめ

2025/06/10 時点
[BEFORE] (1並列)
1851 passed in 351.76s (5分51秒)

[AFTER] (4並列)
1851 passed in 33.03s(0.5分)

TRACERY での結果
351秒 -> 33秒 (90% 削減 達成!)
まとめ
そして現在。
2025/09/27 時点
[HEAD] (4並列)
2492 passed in 42.44s(0.7分)

テストは増え、実行時間との闘いは続く!

ありがとうございました
Questions?
50
https://about.me/shimizukawa