4 Pythonコマンドライン(CLI)アプリをテストするためのテクニック

4 Pythonコマンドライン(CLI)アプリをテストするためのテクニック

最初のPythonコマンドラインアプリの構築が完了しました。 または、2番目または3番目かもしれません。 しばらくの間Pythonを学習していて、something bigger and more complexを作成する準備ができましたが、コマンドラインで実行できます。 または、GUIを使用してWebアプリケーションまたはデスクトップアプリをbuilding and testingすることに慣れていますが、現在CLIアプリケーションの構築を開始しています。

これらのすべての状況において、Python CLIアプリケーションをテストするためのさまざまな方法を学び、慣れる必要があります。

ツールの選択は恐ろしいかもしれませんが、覚えておくべき主なことは、コードが生成する出力と期待する出力を比較しているだけです。 それからすべてが続きます。

このチュートリアルでは、Pythonコマンドラインアプリをテストするための4つの実践的なテクニックを学びます。

  • print()を使用した「Lo-Fi」デバッグ

  • 視覚的なPythonデバッガーの使用

  • pytestとモックを使用した単体テスト

  • 統合テスト

Free Bonus:Click here to get our Python Testing Cheat Sheetは、このチュートリアルで示されている手法を要約したものです。

すべては、マルチレベル辞書の形式でデータを何らかの方法で変換する2つの関数に渡し、ユーザーに出力する基本的なPython CLIアプリを中心に構築されます。

以下のコードを使用して、テストに役立ついくつかの異なる方法を調べます。 確かに網羅的ではありませんが、このチュートリアルが、主要なテストドメインで効果的なテストを作成する自信を得るのに十分な幅を与えることを願っています。

この初期コードにいくつかのバグを振りかけました。これらのバグはテストメソッドで公開します。

Note:簡単にするために、このコードには、辞書にキーが存在することを確認するなど、いくつかの基本的なベストプラクティスは含まれていません。

最初のステップとして、このアプリケーションのすべての段階でオブジェクトについて考えてみましょう。 John Qを記述する構造から始めます。 パブリック:

JOHN_DATA = {
    'name': 'John Q. Public',
    'street': '123 Main St.',
    'city': 'Anytown',
    'state': 'FL',
    'zip': 99999,
    'relationships': {
        'siblings': ['Michael R. Public', 'Suzy Q. Public'],
        'parents': ['John Q. Public Sr.', 'Mary S. Public'],
    }
}

次に、最初の変換関数initial_transformを呼び出した後、これを期待して、他の辞書をフラット化します。

JOHN_DATA = {
    'name': 'John Q. Public',
    'street': '123 Main St.',
    'city': 'Anytown',
    'state': 'FL',
    'zip': 99999,
    'siblings': ['Michael R. Public', 'Suzy Q. Public'],
    'parents': ['John Q. Public Sr.', 'Mary S. Public'],
}

次に、関数final_transformを使用して、すべての住所情報を1つの住所エントリに組み込みます。

JOHN_DATA = {
    'name': 'John Q. Public',
    'address': '123 Main St. \nAnytown, FL 99999'
    'siblings': ['Michael R. Public', 'Suzy Q. Public'],
    'parents': ['John Q. Public Sr.', 'Mary S. Public'],
}

そして、print_personを呼び出すと、これがコンソールに書き込まれます。

Hello, my name is John Q. Public, my siblings are Michael R. Public
and Suzy Q. Public, my parents are John Q. Public Sr. and Mary S. Public,
and my mailing address is:
123 Main St.
Anytown, FL 99999

testapp.py:

def initial_transform(data):
    """
    Flatten nested dicts
    """
    for item in list(data):
        if type(item) is dict:
            for key in item:
                data[key] = item[key]

    return data


def final_transform(transformed_data):
    """
    Transform address structures into a single structure
    """
    transformed_data['address'] = str.format(
        "{0}\n{1}, {2} {3}", transformed_data['street'],
        transformed_data['state'], transformed_data['city'],
        transformed_data['zip'])

    return transformed_data


def print_person(person_data):
    parents = "and".join(person_data['parents'])
    siblings = "and".join(person_data['siblings'])
    person_string = str.format(
        "Hello, my name is {0}, my siblings are {1}, "
        "my parents are {2}, and my mailing"
        "address is: \n{3}", person_data['name'],
        parents, siblings, person_data['address'])
    print(person_string)


john_data = {
    'name': 'John Q. Public',
    'street': '123 Main St.',
    'city': 'Anytown',
    'state': 'FL',
    'zip': 99999,
    'relationships': {
        'siblings': ['Michael R. Public', 'Suzy Q. Public'],
        'parents': ['John Q. Public Sr.', 'Mary S. Public'],
    }
}

suzy_data = {
    'name': 'Suzy Q. Public',
    'street': '456 Broadway',
    'apt': '333',
    'city': 'Miami',
    'state': 'FL',
    'zip': 33333,
    'relationships': {
        'siblings': ['John Q. Public', 'Michael R. Public',
                    'Thomas Z. Public'],
        'parents': ['John Q. Public Sr.', 'Mary S. Public'],
    }
}

inputs = [john_data, suzy_data]

for input_structure in inputs:
    initial_transformed = initial_transform(input_structure)
    final_transformed = final_transform(initial_transformed)
    print_person(final_transformed)

現時点では、コードは実際にこれらの期待を満たしていないため、それらについて学習しながら4つの手法を使用して調査します。 これを行うことにより、これらの技術の使用に関する実践的な経験を獲得し、快適な領域をそれらに拡張し、どの問題に最も適しているかを学び始めます。

印刷による「Lo-Fi」デバッグ

これは最も簡単なテスト方法の1つです。 ここで行う必要があるのは、関数呼び出しの前、関数呼び出しの後、または関数内で、関心のある変数またはオブジェクトのprintだけです。

それぞれ、関数の入力、関数の出力、および関数のロジックを検証できます。

上記のコードをtestapp.pyとして保存し、python testapp.pyで実行しようとすると、次のようなエラーが表示されます。

Traceback (most recent call last):
  File "testapp.py", line 60, in 
    print_person(final_transformed)
  File "testapp.py", line 23, in print_person
    parents = "and".join(person_data['parents'])
KeyError: 'parents'

print_personに渡されるperson_dataに欠落しているキーがあります。 最初のステップは、print_personへの入力をチェックし、期待される出力(印刷されたメッセージ)が生成されない理由を確認することです。 print_personを呼び出す前に、print関数呼び出しを追加するだけです。

final_transformed = final_transform(initial_transformed)
print(final_transformed)
print_person(final_transformed)

print関数はここで機能し、出力に最上位のparentsキーもsiblingsキーもないことを示しますが、正気のために、マルチレベルオブジェクトをより読みやすい方法で出力するpprintを紹介します。 これを使用するには、スクリプトの先頭にfrom pprint import pprintを追加します。

print(final_transformed)の代わりに、pprint(final_transformed)を呼び出してオブジェクトを検査します。

{'address': '123 Main St.\nFL, Anytown 99999',
 'city': 'Anytown',
 'name': 'John Q. Public',
 'relationships': {'parents': ['John Q. Public Sr.', 'Mary S. Public'],
                   'siblings': ['Michael R. Public', 'Suzy Q. Public']},
 'state': 'FL',
 'street': '123 Main St.',
 'zip': 99999}

これを上記の予想最終フォームと比較してください。

final_transformrelationshipsディクショナリにアクセスしないことがわかっているので、initial_transformで何が起こっているかを確認します。 通常、従来のデバッガーを使用してこの手順を実行しますが、印刷デバッグの別の使用方法を示したいと思います。

オブジェクトの状態をコードで印刷できますが、それに限定されません。 好きなものを印刷できるので、マーカーを印刷して、どの論理分岐がいつ実行されたかを確認することもできます。

initial_transformは主にいくつかのループであり、内部辞書は内部のforループによって処理されることになっているため、そこで何が起こっているかを確認する必要があります。

def initial_transform(data):
    """
    Flatten nested dicts
    """
    for item in list(data):
        if type(item) is dict:
            print "item is dict!"
            pprint(item)
            for key in item:
                data[key] = item[key]

    return data

入力data内で辞書に出くわすと、コンソールでアラートが表示され、アイテムがどのように表示されるかがわかります。

実行後、コンソール出力は変更されていません。 これは、ifステートメントが期待どおりに機能していないことを示す良い証拠です。 バグを見つけるために印刷を続けることができますが、これはデバッガーを使用することの長所を示す素晴らしい方法です。

ただし、演​​習として、印刷デバッグのみを使用してこのコードをバグ追跡することをお勧めします。 これは良い習慣であり、コンソールを使用してコードで発生しているさまざまなことについて警告するためのすべての方法を考えるように強制します。

要約

印刷デバッグを使用する場合:

  • 単純なオブジェクト

  • より短いスクリプト

  • 一見単純なバグ

  • 素早い検査

深く掘り下げる:

  • pprint-印刷されたオブジェクトをきれいにします

長所:

  • 迅速なテスト

  • 使いやすい

短所:

  • ほとんどの場合、プログラム全体を実行する必要があります。それ以外の場合:

  • フローを手動で制御するには、追加のコードを追加する必要があります

  • 特に複雑なコードでは、完了時にテストコードを誤って残すことができます

デバッガーを使用する

デバッガーは、一度に1行ずつコードをステップ実行し、アプリケーション全体の状態を検査する場合に最適です。 エラーが発生している場所を大まかに知っている場合に役立ちますが、理由を把握することはできません。また、アプリケーション内で発生しているすべてをすぐに見渡すことができます。

そこには多くのデバッガがあり、多くの場合、IDEに付属しています。 Pythonには、コードをデバッグするためにREPLで使用できるa module called pdbもあります。 このセクションでは、使用可能なすべてのデバッガーの実装固有の詳細に入るのではなく、breakpointswatchesの設定などの一般的な機能でデバッガーを使用する方法を示します。

Breakpointsは、アプリケーションの状態を検査するために実行を一時停止する場所をデバッガーに指示するコード上のマーカーです。 Watchesは、デバッグセッション中に追加して変数(およびそれ以上)の値を監視できる式であり、アプリの実行を通じて保持されます。

しかし、ブレークポイントに戻りましょう。 これらは、デバッグセッションを開始または継続する場所に追加されます。 initial_transformメソッドをデバッグしているので、そこに配置します。 ブレークポイントを(*)で示します。

def initial_transform(data):
    """
    Flatten nested dicts
    """
(*) for item in list(data):
        if type(item) is dict:
            for key in item:
                data[key] = item[key]

    return data

デバッグを開始すると、その行で実行が一時停止し、プログラム実行の特定のポイントで変数とその型を確認できます。 コードをナビゲートするためのいくつかのオプションがあります。step overstep in、およびstep outが最も一般的です。

Step overは、最も頻繁に使用するものです。これは、コードの次の行にジャンプするだけです。

Step inは、コードをさらに深く掘り下げようとします。 これは、より深く調査したい関数呼び出しに出会ったときに使用します。その関数のコードに直接移動し、そこで状態を調べることができます。 また、step overと混同する場合にもよく使用します。 幸いなことに、step outは私たちを救うことができます。これにより、私たちは発信者に戻ります。

ここでwatchを設定することもできます。これはtype(item) is dictのように、ほとんどのIDEでデバッグセッション中に[監視を追加]ボタンを使用して行うことができます。 これで、コード内のどこにいても、TrueまたはFalseが表示されます。

時計を設定し、ステップオーバーして、if type(item) is dict:行で一時停止します。 これで、時計のステータス、新しい変数item、およびオブジェクトdataを確認できるはずです。

Python Debugger Screenshot: Watching Variables

ウォッチがなくても、問題が発生する可能性があります。itemが指しているものをtypeが確認するのではなく、文字列であるitem自体のタイプを確認しています。 結局のところ、コンピューターは私たちが言うことをexactlyで実行します。 デバッガーのおかげで、私たちは方法のエラーを見つけ、次のようにコードを修正します。

def initial_transform(data):
    """
    Flatten nested dicts
    """
    for item in list(data):
        if type(data[item]) is dict:
            for key in data[item]:
                data[key] = item[key]

    return data

デバッガーで再度実行し、コードが期待どおりに動作していることを確認する必要があります。 そして、私たちはそうではありません。構造は次のようになります。

john_data = {
    'name': 'John Q. Public',
    'street': '123 Main St.',
    'city': 'Anytown',
    'state': 'FL',
    'zip': 99999,
    'relationships': {
        'siblings': ['Michael R. Public', 'Suzy Q. Public'],
        'parents': ['John Q. Public Sr.', 'Mary S. Public'],
    },
    'siblings',
    'parents',
}

ビジュアルデバッガーの使用方法を確認したので、次の演習を完了して、さらに深く、新しい知識をテストに加えましょう。

ビジュアルデバッガーについて説明しました。 ビジュアルデバッガーを使用しました。 ビジュアルデバッガが大好きです。 ただし、この手法にはまだ長所と短所があり、以下のセクションでそれらを確認できます。

要約

Pythonデバッガーを使用する場合:

  • より複雑なプロジェクト

  • バグを検出するのが難しい

  • 複数のオブジェクトを検査する必要があります

  • whereでエラーが発生しているという大まかな考えがありますが、それに焦点を合わせる必要があります

深く掘り下げる:

  • 条件付きブレークポイント

  • デバッグ中の式の評価

長所:

  • プログラムの流れを制御する

  • アプリケーションの状態の鳥瞰図

  • バグの発生場所を正確に知る必要はありません

短所:

  • 非常に大きなオブジェクトを手動で見るのが難しい

  • 長時間実行されるコードはデバッグに非常に時間がかかります

PytestとMocksを使用した単体テスト

以前の手法は退屈であり、入出力の組み合わせを徹底的にテストする場合はコードの変更が必要になる可能性があり、特にアプリの成長に合わせてコードのすべてのブランチをヒットします。 この例では、initial_transformの出力はまだ完全に正しく見えません。

コードのロジックは非常にシンプルですが、サイズや複雑さが簡単に増大したり、チーム全体の責任になったりする可能性があります。 より構造化された、詳細な、自動化された方法でアプリケーションをテストするにはどうすればよいですか?

単体テストを入力します。

ユニットテストは、ソースコードを認識可能なユニット(通常はメソッドまたは関数)に分解し、個別にテストするテスト手法です。

基本的に、異なる入力で各メソッドをテストするスクリプトまたはスクリプトのグループを作成して、各メソッド内のすべてのロジックブランチがテストされるようにします。これはコードカバレッジと呼ばれ、通常は100%のコードカバレッジを目指します。 これは必ずしも必要または実用的とは限りませんが、別の記事(または教科書)に保存できます。

各テストは、テスト対象のメソッドを個別に扱います。外部呼び出しは、モッキングと呼ばれる手法でオーバーライドされ、信頼できる戻り値と、テスト後にテストが削除される前に設定されたオブジェクトを提供します。 これらの手法およびその他の手法は、テスト対象のユニットの独立性と分離を保証するために行われます。

期待される出力を実際の出力と比較するというテーマを引き続き継続しているにもかかわらず、再現性と分離はこれらの種類のテストの鍵となります。 ユニットテスト全体を理解したので、簡単に迂回して、minimum viable test suiteを使用してFlaskアプリケーションをユニットテストする方法を確認できます。

パイテスト

理論を深く掘り下げたので、実際にどのように機能するか見てみましょう。 Pythonには組み込みのunittestモジュールが付属していますが、pytestunittestが提供するものに基づいて構築するのに優れた仕事をしていると思います。 いずれにせよ、単体テストだけで複数の長い記事を取り上げることがあるため、単体テストの基本を示します。

一般的な規則は、すべてのテストをプロジェクト内のtestディレクトリに配置することです。 これは小さなスクリプトであるため、testapp.pyと同じレベルのファイルtest_testapp.pyで十分です。

initial_transformの単体テストを作成して、予想される入力と出力のセットを設定し、それらが一致することを確認する方法を示します。 pytestで使用する基本的なパターンは、いくつかのパラメーターを受け取るfixtureを設定し、それらを使用して、必要なテスト入力と期待される出力を生成することです。

最初にフィクスチャのセットアップを示します。コードを見ながら、initial_transformのすべての可能なブランチをヒットするために必要なテストケースについて考えます。

import pytest
import testapp as app

@pytest.fixture(params=['nodict', 'dict'])
def generate_initial_transform_parameters(request):

入力を生成する前に、混乱を招く可能性があるため、ここで何が起こっているのか見てみましょう。

まず、@pytest.fixtureデコレータを使用して、次の関数定義をフィクスチャとして宣言します。 また、generate_initial_transform_parametersで使用する名前付きパラメーターparamsを使用します。

これの優れた機能は、decorated関数が使用されるたびに、すべてのパラメーターで使用されるため、generate_initial_transform_parametersを呼び出すだけで、nodictをパラメーターとして1回、dict

これらのパラメーターにアクセスするには、pytest特殊オブジェクトrequestを関数シグネチャに追加します。

次に、入力と期待される出力を作成しましょう。

@pytest.fixture(params=['nodict', 'dict'])
def generate_initial_transform_parameters(request):
    test_input = {
        'name': 'John Q. Public',
        'street': '123 Main St.',
        'city': 'Anytown',
        'state': 'FL',
        'zip': 99999,
    }
    expected_output = {
        'name': 'John Q. Public',
        'street': '123 Main St.',
        'city': 'Anytown',
        'state': 'FL',
        'zip': 99999,
    }

    if request.param == 'dict':
        test_input['relastionships'] = {
            'siblings': ['Michael R. Public', 'Suzy Q. Public'],
            'parents': ['John Q. Public Sr.', 'Mary S. Public'],
        }
        expected_output['siblings'] = ['Michael R. Public', 'Suzy Q. Public']
        expected_output['parents'] = ['John Q. Public Sr.', 'Mary S. Public']

    return test_input, expected_output

ここでそれほど驚くことはありません。入力と期待される出力を設定し、'dict'パラメータがある場合は、入力と期待される出力を変更して、ifブロックをテストできるようにします。

次に、テストを作成します。 テストでは、フィクスチャにアクセスするためのパラメータとしてフィクスチャをテスト関数に渡す必要があります。

def test_initial_transform(generate_initial_transform_parameters):
    test_input = generate_initial_transform_parameters[0]
    expected_output = generate_initial_transform_parameters[1]
    assert app.initial_transform(test_input) == expected_output

テスト関数の前にはtest_を付け、assert statementsに基づく必要があります。 ここでは、実際の関数に入力を渡すことで得られる出力が、期待される出力に等しいと断言しています。 これをIDEでテスト構成を使用して実行するか、CLIでpytestを使用して実行すると、エラーが発生します。 出力はまだ完全ではありません。 次の演習を使用して修正しましょう。実際の経験は非常に貴重であり、読んだ内容を実践に役立てることで、今後思い出しやすくなります。

モック

モックは、単体テストのもう1つの重要な部分です。 単一のコードユニットのみをテストしているため、他の関数呼び出しが何をするかはあまり気にしません。 私たちは彼らから信頼できるリターンを得たいだけです。

initial_transformに外部関数呼び出しを追加しましょう:

def initial_transform(data):
    """
    Flatten nested dicts
    """
    for item in list(data):
        if type(data[item]) is dict:
            for key in data[item]:
                data[key] = data[item][key]
            data.pop(item)

    outside_module.do_something()
    return data

do_something()にライブ呼び出しを行いたくないので、代わりにテストスクリプトでモックを作成します。 モックはこの呼び出しをキャッチし、モックを返すように設定したものを返します。 フィクスチャにモックをセットアップするのが好きです。これはテストセットアップの一部であり、セットアップコードをすべてまとめることができるからです。

@pytest.fixture(params=['nodict', 'dict'])
def generate_initial_transform_parameters(request, mocker):
    [...]
    mocker.patch.object(outside_module, 'do_something')
    mocker.do_something.return_value(1)
    [...]

これで、initial_transformを呼び出すたびに、do_something呼び出しがインターセプトされ、1が返されます。 フィクスチャパラメータを利用して、モックが返すものを決定することもできます。これは、外部呼び出しの結果によってコードブランチが決定される場合に重要です。

最後の巧妙なトリックは、side_effectを使用することです。 とりわけ、これにより、同じ関数への連続した呼び出しに対して異なるリターンをモックできます。

def initial_transform(data):
    """
    Flatten nested dicts
    """
    for item in list(data):
        if type(data[item]) is dict:
            for key in data[item]:
                data[key] = data[item][key]
            data.pop(item)

    outside_module.do_something()
    outside_module.do_something()
    return data

このようにモックを設定し、(連続する呼び出しごとに)出力のリストをside_effectに渡します。

@pytest.fixture(params=['nodict', 'dict'])
def generate_initial_transform_parameters(request, mocker):
    [...]
    mocker.patch.object(outside_module, 'do_something')
    mocker.do_something.side_effect([1, 2])
    [...]

モックは非常に強力であるため、set up mock servers to test third-party APIsを使用することもできます。また、mockerを使用してモックを作成することをお勧めします。

要約

Pythonユニットテストフレームワークを使用する場合:

  • 大規模で複雑なプロジェクト

  • OSSプロジェクト

役立つツール:

長所:

  • 実行中のテストを自動化する

  • 多くの種類のバグをキャッチできます

  • チーム向けの簡単なセットアップと変更

短所:

  • 書くのが面倒

  • ほとんどのコード変更で更新する必要があります

  • 実行中の真のアプリケーションを複製しません

統合テスト

統合テストは、ここでは簡単なテスト方法の1つですが、おそらく最も重要なテスト方法の1つです。 これには、実稼働環境のような環境で実際のデータを使用してアプリをエンドツーエンドで実際に実行する必要があります。

これがホームマシン、本番サーバーを複製するテストサーバー、または本番サーバーからテストデータベースへの接続を変更するだけであっても、展開時に変更が機能することを確認できます。

他のすべての方法と同様に、入力が与えられるとアプリケーションが期待される出力を生成することを確認します。ただし、今回は実際の外部モジュールを使用します(ユニットテストではモックされます)。ファイル、および大規模なアプリケーションでは、コードがシステム全体と適切に統合されるようにします。

これをどのように行うかは、アプリケーションに大きく依存します。たとえば、テストアプリはpython testapp.pyを使用して単独で実行できます。 ただし、コードがETLパイプラインのような大規模な分散アプリケーションの一部であるとします。その場合は、コードを交換した状態でテストサーバーでシステム全体を実行し、データを実行し、作成したことを確認する必要があります正しい形式でシステム全体を通過します。 コマンドラインアプリケーションの世界以外では、pyVows can be used for integration testing Django appsなどのツール。

要約

Pythonで統合テストを使用する場合:

  • 常に;-)

  • 一般に、他のテスト方法が採用されている場合は、それらの方法の後。

役立つツール:

  • tox環境とテスト自動化管理

長所:

  • アプリケーションが実際の状況でどのように実行されるかを確認する

短所:

  • 大規模なアプリケーションでは、データフローを正確に追跡するのが難しい場合があります

  • 実稼働環境に非常に近いテスト環境が必要

すべてを一緒に入れて

結論として、すべてのCLIテストは、入力のセットを前提として、予想される出力と実際の出力を比較することです。 上記で説明した方法は、それを行うすべての方法であり、多くの点で補完的です。 これらは、Pythonでコマンドラインアプリケーションを構築し続けるときに理解するための重要なツールになりますが、このチュートリアルは出発点にすぎません。

Pythonには非常に豊富なエコシステムがあり、それはテストツールと方法論にまで及ぶので、これから分岐してさらに調査します。ここで言及しなかったツールやテクニックが見つかるかもしれません。 もしそうなら、コメントでそれを聞きたいです!

簡単に要約すると、今日私たちが学んだテクニックとその使用方法は次のとおりです。

  • デバッグの印刷-コード内の変数やその他のマーカーを印刷して、実行の流れを確認します

  • デバッガー-プログラムの実行を制御して、アプリケーションの状態とプログラムフローの概観を取得します

  • ユニットテスト-アプリケーションを個別にテスト可能なユニットに分割し、そのユニット内のすべてのロジックブランチをテストする

  • 統合テスト-幅広いアプリケーションのコンテキストでコードの変更をテストする

さあ、テストに行きましょう! これらの手法を使用する際には、コメントの中で、どのようにそれらを使用し、どれがお気に入りであるかをお知らせください。

このチュートリアルで示したテクニックを要約したPythonテストのチートシートを入手するには、以下のリンクをクリックしてください。

Free Bonus:Click here to get our Python Testing Cheat Sheetは、このチュートリアルで示されている手法を要約したものです。