10
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

Testing Python Applications with Pytest

Last updated at Posted at 2018-03-06

###Testing Python Applications with Pytest

(注 本Tutorialは[Testing Python Appliations with Pytest]Kevin Ndung'u の記事の一部を本人に許可をとり邦訳させていただきました。)

このチュートリアルは、あなたが実際にPytestが使えるようになることを目指して書いています。

####Introduction
アプリケーションをテストする事は多くの開発者にとって必須のスキルになっています。
Pythonコミュニティにおいてもテストを推奨しておりPythonは標準ライブラリの中にテストツールをもっています。Pythonのエコシステム全体でもたくさんのテストツールがあります。
Pytestは使いやすさ、複雑なテストへの対応で際立っています。

####準備

pyenv等の隔離環境でpytestのinstallを推奨します。
python3を対象に記載します。

$mkdir pytest_project
$cd pytest_project
$python3 -m venv pytest-env
$source pytest-env/bin/activate
$pip install pytest

####Pytestの基礎

最初に簡単なテストからスタートしましょう。
ちなみにPytestはtestモジュールをfile名称が、test_ ではじめるか、_test.pyで終わるかで認識します。
今回の例ではtest_capitalize.pyという名称でテストコードを作成していきます。
そのモジュール内でcapital_caseという名称のfunctionを作成しています。
その関数は、stringを受け取り、それを大文字にしてリターンします。
また同じモジュールでテストも書いています。
test_capital_case はtest対象の関数がどのように機能したかを確かめます。
pytestは関数の最初にtest_とついているものをテスト関数と認識します。

test_capitalize.py

def capital_case(x):
    return x.capitalize()

def test_capital_case():
    assert capital_case('semaphore') == 'Semaphore'

pytestはunittestと比べてシンプルなaseert文を使っていてわかりやすいですね。
testの実行はpytestコマンドを実行します。

$pytest

テストはパスするはずです。
するどい人は、この関数にはバグになりそうな箇所があることに気づいているでしょう。
capital_caseの引数に対して、typeをチェックしてstringであることを確認していませんね。
こういったケースの場合はカスタムでエラーメッセージをつくっておき、そのエラーがおこった際に表示させたいところです。
ではテストでこういったケースを想定してみましょう。

test_capitalize.py

import pytest

def test_capital_case():
    assert capital_case('semaphore') == 'Semaphore'

def test_raises_exception_on_non_string_arguments():
    # withブロック内で引数で指定したErrorが発生するとテストがパスされる
    with pytest.raises(TypeError):
        capital_case(9)

ここでの主な追加ポイントは pytest.raises のhelper関数です。
pytest.raisesは、capital_case関数の引数がstringかどうか確認してTypeErrorをだす場面で役に立ちます。この場合でいうと、Typeエラーが発生するとテストをパスするテストコードを作成可能にしています。

$pytest

この時点でテストをはしらせると以下のようなエラーがでて失敗します。

def capital_case(x):
>       return x.capitalize()
E       AttributeError: 'int' object has no attribute 'capitalize'

このようなstring以外の引数がくるケースに対応していないことを確認したので、先に進んでコードを修正します。

capital_case関数では、引数がstringかstringのsubclassかをcapitalize関数を呼ぶ前にチェックすべきです。もしstringでなければ、TypeErrorをcustomしたエラーメッセージとともにだすべきでしょう。

test_capitalize.py

def capital_case(x):
    if not isinstance(x, str):
        raise TypeError('Please provide a string argument')
      return x.capitalize()

さて、もう一度テストを実行してみましょう。今度は通過するはずです。

####Pytestのフィクスチャを使う
では、pytestの進んだ機能をつかっていきましょう。そのために小さなプロジェクトを作成します。
財布のアプリケーションで、ユーザーは財布からお金をだしいれ可能です。
そういった挙動は、spend_cashとadd_cashの二つのinstanceメソッドをもつクラスでモデル化できるでしょう。
テストファーストでやってみましょう。
テストファーストとはテストを先に記述することを意味します。
こうすることにより仕様が明確になり、テストを通るコードを意識して記述できるようになります。
テストを通るコードであるということは、すなわち仕様通りのコードを実装することに繋がります。
まずtest_wallet.pyというファイルを作ってください。
test_wallet.pyは以下の内容になります。

test_wallet.py

import pytest
from wallet import Wallet, InsufficientAmount


def test_default_initial_amount():
    wallet = Wallet()
    assert wallet.balance == 0

def test_setting_initial_amount():
    wallet = Wallet(100)
    assert wallet.balance == 100

def test_wallet_add_cash():
    wallet = Wallet(10)
    wallet.add_cash(90)
    assert wallet.balance == 100

def test_wallet_spend_cash():
    wallet = Wallet(20)
    wallet.spend_cash(10)
    assert wallet.balance == 10

def test_wallet_spend_cash_raises_exception_on_insufficient_amount():
    wallet = Wallet()
    with pytest.raises(InsufficientAmount):
        wallet.spend_cash(100)

大事なことを一番にしておきましょう。
WalletクラスとInsufficentAmount(ユーザーが持っているお金より多く使おうとした時におきる例外)をimportします。
Walletクラスを初期化する際は、初期の所持金はデフォルト状態で0とします。
初期化時に金額を引数に明示的に設定すると、初期の所持金はその額に設定されているべきです。
それでは実装したいメソッドのテストに関心をうつしましょう。
我々は、add_cashメソッドが財布に追加したお金が正確に所持金に反映されているかをテストします。
また一方でspend_cashメソッドについては、使ったお金が所持金からひかれているかどうかを確かめます。
そして所持金以上のお金は使えません。
もしそのようなことをしようと思ったらInsufficientAmount例外があげられるべきです。

この時点でテストをはしらせてみましょう。きっと失敗しますね。

$pytest test_wallet.py

まだWalletクラスをつくっていませんので、、
これからWalletクラスをコードに落とし込んでいきます。
wallet.pyという名称でfileを作成してください。
そのfileは下記のようになります。

wallet.py

class InsufficientAmount(Exception):
    pass


class Wallet(object):

    def __init__(self, initial_amount=0):
        self.balance = initial_amount

    def spend_cash(self, amount):
        if self.balance < amount:
            raise InsufficientAmount('Not enough available to spend {}'.format(amount))
        self.balance -= amount

    def add_cash(self, amount):
        self.balance += amount

まず最初にInsufficientAmountという名称でcustom exceptionを定義します。
そのexceptionは所持金以上に使うと例外をなげるためにつくります。
Walletクラスがそれに続きます。
クラスのコンストラクターは所持金の初期値を引数に取ります。
引数でその初期値がなければデフォルトとして0になります。
初期値は所持金として設定されます。

spend_cashメソッドでは、最初に所持金をチェックします。
もし所持金が使用したい金額よりすくない場合は、InsufficientAmountで定義した、わかりやすいエラーメッセージをなげます。
add_cashメソッドの実装は以下の通りですが、単純に現在の所持金に追加された金額をたしています。

ここまできたらテストに戻ります。
今度はpassするはずです。


$pytest -q test_wallet.py

.....
5 passed in 0.01 seconds

####フィクスチャを利用するリファクタリング
それぞれのテストでクラスの初期化が繰り返し行われていたことに気づいているかもしれません。
pytestのフィクスチャの役割がここになります。
フィクスチャは個別のテストが実行される前に、はしらせておきたいコードの設定を簡単にできるようにしており、テストの実行に必要な環境を準備します。

フィクスチャ機能は@pytest.fixtureデコレーターをつけることで作成できます。
フィクスチャが必要なテスト関数はフィクスチャを引数としてうけとります。
例えば、walletというフィクスチャを受け取るテストは引数に例えばwalletという名称でフィクスチャを引数でとります。
実際にどのように動くかみてみましょう。
さきほどのテストをフィクスチャを利用するためにリファクタリングしてみたいと思います。

test_wallet.py

import pytest
from wallet import Wallet, InsufficientAmount

@pytest.fixture
def empty_wallet():
    '''Returns a Wallet instance with a zero balance'''
    return Wallet()

@pytest.fixture
def wallet():
    '''Returns a Wallet instance with a balance of 20'''
    return Wallet(20)

def test_default_initial_amount(empty_wallet):
    assert empty_wallet.balance == 0

def test_setting_initial_amount(wallet):
    assert wallet.balance == 20

def test_wallet_add_cash(wallet):
    wallet.add_cash(80)
    assert wallet.balance == 100

def test_wallet_spend_cash(wallet):
    wallet.spend_cash(10)
    assert wallet.balance == 10

def test_wallet_spend_cash_raises_exception_on_insufficient_amount(empty_wallet):
    with pytest.raises(InsufficientAmount):
        empty_wallet.spend_cash(100)

リファクタリングしたテストでは、重複したコードがだいぶ減っているかと思います。
二つのフィクスチャ関数を定義していますね。
walletとempty_walletです。
それらはよばれた際にそれぞれ異なる値でWalletクラスを初期化することが役割です。

最初のテストではempty_walletフィクスチャを使って所持金を0でwalletインスタンスを作成しています。続く3つのテストではwallet fixtureを使って所持金20で初期化しています。
最後のテストはempty_walletをうけとっています。
リファクタリング後のテストは、フィクスチャを、テスト関数内で作成されたように使うことが可能です。

順調に機能するかテストしましょう。

フィクスチャの利用で重複を省いています。いくつかのテストでひとかたまりのコードで繰り返しのケースがあると気づけばフィクスチャとして使う良い候補かもしれません。

####フィクスチャの指針
フィクスチャを利用してテストする上でのいくつかのアドバイスです。
それぞれのテストであらたに初期化されたWalletインスタンスがあたえられます。
そして他のテストでつかったものはありませんでしたね。
フィクスチャにdocstringを書くことは良い習慣です。
全ての利用可能なフィスチャを確認するには以下のコマンドをはしらせてください。

$pytest --fixtures

このリストはpytestが内部でもつフィクスチャです。
われわれがつくったカスタムのフィクスチャも同様です。
docstringsはフィクスチャの説明としてつかいます。

wallet
Returns a Wallet instance with a balance of 20
empty_wallet
Returns a Wallet instance with a zero balance

####パラメータ化されたテスト
Walletクラスのメソッドを単体でテストしてきましたが、つぎのステップです。
我々はいくつかの値の組み合わせでこれらのメソッドをテストしていきます。
これは、もし私が初期の所持金が30だった場合、20使って、100財布に足して、それから50使ったら、所持金はいくらでしょう?といった質問と回答のようなものです。

想像してみてください、こういったステップをテストコードに落とし込むのはうんざりしそうです。
pytestはきわめて聡明な解決策を提供します。
パラメータ化されたテスト関数です。

上記のような、ひとつのシナリオを設定して下記のようにテストを書くことができます。

test_wallet.py

@pytest.mark.parametrize("earned,spent,expected", [
    (30, 10, 20),
    (20, 2, 18),
])
def test_transactions(earned, spent, expected):
    my_wallet = Wallet()
    my_wallet.add_cash(earned)
    my_wallet.spend_cash(spent)
    assert my_wallet.balance == expected

@pytest.mark.parametrizeは違うシナリオでも全てひとつの関数で書くことができるようにしてくれます。デコレータでテストファンクションに渡す引数の名前やその名前に関連付けた引数のリストを指定できます。
@pytest.mark.parametrizeでデコレートされたテスト関数はパラメータのセットごとに実行されます。

たとえば最初に所持金が30の状態で設定でテストをします、その状態で10ドル使えば所持金が20になることを期待します。二回目のテスト実行では二つ目のパラメータのセットをとります。
テストコードでこれらのパラメータを使えるのです。

これはテストシナリオを実行する事を支援してくれます。
例えば、、

私の財布は最初所持金0でした。
財布に30のお金を追加しました。
それから10つかいました
2つの操作で残りは20となるはずです。

重複コードを書く事なく、違う組み合わせの値をうまくテストしています。

####フィクスチャと組み合わせテスト

同じ事の繰り返しを少なくするため、さらに発展させてフィクスチャとパラメータ化テストを組み合わせていきましょう。デモンストレーションとしてさきほど実装したwalletの初期化コードをフィクスチャにしていきます。
下記のようなコードになると思います。

test_wallet.py

# 追記
@pytest.fixture
def my_wallet():
    '''Returns a Wallet instance with a zero balance'''
    return Wallet()

@pytest.mark.parametrize("earned,spent,expected", [(30, 10, 20),(20, 2, 18),])
def test_transactions(my_wallet, earned, spent, expected):
    my_wallet.add_cash(earned)
    my_wallet.spend_cash(spent)
    assert my_wallet.balance == expected

empyt_walletとまったく同じように、my_walletという新しいフィクスチャをつくっています。
my_walletは所持金0を返します。フィクスチャとしてもパラメータテストとしても使えるように最初の引数でフィクスチャを残りの引数でパラメータをとります。

そのトランザクション(お金の流れ)はwalletインスタンスにおいてフィクスチャによってあたえられた振る舞いがおこなわれるでしょう。

あなたはこのパターンをさらに別のパターンで試することができます。たとえばwalletインスタんが0でない所持金をもっていたりお金をためたり使ったりする組み合わせをつくったりといったことです。

10
6
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
10
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?