pytestによるテスト駆動開発

良いソフトウェアとは、テストされたソフトウェアである。コードをテストすることで、バグや望ましくない振る舞いを発見することができます。

テスト駆動開発 (TDD) はソフトウェア開発のプラクティスで、追加したい機能に対して段階的にテストを書くことを要求されます。TDDは、Pythonプログラムのテストフレームワークであるpytestのような自動テストスイートを活用します。

自動化テスト

開発者は通常、コードを書き、必要に応じてコンパイルし、そのコードを実行して動作するかどうかを確認します。これは、手動テストの一例です。この方法では、プログラムのどの機能が動作するのかを探ります。もし、テストを徹底的に行いたいのであれば、各機能の様々な結果をテストする方法を覚えておく必要があります。

もし、新しい開発者がプロジェクトに機能を追加し始めたらどうでしょうか。その機能も覚えてテストしなければならないのでしょうか。新しい機能が古い機能に影響を与えることがありますが、新しい機能を追加したときに、以前の機能がすべてまだ機能しているかどうかを手動でチェックするつもりですか?

手動テストは、開発を続けるための自信を手っ取り早く与えてくれます。しかし、アプリケーションが成長するにつれ、コードベースを手動で継続的にテストすることは指数関数的に難しくなり、退屈になります。

自動テストは、私たち自身がコードをテストし、その結果を追跡する負担を、私たちのためにそれを行うスクリプトを保守することにシフトします。スクリプトは、開発者が定義した入力でコードのモジュールを実行し、その出力を開発者が定義した期待値と比較します。

pytestモジュール

Pythonの標準ライブラリには、自動テストのフレームワークであるunittestライブラリが付属しています。unittestライブラリは機能が豊富で効果的ですが、この記事ではpytest` を武器として使うことにします。

ほとんどの開発者は unittest よりも pytest の方が使いやすいと感じています。その理由の一つは、 unittest モジュールがクラスを必要とするのに対して、 pytest はテストを書くために関数を必要とするだけだからです。

多くの新しい開発者にとって、テストにクラスが必要なことは少し抵抗があることでしょう。また、 pytest には、このチュートリアルの後半で使用する、 unittest モジュールにはない多くの機能が含まれています。

テスト駆動開発とは?

テスト駆動開発(Test-Driven Development)とは、以下のような手順でソフトウェアを作成することを、あなたやコーダーチームに指示するシンプルなソフトウェア開発のプラクティスです。

    1. 機能が失敗した場合のテストを書く
  1. テストに合格するようにコードを書く
  2. 必要に応じてコードをリファクタリングする

このプロセスは、一般的にレッド・グリーン・リファクタリングサイクルと呼ばれます。

  • 新しいコードがどのように動作するかの自動化されたテストを書き、それが失敗するのを見る – レッド
  • テストがパスするまでアプリケーションにコードを書く – グリーン
  • コードが読みやすく、効率的になるようにリファクタリングする。リファクタリングによって新しい機能が壊れることを心配する必要はありません。単にテストを再実行して、それが合格することを確認するだけです。

テストに合格するためのコードを書く必要がなくなったとき、その機能は完成します。

なぜTDDでアプリケーションを作るのか?

TDD を使用する際の一般的な不満は、時間がかかりすぎるということです。

テストを効率的に書けるようになると、テストのメンテナンスに必要な時間が短縮されます。さらに、TDDには以下のような利点があり、時間とのトレードオフに見合うだけの価値を見出すことができます。

  • テストを書くには、その機能を動作させるための入力と出力を知っている必要があります – TDD は、コーディングを始める前に、アプリケーションのインターフェースについて考えさせるのです。
  • コードベースに対する信頼性の向上 – すべての機能に対して自動化されたテストがあることで、開発者は新しい機能を開発するときに自信を持つことができます。新しい変更が以前から存在していたものを壊したかどうかを確認するために、システム全体をテストすることは些細なことになる。
  • TDDはすべてのバグを排除するわけではありませんが、バグに遭遇する可能性は低くなります – バグを修正しようとするとき、そのためのテストを書けば、コーディングが終わったときに修正されていることを確認できます。
  • テストはさらなるドキュメントとして使用することができます。ある機能の入力と出力を書くとき、開発者はテストを見て、コードのインターフェイスがどのように使われることを意図しているかを見ることができます。

コードカバレッジ

コードカバレッジは、テスト計画によってカバーされるソースコードの量を測定する指標です。

コードカバレッジが 100%というのは、あなたが書いたすべてのコードが何らかのテストで使われていることを意味します。ツールはさまざまな方法でコードカバレッジを測定しますが、ここではいくつかの一般的な測定方法を紹介します。

  • テストされたコードの行数
    テストされたコードの行数 * 定義された関数がどれだけテストされたか
    テストされたコードの行数 * 定義された関数がいくつテストされたか * 分岐 (たとえば if ステートメント) がいくつテストされたか

コードカバレッジツールがどのような指標を使用しているかを知っておくことは重要です。

私たちは pytest を多用しているので、コードカバレッジを取得するために人気のある pytest-cov プラグインを使用します。

コードカバレッジが高いからといって、アプリケーションにバグがないわけではありません。コードがすべての可能なシナリオに対してテストされていない可能性が高いからです。

ユニットテストとインテグレーションテスト

ユニットテストは個々のモジュールが期待通りに動作することを確認するために使用されます。一方、統合テストはモジュールの集合体が期待通りに相互動作することを確認するために使用されます。

大規模なアプリケーションを開発する場合、多くのコンポーネントを開発する必要があります。個々のコンポーネントにはそれぞれ対応するユニットテストがあるかもしれませんが、複数のコンポーネントを一緒に使用したときに期待通りの動作をすることを確認する方法も必要です。

TDD では、まず現在のコードベースで失敗するテストをひとつだけ書き、それを完成させることから始めることを要求しています。それがユニットテストである必要はなく、最初のテストは必要なら統合テストでも構いません。

最初に失敗する統合テストが書けたら、次に個々のコンポーネントの開発に取りかかります。

統合テストは、各コンポーネントが構築され、テストに合格するまで失敗します。統合テストに合格すると、正しく作られていれば、私たちのシステムのユーザー要件を満たしたことになります。

基本的な例 素数の和を計算する

TDDを理解する最良の方法は、実践することです。まず、素数である数列のすべての数の和を返すPythonプログラムを書きます。

1つはある数字が素数かどうかを判断する関数で、もう1つは与えられた数列から素数を足す関数です。

好きなワークスペースに primes というディレクトリを作成します。そして、2つのファイルを追加します。primes.pytest_primes.py`の2つのファイルを追加します。最初のファイルにはプログラムコードを書き、2番目のファイルにはテストを書きます。

pytestでは、テストファイルは "test_" で始まるか "_test.py" で終わる必要があります(したがって、テストファイルの名前をprimes_test.py` にすることも可能です)。

それでは、primesディレクトリで、仮想環境を構築してみましょう。

$ python3 -m venv env # Create a virtual environment for our modules
$ . env/bin/activate # Activate our virtual environment
$ pip install --upgrade pip # Upgrade pip
$ pip install pytest # Install pytest


is_prime()関数のテスト

素数とは、1より大きい自然数で、1とそれ自身によってのみ割り切れるものです。

この関数は数字を受け取って、それが素数なら True を、そうでなければ False を返さなければなりません。

test_primes.py` に、最初のテストケースを追加しましょう。

def test_prime_low_number():
    assert is_prime(1) == False


Assert()` 文は、Python(そして他の多くの言語)において、ある条件が失敗した場合に即座にエラーを投げるキーワードです。このキーワードはテストを書くときに便利で、どの条件が失敗したかを正確に示すことができるからです。

1または1` よりも小さい数を入力した場合、それは素数ではありえない。

では、テストを実行してみましょう。コマンドラインに以下を入力します。

$ pytest


詳細な出力を得るには、pytest -v を実行します。仮想環境がまだ有効であることを確認してください(ターミナルの行頭に (env) が表示されているはずです)。

このような出力が表示されるはずです。

    def test_prime_low_number():
>       assert is_prime(1) == False
E       NameError: name 'is_prime' is not defined


test_primes.py:2: NameError
========================================================= 1 failed in 0.12 seconds =========================================================


NameError` が表示されるのは、まだ関数を作成していないためです。これは、赤-緑-リファクタリングのサイクルの「赤」の側面です。

もしシェルが色を表示するように設定されていれば、 pytest は失敗したテストを赤色でログに記録することもできます。それでは、このテストに合格するように primes.py ファイルにコードを追加してみましょう。

def is_prime(num):
    if num == 1:
        return False


Note: 一般的に、テストをコードとは別のファイルに保存することは良い習慣です。コードベースが大きくなったときの可読性の向上と関心事の分離は別として、テストの開発者をコードの内部動作から遠ざけることにもなります。したがって、テストは他の開発者が使うのと同じようにアプリケーションのインターフェイスを使用します。

では、もう一度 pytest を実行してみましょう。このような出力が表示されるはずです。

=========================================================== test session starts ============================================================
platform darwin -- Python 3.7.3, pytest-4.4.1, py-1.8.0, pluggy-0.9.0
rootdir: /Users/marcus/stackabuse/test-driven-development-with-pytest/primes
plugins: cov-2.6.1
collected 1 item


test_primes.py .                                                                                                                     [100%]


========================================================= 1 passed in 0.04 seconds =========================================================


最初のテストは成功しました! 私たちは1が素数でないことを知っていますが、定義上0も素数ではありませんし、どんな負の数もそうです。

このことを反映させるために、アプリケーションをリファクタリングして is_prime() を次のように変更します。

def is_prime(num):
    # Prime numbers must be greater than 1
    if num < 2:
        return False


もう一度 pytest を実行すると、テストはまだ合格しています。

次に、素数のテストケースを追加してみましょう。test_primes.pyで、最初のテストケースの後に以下を追加してください。

def test_prime_prime_number():
    assert is_prime(29)


そして pytest を実行すると、次のような出力が表示されます。

    def test_prime_prime_number():
>       assert is_prime(29)
E       assert None
E        +  where None = is_prime(29)


test_primes.py:9: AssertionError
============================================================= warnings summary =============================================================
test_primes.py::test_prime_prime_number
  /Users/marcus/stackabuse/test-driven-development-with-pytest/primes/test_primes.py:9: PytestWarning: asserting the value None, please use "assert is None"
    assert is_prime(29)


-- Docs: https://docs.pytest.org/en/latest/warnings.html
============================================== 1 failed, 1 passed, 1 warnings in 0.12 seconds ==============================================


pytest` コマンドは私たちが書いた 2 つのテストを実行することに注意してください。

新しいケースは、数が素数かどうかを実際に計算していないので、失敗します。is_prime()関数は、他の関数と同じように 1 より大きい数に対してデフォルトでNone` を返します。

出力はまだ失敗するか、出力から赤が見えます。

では、ある数字が素数かそうでないかを判断する方法を考えてみましょう。最も単純な方法は、2から1つ小さい数までループさせ、その数を反復の現在値で割ることでしょう。

これをより効率的にするために、2から平方根までの数字を割ってチェックすることができます。

割り算の余りがなければ、1でもそれ自身でもない除数を持つことになり、素数でないことになる。ループの中で約数を見つけないなら、それは素数でなければならない。

新しいロジックで is_prime() を更新してみましょう。

import math


def is_prime(num):
    # Prime numbers must be greater than 1
    if num < 2:
        return False
    for n in range(2, math.floor(math.sqrt(num) + 1)):
        if num % n == 0:
            return False
    return True


ここで pytest を実行して、テストが成功するかどうかを確認します。

=========================================================== test session starts ============================================================
platform darwin -- Python 3.7.3, pytest-4.4.1, py-1.8.0, pluggy-0.9.0
rootdir: /Users/marcus/stackabuse/test-driven-development-with-pytest/primes
plugins: cov-2.6.1
collected 2 items


test_primes.py ..                                                                                                                    [100%]


========================================================= 2 passed in 0.04 seconds =========================================================


パスしました。この関数は素数を得ることも、低い数を得ることもできることがわかりました。1より大きい合成数に対して False を返すことを確認するテストを追加してみましょう。

test_primes.py に以下のテストケースを追加してください。

def test_prime_composite_number():
    assert is_prime(15) == False


pytest` を実行すると、次のような出力が表示されます。

=========================================================== test session starts ============================================================
platform darwin -- Python 3.7.3, pytest-4.4.1, py-1.8.0, pluggy-0.9.0
rootdir: /Users/marcus/stackabuse/test-driven-development-with-pytest/primes
plugins: cov-2.6.1
collected 3 items


test_primes.py ...                                                                                                                   [100%]


========================================================= 3 passed in 0.04 seconds =========================================================


Testing sum_of_primes()

is_prime()`と同様に、この関数の結果について考えてみましょう。もし、この関数に空のリストが与えられたら、合計は0になるはずである。

これは、この関数が有効な入力に対して常に値を返すべきことを保証しています。その後、数値のリストに含まれる素数だけを足し算することをテストしたいと思います。

最初の失敗するテストを書きましょう。以下のコードを test_primes.py の最後に追加してください。

def test_sum_of_primes_empty_list():
    assert sum_of_primes([]) == 0


pytestを実行すると、おなじみのNameErrorが発生します。これは、まだ関数を定義していないからです。primes.py ファイルに、与えられたリストの合計を返す新しい関数を追加しましょう。

def sum_of_primes(nums):
    return sum(nums)


これで pytest を実行すると、すべてのテストがパスすることがわかります。次のテストでは、素数のみが追加されることを確認する必要があります。

素数と合成数を混ぜて、素数だけを足す関数にすることを期待します。

def test_sum_of_primes_mixed_list():
    assert sum_of_primes([11, 15, 17, 18, 20, 100]) == 28


今テストしているリストの素数は 11 と 17 で、足すと 28 になります。

pytestを実行して、新しいテストが失敗することを検証してみましょう。では、素数のみが加算されるようにsum_of_primes()` を変更してみましょう。

リスト内包で素数をフィルタリングしてみましょう。

def sum_of_primes(nums):
    return sum([x for x in nums if is_prime(x)])


いつものように pytest を実行して、失敗していたテストを修正したことを確認します。

完了したら、コードカバレッジをチェックしましょう。

$ pytest --cov=primes


このパッケージの場合、コードカバレッジは 100% です! もしそうでなければ、もう少しテストを追加して、テスト計画が完全なものであることを確認しましょう。

例えば、is_prime()関数にfloat値が与えられたら、エラーを投げるでしょうか?この is_prime() メソッドは、素数は自然数でなければならないという規則を強制するものではなく、1より大きいかどうかだけをチェックするものです。

コードカバレッジは完璧でも、実装された機能がすべての状況で正しく動作するとは限りません。

高度な例 インベントリマネージャを書く

TDD の基本を理解したところで、テストをより効率的に記述するための pytest の便利な機能に深く潜ってみましょう。

先ほどと同じように、基本的な例では inventory.py とテストファイルである test_inventory.py の2つがメインファイルとなります。

特長とテスト計画

ある洋服店では、商品の管理を紙からオーナーが購入した新しいコンピュータに移行したいと考えています。

オーナーは、多くの機能を望んでいますが、次のような作業をすぐに実行できるソフトウェアで満足しています。

  • 最近買ったナイキのスニーカーを10個記録してください。それぞれ50ドルの価値がある。
  • それぞれ70ドルするアディダスのスウェットパンツを5つ追加します。
  • 彼女は顧客がナイキのスニーカーの2を購入することを期待しています。
  • 彼女は、別の顧客がスウェットパンツの1つを購入することを期待しています。

これらの要件を使用して、最初の統合テストを作成することができます。テストを書く前に、入力と出力、関数シグネチャ、その他のシステム設計要素を把握するために、小さなコンポーネントを少し具体化しましょう。

在庫の各アイテムには、名前、価格、数量が設定されます。新しいアイテムを追加したり、既存のアイテムに在庫を追加したり、もちろん在庫を削除したりすることができます。

インベントリオブジェクトをインスタンス化するとき、ユーザはlimitを提供する必要があります。このlimitはデフォルトで 100 になっています。最初のテストは、オブジェクトをインスタンス化するときにlimitをチェックすることです。制限を超えないようにするために、total_items` カウンタを記録しておく必要があります。初期化されたとき、これは 0 になっているはずです。

Nikeのスニーカーを10個、Adidasのスウェットパンツを5個、システムに追加する必要があります。namepricequantityを受け取るadd_new_stock()` メソッドを作成しましょう。

インベントリオブジェクトにアイテムを追加できることをテストしましょう。負の数量を持つアイテムを追加することはできないはずで、このメソッドは例外を発生させるはずです。このメソッドは例外を発生させる必要があります。

そのため、 remove_stock() メソッドも必要でしょう。この関数には、在庫の name と削除するアイテムの quantity が必要です。もし削除される数量が負であったり、ストックの合計数量が 0 以下になる場合は、このメソッドは例外を発生させなければなりません。さらに、指定された name が在庫にない場合、このメソッドは例外を発生させなければなりません。

第1回テスト

最初にテストを行う準備をすることは、私たちのシステムを設計するのに役立っています。まずは最初の統合テストを作ってみましょう。

def test_buy_and_sell_nikes_adidas():
    # Create inventory object
    inventory = Inventory()
    assert inventory.limit == 100
    assert inventory.total_items == 0


# Add the new Nike sneakers
    inventory.add_new_stock('Nike Sneakers', 50.00, 10)
    assert inventory.total_items == 10


# Add the new Adidas sweatpants
    inventory.add_new_stock('Adidas Sweatpants', 70.00, 5)
    assert inventory.total_items == 15


# Remove 2 sneakers to sell to the first customer
    inventory.remove_stock('Nike Sneakers', 2)
    assert inventory.total_items == 13


# Remove 1 sweatpants to sell to the next customer
    inventory.remove_stock('Adidas Sweatpants', 1)
    assert inventory.total_items == 12


すべてのアクションで、インベントリの状態についてアサーションを行います。アクションが完了した後にアサーションを行うのが最善で、デバッグするときに最後に行われたステップを知ることができます。

pytestを実行すると、Inventoryクラスが定義されていないため、NameError` で失敗するはずです。

それでは、ユニットテストから始めて、リミットパラメータをデフォルトで100に設定した Inventory クラスを作成してみましょう。

def test_default_inventory():
    """Test that the default limit is 100"""
    inventory = Inventory()
    assert inventory.limit == 100
    assert inventory.total_items == 0


そして、クラスそのものを作成します。

class Inventory:
    def __init__(self, limit=100):
        self.limit = limit
        self.total_items = 0


メソッドに移る前に、オブジェクトがカスタムリミットで初期化され、正しく設定されることを確認したいと思います。

def test_custom_inventory_limit():
    """Test that we can set a custom limit"""
    inventory = Inventory(limit=25)
    assert inventory.limit == 25
    assert inventory.total_items == 0


統合は失敗し続けますが、このテストはパスします。

什器備品

最初の2つのテストでは、始める前に Inventory オブジェクトをインスタンス化する必要がありました。おそらく、今後のすべてのテストでも同じことをしなければならないでしょう。これは少し繰り返しになります。

この問題を解決するために、フィクスチャを使用することができます。フィクスチャとは既知の固定された状態のことで、 それに対してテストを実行することで結果の再現性を確保します。

テストは、互いに分離して実行するのがよい方法です。あるテストケースの結果が、別のテストケースの結果に影響を及ぼしてはいけません。

最初のフィクスチャとして、在庫のない Inventory オブジェクトを作成しましょう。

test_inventory.py とします。

import pytest


@pytest.fixture
def no_stock_inventory():
    """Returns an empty inventory that can store 10 items"""
    return Inventory(10)


pytest.fixture` デコレータを使用していることに注意してください。テストのために、在庫の上限を 10 に減らすことができます。

このフィクスチャを使用して、 add_new_stock() メソッドのテストを追加してみましょう。

def test_add_new_stock_success(no_stock_inventory):
    no_stock_inventory.add_new_stock('Test Jacket', 10.00, 5)
    assert no_stock_inventory.total_items == 5
    assert no_stock_inventory.stocks['Test Jacket']['price'] == 10.00
    assert no_stock_inventory.stocks['Test Jacket']['quantity'] == 5


関数の名前はテストの引数であり、フィクスチャを適用するために同じ名前でなければならないことに注意してください。そうでなければ、通常のオブジェクトのように使うことになります。

在庫が追加されたことを確認するために、これまでに格納されたアイテムの合計よりも少し多めにテストする必要があります。このテストを書くことで、在庫の価格と残量をどのように表示するかを検討する必要に迫られました。

pytestを実行すると、失敗が 2 回、成功が 2 回であることがわかります。次にadd_new_stock()` メソッドを追加します。

class Inventory:
    def __init__(self, limit=100):
        self.limit = limit
        self.total_items = 0
        self.stocks = {}


def add_new_stock(self, name, price, quantity):
        self.stocks[name] = {
            'price': price,
            'quantity': quantity
        }
        self.total_items += quantity


init__関数で stock オブジェクトが初期化されていることに気が付くでしょう。もう一度、pytest`を実行して、テストがパスしたことを確認してください。

パラメトライジングテスト

先ほど、add_new_stock() メソッドが入力の検証を行うと書きました。数量がゼロまたはマイナスの場合、あるいは在庫の制限を超えた場合は例外を発生させます。

さらにテストケースを追加して、try/except を使ってそれぞれの例外をキャッチすることも簡単にできます。これもまた繰り返しに感じます。

Pytestにはパラメトリック関数というものがあり、これを使うと1つの関数で複数のシナリオをテストすることができます。入力の検証がうまくいくことを確認するために、パラメータ化されたテスト関数を書いてみましょう。

@pytest.mark.parametrize('name,price,quantity,exception', [
    ('Test Jacket', 10.00, 0, InvalidQuantityException(
        'Cannot add a quantity of 0. All new stocks must have at least 1 item'))
])
def test_add_new_stock_bad_input(name, price, quantity, exception):
    inventory = Inventory(10)
    try:
        inventory.add_new_stock(name, price, quantity)
    except InvalidQuantityException as inst:
        # First ensure the exception is of the right type
        assert isinstance(inst, type(exception))
        # Ensure that exceptions have the same message
        assert inst.args == exception.args
    else:
        pytest.fail("Expected error but found none")


このテストでは、在庫を追加しようとして例外を取得し、それが正しい例外であることをチェックします。もし例外が発生しなければ、テストは失敗です。このシナリオでは、else 節が非常に重要です。この句がなければ、例外が投げられなかったとしても合格とみなしてしまいます。そのため、このテストは誤検出をすることになります。

関数にパラメータを追加するために、 pytest デコレータを使用します。最初の引数には、すべてのパラメータ名を表す文字列を指定します。2 番目の引数はタプルのリストで、それぞれのタプルがテストケースとなります。

pytestを実行すると、InvalidQuantityExceptionが定義されていないため、テストが失敗するのがわかります。inventory.py に戻って、 Inventory クラスの上に新しい例外を作成しましょう。

class InvalidQuantityException(Exception):
    pass


そして、 add_new_stock() メソッドを変更します。

def add_new_stock(self, name, price, quantity):
        if quantity <= 0:
            raise InvalidQuantityException(
                'Cannot add a quantity of {}. All new stocks must have at least 1 item'.format(quantity))
        self.stocks[name] = {
            'price': price,
            'quantity': quantity
        }
        self.total_items += quantity


pytest` を実行すると、直近のテストがパスすることが確認できます。次に、2 番目のエラーテストケースを追加しましょう。インベントリが保存できない場合に例外が発生します。テストを以下のように変更してください。

@pytest.mark.parametrize('name,price,quantity,exception', [
    ('Test Jacket', 10.00, 0, InvalidQuantityException(
        'Cannot add a quantity of 0. All new stocks must have at least 1 item')),
    ('Test Jacket', 10.00, 25, NoSpaceException(
        'Cannot add these 25 items. Only 10 more items can be stored'))
])
def test_add_new_stock_bad_input(name, price, quantity, exception):
    inventory = Inventory(10)
    try:
        inventory.add_new_stock(name, price, quantity)
    except (InvalidQuantityException, NoSpaceException) as inst:
        # First ensure the exception is of the right type
        assert isinstance(inst, type(exception))
        # Ensure that exceptions have the same message
        assert inst.args == exception.args
    else:
        pytest.fail("Expected error but found none")


まったく新しい関数を作る代わりに、この関数を少し修正して新しい例外を受け取り、デコレータに別のタプルを追加します! これで、ひとつの関数に対してふたつのテストが実行されるようになりました。

パラメータ化された関数は、新しいテストケースの追加にかかる時間を短縮します。

inventory.pyでは、まず新しい例外をInvalidQuantityException` の下に追加します。

class NoSpaceException(Exception):
    pass


そして、 add_new_stock() メソッドを変更します。

def add_new_stock(self, name, price, quantity):
    if quantity <= 0:
        raise InvalidQuantityException(
            'Cannot add a quantity of {}. All new stocks must have at least 1 item'.format(quantity))
    if self.total_items + quantity > self.limit:
        remaining_space = self.limit - self.total_items
        raise NoSpaceException(
            'Cannot add these {} items. Only {} more items can be stored'.format(quantity, remaining_space))
    self.stocks[name] = {
        'price': price,
        'quantity': quantity
    }
    self.total_items += quantity


pytest` を実行して、新しいテストケースもパスすることを確認します。

パラメータ化された関数でフィクスチャを使うことができます。空の在庫フィクスチャを使用するようにテストをリファクタリングしてみましょう。

def test_add_new_stock_bad_input(no_stock_inventory, name, price, quantity, exception):
    try:
        no_stock_inventory.add_new_stock(name, price, quantity)
    except (InvalidQuantityException, NoSpaceException) as inst:
        # First ensure the exception is of the right type
        assert isinstance(inst, type(exception))
        # Ensure that exceptions have the same message
        assert inst.args == exception.args
    else:
        pytest.fail("Expected error but found none")


先ほどと同様、これは関数の名前を使った単なる引数です。重要なのは、パラメトリック・デコレーターでこれを除外することです。

もう少しコードを見てみると、新しいストックを追加するために2つのメソッドが必要な理由はありません。エラーと成功をひとつの関数でテストできるのです。

test_add_new_stock_bad_input()test_add_new_stock_success()` を削除して、新しい関数を追加してみましょう。

@pytest.mark.parametrize('name,price,quantity,exception', [
    ('Test Jacket', 10.00, 0, InvalidQuantityException(
        'Cannot add a quantity of 0. All new stocks must have at least 1 item')),
    ('Test Jacket', 10.00, 25, NoSpaceException(
        'Cannot add these 25 items. Only 10 more items can be stored')),
    ('Test Jacket', 10.00, 5, None)
])
def test_add_new_stock(no_stock_inventory, name, price, quantity, exception):
    try:
        no_stock_inventory.add_new_stock(name, price, quantity)
    except (InvalidQuantityException, NoSpaceException) as inst:
        # First ensure the exception is of the right type
        assert isinstance(inst, type(exception))
        # Ensure that exceptions have the same message
        assert inst.args == exception.args
    else:
        assert no_stock_inventory.total_items == quantity
        assert no_stock_inventory.stocks[name]['price'] == price
        assert no_stock_inventory.stocks[name]['quantity'] == quantity


このテスト関数は、まず既知の例外をチェックし、何も見つからなければ、追加が期待通りに行われたことを確認します。別の test_add_new_stock_success() 関数は、タプルのパラメータを介して実行されるだけです。成功した場合に例外がスローされることを期待していないので、例外として None を指定します。

在庫管理担当者のまとめ

より高度な pytest を使用することで、TDD で remove_stock 関数を素早く開発することができます。inventory_test.py`にあります。

# The import statement needs one more exception
from inventory import Inventory, InvalidQuantityException, NoSpaceException, ItemNotFoundException


# ...
# Add a new fixture that contains stocks by default
# This makes writing tests easier for our remove function
@pytest.fixture
def ten_stock_inventory():
    """Returns an inventory with some test stock items"""
    inventory = Inventory(20)
    inventory.add_new_stock('Puma Test', 100.00, 8)
    inventory.add_new_stock('Reebok Test', 25.50, 2)
    return inventory


# ...
# Note the extra parameters, we need to set our expectation of
# what totals should be after our remove action
@pytest.mark.parametrize('name,quantity,exception,new_quantity,new_total', [
    ('Puma Test', 0,
     InvalidQuantityException(
         'Cannot remove a quantity of 0. Must remove at least 1 item'),
        0, 0),
    ('Not Here', 5,
     ItemNotFoundException(
         'Could not find Not Here in our stocks. Cannot remove non-existing stock'),
        0, 0),
    ('Puma Test', 25,
     InvalidQuantityException(
         'Cannot remove these 25 items. Only 8 items are in stock'),
     0, 0),
    ('Puma Test', 5, None, 3, 5)
])
def test_remove_stock(ten_stock_inventory, name, quantity, exception,
                      new_quantity, new_total):
    try:
        ten_stock_inventory.remove_stock(name, quantity)
    except (InvalidQuantityException, NoSpaceException, ItemNotFoundException) as inst:
        assert isinstance(inst, type(exception))
        assert inst.args == exception.args
    else:
        assert ten_stock_inventory.stocks[name]['quantity'] == new_quantity
        assert ten_stock_inventory.total_items == new_total


そして inventory.py ファイルに、存在しないストックを変更しようとしたときのための新しい例外を作成します。

class ItemNotFoundException(Exception):
    pass


そして、このメソッドを Inventory クラスに追加します。

def remove_stock(self, name, quantity):
    if quantity <= 0:
        raise InvalidQuantityException(
            'Cannot remove a quantity of {}. Must remove at least 1 item'.format(quantity))
    if name not in self.stocks:
        raise ItemNotFoundException(
            'Could not find {} in our stocks. Cannot remove non-existing stock'.format(name))
    if self.stocks[name]['quantity'] - quantity <= 0:
        raise InvalidQuantityException(
            'Cannot remove these {} items. Only {} items are in stock'.format(
                quantity, self.stocks[name]['quantity']))
    self.stocks[name]['quantity'] -= quantity
    self.total_items -= quantity


pytest` を実行すると、統合テストと他のすべてのテストがパスしていることが確認できるはずです!

結論

テスト駆動開発とは、システムの設計を導くためにテストを使用するソフトウェア開発プロセスである。TDDは、私たちが実装しなければならないすべての機能に対して、失敗するテストを書き、テストに合格するために最小限のコードを追加し、最後にコードをよりきれいにするためにリファクタリングすることを義務付けています。

このプロセスを可能かつ効率的にするために、私たちは pytest – 自動テストツール を活用しました。pytest` を使用すると、テストをスクリプト化することができ、コードを変更するたびに手動でテストする時間を節約することができます。

ユニットテストは個々のモジュールが期待通りに動作することを確認するために使用され、一方、統合テストはモジュールの集合が期待通りに相互運用されることを確認するために使用されます。pytest` ツールと TDD 方法論の両方が、両方のテストタイプを使用することを可能にしており、開発者は両方を使用することを推奨されます。

TDDでは、システムの入力と出力について考えなければならないので、全体的な設計を考えることになります。テストを書くことで、変更後のプログラムの機能に対する信頼性が高まるなどの利点もあります。TDDは繰り返しの多いプロセスを要求しますが、pytestのような自動テストスイートを活用することで効率的に行うことができます。フィクスチャやパラメータ化された関数などの機能により、要求に応じて素早くテストケースを書くことができます。

タイトルとURLをコピーしました