Source From Here
PrefaceTesting applications has become a standard skill set required for any competent developer today. The Python community embraces testing, and even the Python standard library has good inbuilt tools to support testing. In the larger Python ecosystem, there are a lot of testing tools. Pytest stands out among them due to its ease of use and its ability to handle increasingly complex testing needs.
This tutorial will demonstrate how to write tests for Python code with pytest, and how to utilize it to cater for a wide range of testing scenarios.
Prerequisites
This tutorial uses Python 3, and we will be working inside a virtualenv.
Fortunately for us, Python 3 has inbuilt support for creating virtual environments. To create and activate a virtual environment for this project, let’s run the following commands:
This creates a virtual environment called pytest-env in our working directory. To begin using the virtualenv, we need to activate it as follows:
As long as the virtualenv is active, any packages we install will be installed in our virtual environment, rather than in the global Python installation. To get started, let’s install pytest in our virtualenv:
Basic Pytest Usage
We will start with a simple test. Pytest expects our tests to be located in files whose names begin with test_ or end with _test.py.
Let’s create a file called test_capitalize.py, and inside it, we will write a function called capital_case which should take a string as its argument and should return a capitalized version of the string. We will also write a test, test_capital_case to ensure that the function does what it says. We’ll prefix our test function names with test_, since this is what pytest expects our test functions to be named:
- test_capitalize.py
- def capital_case(x):
- return x.capitalize()
- def test_capital_case():
- assert capital_case('semaphore') == 'Semaphore'
We should see that our first test passes.
A keen reader will notice that our function could lead to a bug. It does not check the type of the argument to ensure that it is a string. Therefore, if we passed in a number as the argument to the function, it would raise an exception. We would like to handle this case in our function by raising a custom exception with a friendly error message to the user.
Let’s try to capture this in our test (Assert that a certain exception is raised):
- import pytest
- def capital_case(x):
- return x.capitalize()
- def test_capital_case():
- assert capital_case('semaphore') == 'Semaphore'
- def test_raises_exception_on_non_string_arguments():
- # Assert certain Exception being raised
- with pytest.raises(TypeError):
- capital_case(9)
Since we’ve verified that we have not handled such a case, we can go ahead and fix it.
In our capital_case function, we should check that the argument passed is a string or a string subclass before calling the capitalize function. If it is not, we should raise a TypeError with a custom error message.
- def capital_case(x):
- if not isinstance(x, str):
- raise TypeError('Please provide a string argument')
- return x.capitalize()
Using Pytest Fixtures
In the following sections, we will explore some more advanced pytest features. To do this, we will need a small project to work with. We will be writing a wallet application that enables its users to add or spend money in the wallet. It will be modeled as a class with two instance methods: spend_cash and add_cash.
We’ll get started by writing our tests first. Create a file called test_wallet.py in the working directory, and add the following contents:
- 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)
When we initialize the Wallet class, we expect it to have a default balance of 0. However, when we initialize the class with a value, that value should be set as the wallet’s initial balance.
Moving on to the methods we plan to implement, we test that the add_cash method correctly increments the balance with the added amount. On the other hand, we are also ensuring that the spend_cash method reduces the balance by the spent amount and that we can’t spend more cash than we have in the wallet. If we try to do so, an InsufficientAmount exception should be raised.
Running the tests at this point should fail since we have not created our Wallet class yet. We’ll proceed with creating it. Create a file called wallet.py, and we will add our Wallet implementation in it. The file should look as follows:
- 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
Refactoring our Tests with Fixtures
You may have noticed some repetition in the way we initialized the class in each test. This is where pytest fixtures come in. They help us set up some helper code that should run before any tests are executed, and are perfect for setting-up resources that are needed by the tests.
Fixture functions are created by marking them with the @pytest.fixture decorator. Test functions that require fixtures should accept them as arguments. For example, for a test to receive a fixture called wallet, it should have an argument with the fixture name, i.e. wallet.
Let’s see how this works in practice. We will refactor our previous tests to use test fixtures where appropriate:
- 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)
Utilizing fixtures helps us de-duplicate our code. If you notice a case where a piece of code is used repeatedly in a number of tests, that might be a good candidate to use as a fixture.
Some Pointers on Test Fixtures
Here are some pointers on using test fixtures:
Parametrized Test Functions
Having tested the individual methods in the Wallet class, the next step we should take is to test various combinations of these methods. This is to answer questions such as “If I have an initial balance of 30, and spend 20, then add 100, and later on, spend 50, how much should the balance be?”
As you can imagine, writing out those steps in the tests would be tedious, and pytest provides quite a delightful solution: Parametrized test functions
To capture a scenario like the one above, we can write a test:
- # 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
This elegantly helps us capture the scenario:
This is quite a succinct way to test different combinations of values without writing a lot of repeated code.
Combining Test Fixtures and Parametrized Test Functions
To make our tests less repetitive, we can go further and combine test fixtures and parametrize test functions. To demonstrate this, let’s replace the wallet initialization code with a test fixture as we did before. The end result will be:
- @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
沒有留言:
張貼留言