pytestでRust製CLIをe2eテストしてみよう
- author:
Kazuya Takei / @attakei
- date:
2024/11/16
- event:
PyCon mini 東海 2024
- hashtags:
はじめに
このトークの概要
このトークでは、
PythonistaがRustでCLIを作った際に
試行錯誤の結果として
品質担保をRustではなくPythonで実施した
という話をします。
このトークの概要
このトークでは、
Rust側の細かい話
作ったCLI自体の細かい話
「超テクニカル」な使い方
という話はしません。
(考え方とエッセンシャルな技法が中心です)
お前誰よ
Kazuya Takei
attakei (X, GitHub, etc)
株式会社ニジボックス
趣味系Pythonista <= こっち
ライブラリ・拡張系を作りがち
Sphinx拡張生成マシーン
Sphinxでプレゼンテーションしたがる人
- Python は 2.6ぐらいから?(GAE for Python出たあたり)
株式会社ニジボックス
ニジボックス は「Grow all」をミッションに、企業やサービスの成長に向き合い続けるリクルートグループのデザイン会社です。
お客様のビジネスの成長をUI UXのデザインプロセスから開発・運用・改善までワンストップでサポートします。
株式会社ニジボックス
POSTD
エンジニアに向けたキュレーションメディア
海外のテック記事を日本語に翻訳して配信
本日の脇役: age-cli
age-cliの宣伝
ライブラリのバージョニングを補助するCLIツール。Rust製。
管理用ファイルに「現在のバージョン」と「更新対象とルール」を記述。
age mijor|minor|patch
で一括でルールに基づいて更新。今のところ、コミットなどはしない。
age-cliの宣伝
# バージョン情報
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
modified: pyproject.toml
no changes added to commit (use "git add" and/or "git commit -a")
(余談)
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::*;
#[test]
fn test_up_major() {
let before = Version::parse("1.2.3").unwrap();
let after = up_major(&before);
assert_eq!(after.major, before.major + 1);
assert_eq!(after.minor, 0);
assert_eq!(after.patch, 0);
}
}
(テストの一例)
関数等のユニットテスト自体は頑張れる。
習作フェーズで網羅するのしんどい。(というか面倒)
要点
Pythonistaが動作速度などを求めてRustに手を出し
LSPなどを駆使して「やりたいこと」の実現ぐらいにはたどり着けたが
「動作の担保」までRustで頑張るまでが大変
→ここはRustなくても平気なのでは?
→じゃあ、pytest使うか!
テストツールとしてのpytest
pytest
Pythonのテストライブラリ。
関数とassertというシンプルな作りが基本。
fixtureという仕組みが便利(後述)。
標準ライブラリにunittestはあるが、こっちのほうがデファクト感ある。
サンプルコード
def test_foo():
# 成功する
assert True is True
def test_bar():
# 失敗する
assert "Hello" == "hello"
def test_buzz(capsys): # 標準出力をテストする
print("Hello world")
c = capsys.readourerr()
assert c.out.startswith("Hello")
pytestの良いところ
- 関数ベースでのテストが基本のため、インデントが浅い。TestCaseクラスのようなものは不要。(使うことは出来る)
ドキュメントがちゃんとしてるので、自分での機能追加もしやすい。
↑の実装が規約ベースで書けるので、考えることがあまり多くない(はず)。
fixture
テストのプロセスに割り込んだりするための仕組み。
多くは、「各テストの引数」の体裁で、 テストの前処理をオブジェクトを受け取ってテストする。
受け取った中身はオブジェクトなので、メソッドなどを駆使して高度なことも出来る。
scopeの概念があり処理の差し込みは、かなり柔軟。
各テストの前/後
テストモジュール全体の前/後
テスト実行全体の最初/最後
e2eテストを頑張るためのpytest
e2eとしてのpytest
「Pythonのモジュール内動作確認」としては使わない。
「Pythonを使ったコマンド実行結果の検証」のラッパーとして使う。
「FastAPIのWebアプリ開発時にTestClientを使う」あたりと近い。
【NOTE】ここから先の各要素は、相互依存的な内容です。
parameterize
pytest組み込みのフィクスチャ。
テスト関数へのデコレーターとして使う。
引数に従って、デコレーションしているテスト関数を複数パターンで実行できる。
とにかく同じコマンドを繰り返すので、無いと困る存在。
parameterize
よく見る使い方
import pytest
@pytest.mark.parametrize(
"in_,out_", # 第1引数で、テスト関数に渡す名称を決めて
[(2, 4), (3, 9)] # 第2引数で、渡したい値をリストで定義する
)
def test_it(in_, out_):
out = in_ * in_
assert out == out_
parameterize
<age-cli内での使い方>
def get_env_dirs(name: str):
# name 配下のサブフォルダを一括でテストケース化する
paths = [p for p in (root / name).glob("*") if p.is_dir()]
return {
"argvalues": paths,
"ids": [f"{p.parent.name}/{p.name}" for p in paths]
}
@pytest.mark.parametrize(
"env_path", # 第1引数の使い方は同じ
**get_env_dirs("return-1"), # 可変長引数を使って、その場でリストを作る
)
def test_invalid_env(cmd, env_path: Path, tmp_path: Path):
...
tmp_path
pytest組み込みのフィクスチャ。
「一時ファイル置き場」となるディレクトリを生成してくれる。
CLIの実行時に "working directory"として使用できる。
これを使って、「コマンドの実行前後」の想定状態を再現テストしている。
tmp_path
<age-cli内での使い方>
import shutil
@pytest.mark.parametrize(
"env_path",
**get_env_dirs("return-1"),
)
def test_invalid_env(cmd, env_path: Path, tmp_path: Path):
"""Run test cases on env having invalid configuration."""
# 生成されている一時フォルダに、まるっとテスト用ファイルをコピーしている
shutil.copytree(env_path / "before", tmp_path, dirs_exist_ok=True)
...
pytest_sessionstart
conftest.py
にこの関数を定義すると、
「pytestのセッション開始時」=「pytest実行の最初に1回」特定の処理を実行できる。
テスト対象のビルドや、共有環境のクリーンアップに向いている。
実際に age 本体のビルドをここでしている。
pytest_sessionstart
<age-cli内での使い方>
def pytest_sessionstart(session):
"""Generate age binary for testing."""
print("Now building binary from Cargo ... ", end="")
proc = run(
["cargo", "build"],
stdout=PIPE, stderr=PIPE, cwd=project_root
)
if proc.returncode != 0: # 万が一ビルドに失敗したら「e2eしない」ためにexit
print("Failed!!")
pytest.exit(1)
print(" OK!")
subprocess.run
※pytestではなく、Pythonの標準ライブラリ
おなじみ、外部コマンドを実行して結果を受け取る関数。
コマンド+引数を渡せる。
リターンコード、標準出力、標準エラーを受け取れる。
「End-to-End」の要と言える存在。
subprocess.run
<age-cli内での使い方>
def test_valid_env(cmd, env_path: Path, tmp_path: Path):
"""Run test cases on env having valid files."""
# 環境用意
shutil.copytree(env_path / "before", tmp_path, dirs_exist_ok=True)
# cmd(fixture製の関数で、内部でrunしてる)でage-cliを実行
proc: CompletedProcess = cmd("update", "0.2.0")
assert proc.returncode == 0
# run()でdiffを実行して、「差分がないこと」を検証
# diff は差分がまったくないときだけリターンコードが0になる
diff = run([
"diff", "--recursive",
str(tmp_path), str(env_path / "after")
])
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
Sphinx+翻訳 Hack-a-thon
GitHubの各サービス