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出たあたり)
著者近影

株式会社ニジボックス

../_images/logo-corporate.png

https://www.nijibox.jp

ニジボックス は「Grow all」をミッションに、企業やサービスの成長に向き合い続けるリクルートグループのデザイン会社です。

お客様のビジネスの成長をUI UXのデザインプロセスから開発・運用・改善までワンストップでサポートします。

株式会社ニジボックス

POSTD

  • https://postd.cc

  • エンジニアに向けたキュレーションメディア

  • 海外のテック記事を日本語に翻訳して配信

../_images/logo-postd.png

本日の脇役: 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
        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はあるが、こっちのほうがデファクト感ある。

https://docs.pytest.org/en/stable/_static/pytest1.png

サンプルコード

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