-- Views
November 23, 24
スライド概要
PyCon mini 東海 2024で発表したスライドのPDF版
Sphinx「で」プレゼンテーションをする人。 ※主にバックアップ用のスライドをアップロードします。
pytestでRust製CLIをe2eテストしてみよう author: Kazuya Takei / @attakei date: 2024/11/16 event: PyCon mini 東海 2024 hashtags: #pycontokai
はじめに
このトークの概要 このトークでは、 PythonistaがRustでCLIを作った際に 試行錯誤の結果として 品質担保をRustではなくPythonで実施した という話をします。
このトークの概要 このトークでは、 Rust側の細かい話 作ったCLI自体の細かい話 「超テクニカル」な使い方 という話はしません。 (考え方とエッセンシャルな技法が中心です)
お前誰よ Kazuya Takei attakei (X, GitHub, etc) 株式会社ニジボックス 趣味系Pythonista <= こっち ライブラリ・拡張系を作りがち Sphinx拡張生成マシーン Sphinxでプレゼンテーションしたがる人 Python は 2.6ぐらいから? (GAE for Python出たあたり)
株式会社ニジボックス https://www.nijibox.jp ニジボックス は「Grow all」をミッションに、企業やサービスの成長に向き合 い続けるリクルートグループのデザイン会社です。 お客様のビジネスの成長をUI UXのデザインプロセスから開発・運用・改善まで ワンストップでサポートします。
株式会社ニジボックス POSTD https://postd.cc エンジニアに向けたキュレーションメディア 海外のテック記事を日本語に翻訳して配信
本日の脇役: age-cli
age-cliの宣伝 ライブラリのバージョニングを補助するCLIツール。Rust製。 管理用ファイルに「現在のバージョン」と「更新対象とルール」を記述。 age mijor|minor|patch で一括でルールに基づいて更新。 今のところ、コミットなどはしない。
age-cliの宣伝 .age.toml(一部略) # バージョン情報 current_version = "0.7.0" #[[files]] 更新対象(いっぱい) path = "Cargo.toml" search = "version = \"{{current_version}}\"" replace = "version = \"{{new_version}}\"" [[files]] path = "CHANGELOG.md" search = "# Changelog" replace = """ # Changelog ## v{{new_version}} - {{now|date}} (JST) """
age-cliの宣伝
実行例
$ time age minor
Updated!
age minor 0.00s user 0.00s system 90% cpu 0.003 total
$ git status
On branch main
Your branch is up to date with 'origin/main'.
Changes
not
staged
for
commit:
(use "git add <file>..." to update what will be committed)
(use "git restore <file>..." to discard changes in working directory)
modified: .age.toml
modified: .github/release-body.md
modified:
CHANGELOG.md
modified: Cargo.toml
modified: doc/conf.py
modified: doc/usage/installation.rst
(余談) Q: なんで作ったんですか? A1: 似たPythonライブラリがあるけど、世代交代についていけなくなっ た。 bumpversion bump2version bump-my-version A2: せっかくなので、Rustの習作にしたかった。
Pythonに浸かった人から見たRust ※個人の感想です
Rustの良いところ 最終生成物の動作速度が軽快。 バイナリ配布がしやすい。 LSPもlinterもRustの公式Orgが提供しており、 「とりあえずこれ」がしやすい。 本体ソースにテストコードを同居させられる。 コンパイルエラーがあるから(?)、 不安定なコードを潰しやすい ※ただし体感しづらい
Rustの辛いところ いくつかの要素に慣れるまでが大変。 「借用」まわり。 データ構造に柔軟性がない。 標準ライブラリがそんなに多くない ↓ありそうでないもの(クレートはあるのでなんとかなる) regex toml
(テストの一例)
// モジュール内の関数
pub fn up_major(base: &Version) -> Version {
Version {
major: base.major + 1,
minor: 0,
patch: 0,
pre:
Prerelease::EMPTY,
build: BuildMetadata::EMPTY,
}
}
//#[cfg(test)]
ここから先が↑のテスト(同一コード内)
mod tests {
use super::*;
(テストの一例) 関数等のユニットテスト自体は頑張れる。 習作フェーズで網羅するのしんどい。(というか面倒)
要点 Pythonistaが動作速度などを求めてRustに手を出し LSPなどを駆使して「やりたいこと」の実現ぐらいにはたどり着けたが 「動作の担保」までRustで頑張るまでが大変
要点 Pythonistaが動作速度などを求めてRustに手を出し LSPなどを駆使して「やりたいこと」の実現ぐらいにはたどり着けたが 「動作の担保」までRustで頑張るまでが大変 →ここはRustなくても平気なのでは?
要点 Pythonistaが動作速度などを求めてRustに手を出し LSPなどを駆使して「やりたいこと」の実現ぐらいにはたどり着けたが 「動作の担保」までRustで頑張るまでが大変 →ここはRustなくても平気なのでは? →じゃあ、pytest使うか!
テストツールとしてのpytest
pytest Pythonのテストライブラリ。 関数とassertというシンプルな作りが基本。 fixtureという仕組みが便利(後述)。 標準ライブラリにunittestはあるが、こっちの ほうがデファクト感ある。
サンプルコード 1 def test_foo(): 2 # 成功する 34 assert True is True 5 6 def test_bar(): 7 # 失敗する 89 assert "Hello" == "hello" 10 11 def test_buzz(capsys): # 標準出力をテストする 12 print("Hello world") 1314 cassert = capsys.readourerr() c.out.startswith("Hello")
サンプルコード 1 def test_foo(): 2 # 成功する 34 assert True is True 5 6 def test_bar(): 7 # 失敗する 89 assert "Hello" == "hello" 10 11 def test_buzz(capsys): # 標準出力をテストする 12 print("Hello world") 1314 cassert = capsys.readourerr() c.out.startswith("Hello")
サンプルコード 1 def test_foo(): 2 # 成功する 34 assert True is True 5 6 def test_bar(): 7 # 失敗する 89 assert "Hello" == "hello" 10 11 def test_buzz(capsys): # 標準出力をテストする 12 print("Hello world") 1314 cassert = capsys.readourerr() c.out.startswith("Hello")
サンプルコード 1 def test_foo(): 2 # 成功する 34 assert True is True 5 6 def test_bar(): 7 # 失敗する 89 assert "Hello" == "hello" 10 11 def test_buzz(capsys): # 標準出力をテストする 12 print("Hello world") 1314 cassert = capsys.readourerr() c.out.startswith("Hello")
pytestの良いところ 関数ベースでのテストが基本のため、インデントが浅い。 TestCaseクラスのようなものは不要。(使うことは出来る) ドキュメントがちゃんとしてるので、自分での機能追加もしやすい。 ↑の実装が規約ベースで書けるので、考えることがあまり多くない(は ず)。
fixture テストのプロセスに割り込んだりするための仕組み。 多くは、「各テストの引数」の体裁で、 テストの前処理をオブジェクトを 受け取ってテストする。 受け取った中身はオブジェクトなので、メソッドなどを駆使して高度なこ とも出来る。 scopeの概念があり処理の差し込みは、かなり柔軟。 各テストの前/後 テストモジュール全体の前/後 テスト実行全体の最初/最後
e2eテストを頑張るためのpytest
e2eとしてのpytest 「Pythonのモジュール内動作確認」としては使わない。 「Pythonを使ったコマンド実行結果の検証」のラッパーとして使う。 「FastAPIのWebアプリ開発時にTestClientを使う」あたりと近い。
【NOTE】ここから先の各要素は、相互依存的な内容です。
parameterize pytest組み込みのフィクスチャ。 テスト関数へのデコレーターとして使う。 引数に従って、デコレーションしているテスト関数を複数パターンで実行 できる。 とにかく同じコマンドを繰り返すので、無いと困る存在。
parameterize よく見る使い方 1 import pytest 2 3 45 @pytest.mark.parametrize( "in_,out_", # 第1引数で、テスト関数に渡す名称を決めて 6 [(2, 4), (3, 9)] # 第2引数で、渡したい値をリストで定義する 7) 8 def test_it(in_, out_): 9 out = in_ * in_ 10 assert out == out_
parameterize よく見る使い方 1 import pytest 2 3 45 @pytest.mark.parametrize( "in_,out_", # 第1引数で、テスト関数に渡す名称を決めて 6 [(2, 4), (3, 9)] # 第2引数で、渡したい値をリストで定義する 7) 8 def test_it(in_, out_): 9 out = in_ * in_ 10 assert out == out_
parameterize よく見る使い方 1 import pytest 2 3 45 @pytest.mark.parametrize( "in_,out_", # 第1引数で、テスト関数に渡す名称を決めて 6 [(2, 4), (3, 9)] # 第2引数で、渡したい値をリストで定義する 7) 8 def test_it(in_, out_): 9 out = in_ * in_ 10 assert out == out_
parameterize
<age-cli内での使い方>
1 def get_env_dirs(name: str):
2 # name 配下のサブフォルダを一括でテストケース化する
3 paths = [p for p in (root / name).glob("*") if p.is_dir()]
45 return"argvalues":
{
paths,
6
"ids": [f"{p.parent.name}/{p.name}" for p in paths]
7 }
8
9
10 @pytest.mark.parametrize(
11 "env_path",
# 第1引数の使い方は同じ
12 **get_env_dirs("return-1"), # 可変長引数を使って、その場でリストを作る
13 )
1415 def ...
test_invalid_env(cmd, env_path: Path, tmp_path: Path):
parameterize
<age-cli内での使い方>
1 def get_env_dirs(name: str):
2 # name 配下のサブフォルダを一括でテストケース化する
3 paths = [p for p in (root / name).glob("*") if p.is_dir()]
45 return"argvalues":
{
paths,
6
"ids": [f"{p.parent.name}/{p.name}" for p in paths]
7 }
8
9
10 @pytest.mark.parametrize(
11 "env_path",
# 第1引数の使い方は同じ
12 **get_env_dirs("return-1"), # 可変長引数を使って、その場でリストを作る
13 )
1415 def ...
test_invalid_env(cmd, env_path: Path, tmp_path: Path):
parameterize
<age-cli内での使い方>
1 def get_env_dirs(name: str):
2 # name 配下のサブフォルダを一括でテストケース化する
3 paths = [p for p in (root / name).glob("*") if p.is_dir()]
45 return"argvalues":
{
paths,
6
"ids": [f"{p.parent.name}/{p.name}" for p in paths]
7 }
8
9
10 @pytest.mark.parametrize(
11 "env_path",
# 第1引数の使い方は同じ
12 **get_env_dirs("return-1"), # 可変長引数を使って、その場でリストを作る
13 )
1415 def ...
test_invalid_env(cmd, env_path: Path, tmp_path: Path):
tmp_path pytest組み込みのフィクスチャ。 「一時ファイル置き場」となるディレクトリを生成してくれる。 CLIの実行時に "working directory"として使用できる。 これを使って、「コマンドの実行前後」の想定状態を再現テストしてい る。
tmp_path
<age-cli内での使い方>
1 import shutil
2
3 @pytest.mark.parametrize(
45 **get_env_dirs("return-1"),
"env_path",
6 )
7 def test_invalid_env(cmd, env_path: Path, tmp_path: Path):
8 """Run test cases on env having invalid configuration."""
9
#
生成されている一時フォルダに、まるっとテスト用ファイルをコピーしている
10 shutil.copytree(env_path / "before", tmp_path, dirs_exist_ok=True)
11 ...
tmp_path
<age-cli内での使い方>
1 import shutil
2
3 @pytest.mark.parametrize(
45 **get_env_dirs("return-1"),
"env_path",
6 )
7 def test_invalid_env(cmd, env_path: Path, tmp_path: Path):
8 """Run test cases on env having invalid configuration."""
9
#
生成されている一時フォルダに、まるっとテスト用ファイルをコピーしている
10 shutil.copytree(env_path / "before", tmp_path, dirs_exist_ok=True)
11 ...
pytest_sessionstart conftest.py にこの関数を定義すると、 「pytestのセッション開始時」= 「pytest実行の最初に1回」特定の処理を実行できる。 テスト対象のビルドや、共有環境のクリーンアップに向いている。 実際に age 本体のビルドをここでしている。
pytest_sessionstart
<age-cli内での使い方>
1 def pytest_sessionstart(session):
2 """Generate age binary for testing."""
3 print("Now building binary from Cargo ... ", end="")
45 proc["cargo",
= run( "build"],
6
stdout=PIPE, stderr=PIPE, cwd=project_root
7 )
8 if proc.returncode != 0: # 万が一ビルドに失敗したら「e2eしない」ためにexit
9
print("Failed!!")
pytest.exit(1)
10
11 print(" OK!")
pytest_sessionstart
<age-cli内での使い方>
1 def pytest_sessionstart(session):
2 """Generate age binary for testing."""
3 print("Now building binary from Cargo ... ", end="")
45 proc["cargo",
= run( "build"],
6
stdout=PIPE, stderr=PIPE, cwd=project_root
7 )
8 if proc.returncode != 0: # 万が一ビルドに失敗したら「e2eしない」ためにexit
9
print("Failed!!")
pytest.exit(1)
10
11 print(" OK!")
pytest_sessionstart
<age-cli内での使い方>
1 def pytest_sessionstart(session):
2 """Generate age binary for testing."""
3 print("Now building binary from Cargo ... ", end="")
45 proc["cargo",
= run( "build"],
6
stdout=PIPE, stderr=PIPE, cwd=project_root
7 )
8 if proc.returncode != 0: # 万が一ビルドに失敗したら「e2eしない」ためにexit
9
print("Failed!!")
pytest.exit(1)
10
11 print(" OK!")
subprocess.run ※pytestではなく、Pythonの標準ライブラリ おなじみ、外部コマンドを実行して結果を受け取る関数。 コマンド+引数を渡せる。 リターンコード、標準出力、標準エラーを受け取れる。 「End-to-End」の要と言える存在。
subprocess.run
<age-cli内での使い方>
1 def test_valid_env(cmd, env_path: Path, tmp_path: Path):
2 """Run test cases on env having valid files."""
3 # 環境用意
45 shutil.copytree(env_path
/
"before",
tmp_path,
dirs_exist_ok=True)
# cmd(fixture製の関数で、内部でrunしてる)でage-cliを実行
6 proc: CompletedProcess = cmd("update", "0.2.0")
7 assert proc.returncode == 0
8 # run()でdiffを実行して、「差分がないこと」を検証
9
#
diff
は差分がまったくないときだけリターンコードが0になる
10 diff = run([
11
"diff", "--recursive",
12
str(tmp_path), str(env_path / "after")
13 ])
14 assert diff.returncode == 0
subprocess.run
<age-cli内での使い方>
1 def test_valid_env(cmd, env_path: Path, tmp_path: Path):
2 """Run test cases on env having valid files."""
3 # 環境用意
45 shutil.copytree(env_path
/
"before",
tmp_path,
dirs_exist_ok=True)
# cmd(fixture製の関数で、内部でrunしてる)でage-cliを実行
6 proc: CompletedProcess = cmd("update", "0.2.0")
7 assert proc.returncode == 0
8 # run()でdiffを実行して、「差分がないこと」を検証
9
#
diff
は差分がまったくないときだけリターンコードが0になる
10 diff = run([
11
"diff", "--recursive",
12
str(tmp_path), str(env_path / "after")
13 ])
14 assert diff.returncode == 0
subprocess.run
<age-cli内での使い方>
1 def test_valid_env(cmd, env_path: Path, tmp_path: Path):
2 """Run test cases on env having valid files."""
3 # 環境用意
45 shutil.copytree(env_path
/
"before",
tmp_path,
dirs_exist_ok=True)
# cmd(fixture製の関数で、内部でrunしてる)でage-cliを実行
6 proc: CompletedProcess = cmd("update", "0.2.0")
7 assert proc.returncode == 0
8 # run()でdiffを実行して、「差分がないこと」を検証
9
#
diff
は差分がまったくないときだけリターンコードが0になる
10 diff = run([
11
"diff", "--recursive",
12
str(tmp_path), str(env_path / "after")
13 ])
14 assert diff.returncode == 0
全部まとめると テスト用の環境をフォルダ単位で管理して session_startでCLIを事前ビルドして parametrizeで大量のテストパターンを実行して 内部では、tmp_pathで都度きれいな環境を用意して subprocess.runを使って、テスト結果を検査する
おわりに
大事なこと Rust製CLIの大半のテストをRustではなくPythonで書いてました。
大事なこと Rust製CLIの大半のテストをRustではなくPythonで書いてました。 => メイン言語が変わっても、Pythonの使いどころが結構ある。 今回のトークだとRust製ツールをテストしたが、別にGolangでも同じこ と。 技術トレンドが変わっても、覚えたことはいつでも選択肢になる。 Python楽しいですね。
Thanks NIJIBOX Co., Ltd. Reveal.js Sphinx+翻訳 Hack-a-thon GitHubの各サービス