コンテンツへスキップ

テスト

Typerアプリケーションのテストは、pytestを使用すると非常に簡単です。

例えば、以下のようなアプリケーションapp/main.pyがあるとします。

from typing import Optional

import typer

app = typer.Typer()


@app.command()
def main(name: str, city: Optional[str] = None):
    print(f"Hello {name}")
    if city:
        print(f"Let's have a coffee in {city}")


if __name__ == "__main__":
    app()

したがって、以下のように使用します。

$ python main.py Camila --city Berlin

Hello Camila
Let's have a coffee in Berlin

また、ディレクトリには空のapp/__init__.pyファイルもあります。

したがって、appは「Pythonパッケージ」です。

アプリをテストする

CliRunnerをインポートして作成する

別のファイル/モジュールapp/test_main.pyを作成します。

CliRunnerをインポートし、runnerオブジェクトを作成します。

このrunnerがコマンドラインアプリケーションを「起動」または「呼び出し」ます。

from typer.testing import CliRunner

from .main import app

runner = CliRunner()


def test_app():
    result = runner.invoke(app, ["Camila", "--city", "Berlin"])
    assert result.exit_code == 0
    assert "Hello Camila" in result.stdout
    assert "Let's have a coffee in Berlin" in result.stdout

ヒント

ファイルの先頭がtest_で始まる名前であることが重要です。そうすることで、pytestがそれを検出して自動的に使用できるようになります。

アプリを呼び出す

次に、関数test_app()を作成します。

そして、関数の内部で、runnerを使用してアプリケーションをinvokeします。

runner.invoke()の最初のパラメータはTyperアプリです。

2番目のパラメータは、コマンドラインで渡すテキストをすべて含むstrlistです。コマンドラインで渡すのとまったく同じように渡します。

from typer.testing import CliRunner

from .main import app

runner = CliRunner()


def test_app():
    result = runner.invoke(app, ["Camila", "--city", "Berlin"])
    assert result.exit_code == 0
    assert "Hello Camila" in result.stdout
    assert "Let's have a coffee in Berlin" in result.stdout

ヒント

関数の名前はtest_で始める必要があります。そうすることで、pytestがそれを検出して自動的に使用できるようになります。

結果を確認する

次に、テスト関数の中で、呼び出しの結果のすべてが期待どおりであることを確認するために、assertステートメントを追加します。

from typer.testing import CliRunner

from .main import app

runner = CliRunner()


def test_app():
    result = runner.invoke(app, ["Camila", "--city", "Berlin"])
    assert result.exit_code == 0
    assert "Hello Camila" in result.stdout
    assert "Let's have a coffee in Berlin" in result.stdout

ここでは、エラーなしで終了するプログラムの場合と同様に、終了コードが0であることを確認しています。

次に、「標準出力」に出力されたテキストに、CLIプログラムが出力するテキストが含まれていることを確認します。

ヒント

CliRunnerインスタンスがmix_stderr=False引数で作成されている場合は、「標準出力」とは別に「標準エラー」のresult.stderrを確認することもできます。

情報

「標準出力」と「標準エラー」が何かを思い出したい場合は、印刷と色:「標準出力」と「標準エラー」のセクションを参照してください。

pytestを呼び出す

次に、ディレクトリでpytestを呼び出すと、テストが実行されます。

$ pytest

================ test session starts ================
platform linux -- Python 3.10, pytest-5.3.5, py-1.8.1, pluggy-0.13.1
rootdir: /home/user/code/superawesome-cli/app
plugins: forked-1.1.3, xdist-1.31.0, cov-2.8.1
collected 1 item

---> 100%

test_main.py <span style="color: green; white-space: pre;">.                                 [100%]</span>

<span style="color: green;">================= 1 passed in 0.03s =================</span>

入力のテスト

以下のようなプロンプト付きのCLIがある場合。

import typer
from typing_extensions import Annotated

app = typer.Typer()


@app.command()
def main(name: str, email: Annotated[str, typer.Option(prompt=True)]):
    print(f"Hello {name}, your email is: {email}")


if __name__ == "__main__":
    app()

ヒント

可能な限り、Annotatedバージョンを使用することを推奨します。

import typer

app = typer.Typer()


@app.command()
def main(name: str, email: str = typer.Option(..., prompt=True)):
    print(f"Hello {name}, your email is: {email}")


if __name__ == "__main__":
    app()

以下のように使用するとします。

$ python main.py Camila

# Email: $ camila@example.com

Hello Camila, your email is: camila@example.com

input="camila@example.com\n"を使用して、ターミナルに入力した入力をテストできます。

これは、ターミナルに入力した内容が「標準入力」に送られ、オペレーティングシステムによって「仮想ファイル」であるかのように処理されるためです。

情報

「標準出力」、「標準エラー」、「標準入力」が何かを思い出したい場合は、印刷と色:「標準出力」と「標準エラー」のセクションを参照してください。

メールを入力した後でENTERキーを押すと、それはただの「改行文字」になります。そしてPythonでは、それは"\n"で表されます。

したがって、input="camila@example.com\n"を使用する場合、それは「ターミナルにcamila@example.comと入力し、ENTERキーを押す」ことを意味します。

from typer.testing import CliRunner

from .main import app

runner = CliRunner()


def test_app():
    result = runner.invoke(app, ["Camila"], input="camila@example.com\n")
    assert result.exit_code == 0
    assert "Hello Camila, your email is: camila@example.com" in result.stdout

関数のテスト

以下のように、明示的なtyper.Typerアプリを作成したことがないスクリプトがある場合。

import typer


def main(name: str = "World"):
    print(f"Hello {name}")


if __name__ == "__main__":
    typer.run(main)

...テスト中にアプリを作成することで、それでもテストできます。

import typer
from typer.testing import CliRunner

from .main import main

app = typer.Typer()
app.command()(main)

runner = CliRunner()


def test_app():
    result = runner.invoke(app, ["--name", "Camila"])
    assert result.exit_code == 0
    assert "Hello Camila" in result.stdout

もちろん、そのスクリプトをテストしている場合、テスト中だけ作成するのではなく、main.pyに明示的なtyper.Typerアプリを作成する方が簡単/クリーンでしょう。

ただし、ドキュメントの簡単な例であるなどの理由で、そのように維持したい場合は、そのトリックを使用できます。

app.commandデコレータについて

app.command()(main)に注目してください。

それが何をしているのかが不明確な場合は、読み進めてください。

通常は次のように記述します。

@app.command()
def main(name: str = "World"):
    # Some code here

しかし、@app.command()は単なるデコレータです。

それは以下と同等です。

def main(name: str = "World"):
    # Some code here

decorator = app.command()

new_main = decorator(main)
main = new_main

app.command()は、別の関数を唯一のパラメータ(main)として受け取る関数(decorator)を返します。

そして、通常は@somethingを使用することで、Pythonに(関数main)以下のものをdecorator関数(new_main)の戻り値に置き換えるように指示します。

ここで、Typerの特定の場合、デコレータは元の関数を変更しません。内部的に登録し、変更せずに返します。

したがって、new_mainは実際には元のmainと同じです。

したがって、Typerの場合、デコレートされた関数を実際には変更しないため、それは以下と同等になります。

def main(name: str = "World"):
    # Some code here

decorator = app.command()

decorator(main)

ただし、以下で使用するために変数decoratorを作成する必要はなく、直接使用できます。

def main(name: str = "World"):
    # Some code here

app.command()(main)

...以上です。おそらく、main.pyファイルに明示的なtyper.Typerを作成する方がまだ簡単でしょう😅。