テスト
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番目のパラメータは、コマンドラインで渡すテキストをすべて含むstrのlistです。コマンドラインで渡すのとまったく同じように渡します。
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を作成する方がまだ簡単でしょう😅。