Python Clickユニットテスト・レシピ集 - CLIではじめるテスト駆動開発(その1)

JX通信社Advent Calendar 2019」20日目の記事です。 こんにちは。2019年9月からJX通信社のエンジニアとなった鈴木(泰)です。好きな食べ物はオムライスです。

本日は、Python Clickユニットテスト・レシピ集 - CLIではじめるテスト駆動開発(その1)と題して、CLIのユニットテストのスニペットを書いてみたいと思います! "その1"とした理由は、アドカレに間に合わな(小声)・・・じゃなかった・・・この記事だけで全ての備忘録を列挙すると長くなりすぎてしまい、記事が読み難くなると判断したからです。

今後も引き続き少しづつ備忘録を紹介していければと思います。

はじめに

私はCLIをよく書きます。その理由は、バックエンドシステムの運用業務に携わっていることにあります。運用業務では様々な場面でCLIを作成します。私の場合、運用業務における手作業を自動化するため、バッチ処理を書くため、というのが主です。

にも関わらず、私はCLIのユニットテストを、これまであまり書いてきませんでした。これについてはとても反省しています。ごめんなさい!

書かなかった理由(a.k.a 言い訳)は多々あります。

  • 書き捨てスクリプトだから。
  • 外部コンポネントと密結合しているので、ユニットテストが書き難い。
  • テストを書く時間がない。忙しい。
  • テストを書くことは、オーバーワーク。
  • etc ...

ざっくりとひとまとめにすると、要は「書いている時間がない」のです。

書いている時間がない場合どうするか?私がよく使う手はコピー・ペーストです。つまり、CLIを書くときに頻出するパターンを想定した、コピーペースト用のスニペットを用意しておけば良いのです。今回は、CLIを書くときに頻出するパターン毎に、コピーペースト用のスニペットを書いてみることにしました。

私の知らないもっと良い方法を知っている方がいましたら、コメント等でご教示いただけると幸いです!

目次

対象とするCLI

対象とするのは、外部コンポネントとの結合がないCLIのユニットテストです。

外部コンポネントとの結合とは、ファイルシステムやネットワーク通信を介してシステム繋がりのことです。例えば、WebAPIから何らかのデータを取得し、結果をローカルファイルに保存するCLIは、外部コンポネントとの結合があるCLIです。なぜなら、WebAPIとローカルファイルシステムという、2つのコンポネントとつながっているからです。

click

clickというライブラリを用いてCLIを書きます。clickを用いる理由は

といったことがあります。

諸注意

  • 本記事で掲載するソースコードは、MacOS 10.15.2、Python3.7、こちらのclickおよびその依存パッケージのバージョンで動作確認をしております。
  • 私がよく使うスニペットを前提に書いています。私があまり使わないなぁ・・・と思ったら、そのスニペットは書いていません。(・・・が、こんなスニペットも使える!とか、これは必要だろ!とかあればコメントお願いします!)
  • 本記事で使ったソースコードはこちらにあります。

スニペット一覧

外部コンポネントとの結合がない場合

標準出力、標準エラー出力、終了ステータスコードの検証

まずは、必ず検証すべき3つの値(標準出力、標準エラー出力、終了ステータスコード)からです。

CLIのソースコード 全体

import click

@click.command()
def cli():
    click.echo('こんにちは')
    click.echo('世界!', err=True)  # 標準エラー出力
    exit(100)

テストコード 全体

from click.testing import CliRunner
from cli_stdout import cli

def test_cli_output():
    result = CliRunner().invoke(cli)
    assert result.output == 'こんにちは\n世界!\n'

def test_cli_output_separatly():
    # 標準出力と標準エラー出力を分離して出力をテストしたい場合、
    # `CliRunner(mix_stderr=False)`とする。
    result = CliRunner(mix_stderr=False).invoke(cli)
    assert result.stdout == 'こんにちは\n'
    assert result.stderr == '世界!\n'

def test_cli_exit_code():
    result = CliRunner().invoke(cli)
    assert result.exit_code == 100
  • clickを使用する場合click.echo関数を用いて出力することが慣習ですが、print関数で出力した場合でも、上のテストは動きます。

色付き出力の検証

clickではANSI Color codeを用いて、出力される文字列に色を付与できます。

f:id:taisuzuk:20191219114904p:plain

CLIのソースコード 全体

import click

@click.command()
def cli():
    click.echo('こんにちは')
    click.echo(click.style('JX', fg='green'), nl=False)
    click.echo(click.style('通信社!', fg='red'))

テストコード 全体

from click.testing import CliRunner
from cli_color_output import cli

def test_cli_output():
    result = CliRunner().invoke(cli)
    assert result.output == 'こんにちは\nJX通信社!\n'
  • ユニットテストで検証するときは、ANSI Color codeを無視できます。
  • ANSI Color codeを含めた出力の検証をしたい場合、CliRunner(color=True)とすればできます。

例外の検証

例外を投げるCLIの検証です。

CLIのソースコード 全体

import click

@click.command()
def cli():
    raise Exception('Hello world!')

テストコード 全体

from click.testing import CliRunner
from cli_exception import cli

def test_cli_exception():
    result = CliRunner().invoke(cli)
    assert 'Hello world!' == str(result.exception)
    assert Exception == type(result.exception)
  • 例外がない場合、result.exceptionNoneです。

コマンドライン引数やオプションの検証

clickでは、コマンドライン引数やオプション指定を間違えていた場合、デフォルトでは終了ステータス2(より厳密には、staticな変数click.exceptions.UsageError.exit_codeの値)で終了します。

私としては、CLIの引数の検証(例えば、このオプションが必須で・・・この引数は文字列で・・・といったような仕様の検証)は不要であり、終了ステータスだけ検証すれば良いと思います。なぜなら、CLIの引数周りの処理はclickライブラリが担う責務であり、利用者である私たちが検証すべきことではないからです。

CLIのソースコード 全体

import click

@click.command()
@click.argument('src', nargs=-1)
@click.argument('dst', nargs=1)
def cli(src, dst):
    click.echo(src)
    click.echo(dst)

テストコード 全体

from click.testing import CliRunner
from cli_option import cli

def test_cli_option_usage_exception():
    result = CliRunner().invoke(cli, args=[])
    assert 2 == result.exit_code

サブコマンドのテストの書き方

サブコマンドのテストは次のように書きます。

CLIのソースコード 全体

import click

@click.group()
def cli():
    pass

@cli.command()
def sub1():
    click.echo('sub command1')

@cli.command()
def sub2():
    click.echo('sub command2')

テストコード 全体

from click.testing import CliRunner
from cli_sub import

def test_cli_sub1():
    result = CliRunner().invoke(cli, args=['sub1'])
    assert 'sub command1\n' == result.output

def test_cli_sub2():
    result = CliRunner().invoke(cli, args=['sub2'])
    assert 'sub command2\n' == result.output

環境変数の検証

clickでは、CliRunnerのenvという引数に、テストで使用する環境変数を指定できます。

CLIのソースコード 全体

import click

@click.command()
@click.option('--name', type=str, envvar='NAME')
@click.option('--age', type=int)
def cli(name: str, age: int):
    click.echo('{} {}'.format(name, age))

テストコード 全体

from click.testing import CliRunner
from cli_env import 

def test_cli():
    result = CliRunner(env={'NAME': 'hoge'}).invoke(cli, args=['--age=1'])
    assert 'hoge 1\n' == result.output

標準入力

clickには、標準入力からデータを読むためのget_text_stream関数があるのですが、この関数を使った場合のユニットテストの書き方はわかりませんでした。。。じゃあこの記事に掲載するなよ!と思われるかもしれませんが、もしかしたら誰か良い方法を知っているかもしれないということで・・・記事に掲載することとしました。

CLIのソースコード 全体

import click

@click.command()
def cli():
    body: str = click.get_text_stream('stdin', encoding='utf-8').read()
    click.echo(body)

テストコード 全体

# どう書いたら良いかわかりませんでした・・・。

標準入力2

標準入力を読み込むために、clickのget_text_stream関数ではなくinput関数を使用した場合であれば、次のようにしてユニットテストを書くことができます。

CLIのソースコード 全体

import click

@click.command()
def cli():
    body: str = input()
    click.echo(body)

テストコード 全体

from click.testing import CliRunner
from unittest.mock import patch
from cli_stdin2 import cli

def test_cli():
    with patch('builtins.input', return_value='hoge'):
        result = CliRunner().invoke(cli, args=[])
        assert 'hoge\n' == result.output

所感

今回は、外部コンポネントとの結合がない場合のみなので、あまり迷うことはありませんでした。その理由はclick.testing.CliRunnerが用意されていることにあると思います。これのおかげで「こういう場合はこう書けば良い」という方針が明確になっています。

次回は、外部コンポネントとの結合がある場合のスニペットについて書く予定です。特に、ファイルシステムに読み書きする、HTTP通信する、MySQLに接続する、ロガーを通してアプリケーションログを残す、については頻出パターンなので、スニペットを用意したいと考えています。外部コンポネントとの結合部をどうモックするか?スニペットし易い簡潔なモックをどう書くか?というところが鍵となりそうだと考えています。

それでは次回もご期待ください! ありがとうございました。