nikkie-ftnextの日記

イベントレポートや読書メモを発信

Clickを使ったCLIアプリケーションのテストについて、ドキュメントの「Testing Click Applications」を読みました(CliRunnerのinvokeメソッドやisolated_filesystemメソッドを知る)

はじめに

アプリケーション・テスティング! nikkieです。

Command Line Interface Creation Kit、頭文字を取ってClick。
コマンドラインツールを実装できるライブラリの1つです。
Clickのドキュメントの中にテストの書き方を見つけ、手を動かしてみました。

目次

動作環境

ドキュメント「Testing Click Applications」

Clickで作るCLIのテストの基本として、click.testingモジュール1について解説したドキュメントです。
扱っているのは次の3つ。

  • Basic Testing
  • File System Isolation
  • Input Streams

順番に見ていきましょう。

Basic Testing(テストの基本)

https://click.palletsprojects.com/en/8.1.x/testing/#basic-testing

click.testing.CliRunnerを使うという基本が紹介されます

  • CliRunnerのinvokeメソッドでCommandを実行できる
  • invokeメソッドの返り値はclick.testing.Result型で、テストのアサーションに利用できる

テスト対象:helloコマンド

import click


@click.command
@click.argument("name")
def hello(name):
    click.echo(f"Hello {name}!")


if __name__ == "__main__":
    hello()
% python hello.py Peter
Hello Peter!

helloコマンドのテスト

CliRunnerにinvokeさせましょう。

from click.testing import CliRunner
from hello import hello


def test_hello_world():
    runner = CliRunner()

    result = runner.invoke(hello, ["Peter"])

    assert result.exit_code == 0
    assert result.output == "Hello Peter!\n"
% pytest test_hello.py
============================= test session starts ==============================
platform darwin -- Python 3.11.4, pytest-7.4.0, pluggy-1.2.0
rootdir: /Users/.../testing
collected 1 item

test_hello.py .                                                          [100%]

============================== 1 passed in 0.01s ===============================

サブコマンドのテスト

別の例としてサブコマンドにも言及されます。

For subcommand testing, a subcommand name must be specified in the args parameter of CliRunner.invoke() method:

CliRunner.invokeの第1引数にはGroupのcliを渡します。
そして、第2引数(文字列のリスト)にサブコマンド名(sync)を含めたリストを渡します!

result = runner.invoke(cli, ['--debug', 'sync'])

File System Isolation(ファイルシステム分離)

https://click.palletsprojects.com/en/8.1.x/testing/#file-system-isolation

CliRunnerのisolated_filesystemメソッドを使うことで、現在のワーキングディレクトリを新しい空のディレクトリとして扱える、とあります2

テスト対象:catコマンド

import click


@click.command()
@click.argument("f", type=click.File())
def cat(f):
    click.echo(f.read())


if __name__ == "__main__":
    cat()
% python cat.py input.txt
kokoro
aki
fuka

catコマンドのテスト

isolated_filesystemメソッドを使い、コンテキストマネージャのスコープにて、catコマンドに入力するファイルを書き込む例が紹介されています。

from click.testing import CliRunner
from cat import cat


def test_cat():
    runner = CliRunner()
    with runner.isolated_filesystem():
        with open("hello.txt", "w") as f:
            f.write("Hello World!")

        result = runner.invoke(cat, ["hello.txt"])

        assert result.exit_code == 0
        assert result.output == "Hello World!\n"

isolated_filesystemメソッドにより、テスト実行時のワーキングディレクトリが空のディレクトリのように扱えるので、そこにhello.txtを作り、相対パスでcatコマンドに入力するわけですね。

% pytest test_cat.py
============================= test session starts ==============================
platform darwin -- Python 3.11.4, pytest-7.4.0, pluggy-1.2.0
rootdir: /Users/.../testing
collected 1 item

test_cat.py .                                                            [100%]

============================== 1 passed in 0.01s ===============================

私はテストに使うデータ(フィクスチャ)として用意したい

好みだと思いますが、私はテスト実行中に書き込むよりは、あらかじめテストに使うデータとして用意しておきたいです。

from pathlib import Path

from click.testing import CliRunner
from cat import cat


def test_cat():
    test_root_dir = Path(__file__).resolve().parent
    input_path = test_root_dir / "fixtures" / "cat_input.txt"
    runner = CliRunner()

    result = runner.invoke(cat, [str(input_path)])

    assert result.exit_code == 0
    assert result.output == "Hello World!\n"

どちらの方法にもメリット・デメリットはあると思います。
デメリット < メリットとなる方法を使うのがよいと思います(私の文脈ではテストに使うデータとして用意するほうがデメリットが小さそう)

  • テストに使うデータ(フィクスチャ)として用意する
    • メリット:テストに使うデータが増えても、テストメソッドは簡潔なままで済む
    • デメリット:フィクスチャのパスの取得が(私にとってはボイラープレートではあるが)easyではない
  • isolated_filesystemメソッドを使う
    • メリット:カレントディレクトリを空のディレクトリとして使えるので、コマンドに入力するファイルパスの指定がeasy
    • デメリット:コマンドの入力に使うファイルに5行・10行とテストコードの中で書き込むと、テストメソッド(のarrange部分)が長くなりテスト自体が読みづらくなりがちではないか

また、コマンドが出力するファイルについては、isolated_filesystemよりも、pytestのフィクスチャ(tmp_path3)を使えばよさそうと思いました。

Input Streams(入力ストリーム)

https://click.palletsprojects.com/en/8.1.x/testing/#input-streams

入力にはinvokeメソッドのinput引数が使えます。

テスト対象:promptコマンド

import click


@click.command()
@click.option("--foo", prompt=True)
def prompt(foo):
    click.echo(f"foo={foo}")


if __name__ == "__main__":
    prompt()

optionのprompt=Trueについては以下をどうぞ:
https://click.palletsprojects.com/en/8.1.x/options/#prompting

% python prompt.py
Foo: wau wau
foo=wau wau

「Foo: 」と促されるので「wau wau」と入力しました。

promptコマンドのテスト

from click.testing import CliRunner
from prompt import prompt


def test_prompt():
    runner = CliRunner()

    result = runner.invoke(prompt, input="wau wau\n")

    assert result.exit_code == 0
    assert result.output == "Foo: wau wau\nfoo=wau wau\n"
% pytest test_prompt.py
============================= test session starts ==============================
platform darwin -- Python 3.11.4, pytest-7.4.0, pluggy-1.2.0
rootdir: /Users/.../testing
collected 1 item

test_prompt.py .                                                         [100%]

============================== 1 passed in 0.01s ===============================

終わりに

Clickのドキュメント「Testing Click Applications」の3つの例を見てきました。

  • click.testing.CliRunnerinvokeメソッドにコマンドやグループと、引数(文字列)のリストを渡す
    • サブコマンドのテストは、引数のリストでサブコマンド名を指定
  • isolated_filesystemで、現在のワーキングディレクトリを空のディレクトリのように扱える
    • IMO:私にはあんまり使いどころないかも
      • 入力するファイルは、(テストコードで書き込むのでなく)ファイルとして用意したい
      • 出力するファイルは、pytestの一時ディレクトリを作るフィクスチャを使って指定したい
  • invokeメソッドはinput引数で、コマンド実行中にプロンプトで渡す値を指定できる

Clickで実装したアプリケーションのテストはどんと来い!という気持ちです。


  1. APIリファレンスソースコード
  2. the CliRunner.isolated_filesystem() method is useful for setting the current working directory to a new, empty folder.
  3. https://docs.pytest.org/en/7.3.x/how-to/tmp_path.html