
pytest
and pytest-mock
. I provided commands for both python library installation options: pip and poetry.python -m pip install pytest
python -m pip install pytest-mock
poetry add pytest
poetry add pytest-mock
Test Pyramid
assert output == expect
) and the way of writing mocks.Code Coverage = (Number of lines of code executed)/(Total Number of lines of code in a system component) * 100
tests
folder that is outside of your src
folder..
├── docs # Documentation files (alternatively `doc`)
├── src # Source files (alternatively `lib` or `app`)
├── tests # Automated tests (alternatively `test`)
└── README.md
add
function of calc.py
file under src
folder# src/calc.py
def add(x, y):
"""Add Function"""
return x + y
test_calc.py
inside the tests
folder.The test file name should always start or end with test
. I prefer to keep the structure consistent as test_xxx.py
where xxx
where be py file name you are testing. You may read more about documentation on how test discovery works for pytest.test_add
When writing the test cases, just define a function starting with test
in the name. Similar as the file name, I prefer to keep it consistent as test_xxx
where xxx
is the function you are testing. This provides a very clear understanding for others.And this follows Arrange-Act-Assert pattern to structure the test content. Though this example is very simple and straightforward and we could replace it with one line assertion, but let's just use it for illustration purpose.output == expected
part), generally, you could use any of logical conditions you would put similar as you write if
statement.import pytest
from src.calc import add
def test_add():
# Arrange
a = 2
b = 5
expected = 7
# Act
output = add(a, b)
# Assert
assert output == expected
assert output == expected
not assert expected == output
. This doesn't make a difference in Terminal/CMD. But PyCharm will display it wrongly if we did it reversely.# run all tests
python -m pytest tests
# run single test file
python -m pytest tests/test_calc.py
# run single test case
python -m pytest tests/test_calc.py::test_add
poetry run
before any of the testing command.import pytest
from src.calc import add
@pytest.mark.parametrize("a,b,expected",
[(10, 5, 15),
(-1, 1, 0),
(-1, -1, -2)])
def test_add(a, b, expected):
assert add(a, b) == expected
@pytest.fixture
def employee_obj_helper():
"""
Test Employee Fixture
"""
obj = Employee(first='Corey', last='Schafer', pay=50000)
return obj
def test_employee_init(employee_obj):
employee_obj.first = 'Corey'
employee_obj.last = 'Schafer'
employee_obj.pay = 50000
def test_email(employee_obj):
assert employee_obj.email == 'Corey.Schafer@email.com'
def test_fullname(employee_obj):
assert employee_obj.fullname == 'Corey Schafer'
def sleep_awhile(duration):
"""sleep for couple of seconds"""
time.sleep(duration)
# some other processing steps
def test_sleep_awhile(mocker):
m = mocker.patch("src.example.time.sleep", return_value=None)
sleep_awhile(3)
m.assert_called_once_with(3)
mocker
as part of test case inputs so we can call mocker.patch
.time
module with a fake object that do nothing, by specifying the target as "src.example.time.sleep", meaning the time.sleep
function inside src/example.py
. Here comes the rule of thumb for mocking: Mock where it is used, and not where it's defined (source). Here, sleep
function doesn't return anything so we just define return_value
as None
.sleep_awhile
there is no output provided so we can't verify that. So how do we know the test case is written properly?Thus, we check whether the mock object has been called with correct input using assert_called_once_with
. And the test time should not be as long as 3 seconds.(should be <1s)Note, this function assert_called_once_with
is already an assertion function so we don't use assert
keyword again in front of it.Assertion functions for mock objects can also be assert_called(), assert_any_call(), assert_not_called()
etc, referring to the documentation.get_time_of_day
in src/example.py
as following to tell us what time of day it is now. It will return us the string of Night/Morning/Afternoon/Evening, depending on the hour range.# src/example.py
from datetime import datetime
def get_time_of_day():
"""return string Night/Morning/Afternoon/Evening depending on the hours range"""
time = datetime.now()
if 0 <= time.hour <6:
return "Night"
if 6 <= time.hour < 12:
return "Morning"
if 12 <= time.hour <18:
return "Afternoon"
return "Evening"
datetime
object and the returns of now
functionWe need to include mocker
as the function input. And we use mocker.patch
where "src.example.datetime"
refers to the object needs mocking. You may wonderly why it's not purely just "datetime.datetime"
.So "src.example" here refers to the file path, and ".datetime" refers to the library/the part being used within the get_time_of_day
function.And we use mock_obj.function.return_value
to define what kind of return we want to replace for the function. It could be many layers other than one like mock_obj.another_obj.function.return_value
.Here we fix the value return for datetime.now()
function to be 2pm, therefore, we are expecting an output of "Afternoon"
.import pytest
from datetime import datetime
from src.example import get_time_of_day
def test_get_time_of_day(mocker):
mock_now = mocker.patch("src.example.datetime")
mock_now.now.return_value = datetime(2016, 5, 20, 14, 10, 0)
assert get_time_of_day() == "Afternoon"
mocker
as the def test_get_time_of_day(datetime_obj, expect, mocker):
.And for input datetime_obj
, I try to cover every scenario of Night/Morning/Afternoon/Evening and particularly for the boundry conditions (0/6/12/18 hour).import pytest
from datetime import datetime
from src.example import get_time_of_day
@pytest.mark.parametrize(
"datetime_obj, expect",
[
(datetime(2016, 5, 20, 0, 0, 0), "Night"),
(datetime(2016, 5, 20, 1, 10, 0), "Night"),
(datetime(2016, 5, 20, 6, 10, 0), "Morning"),
(datetime(2016, 5, 20, 12, 0, 0), "Afternoon"),
(datetime(2016, 5, 20, 14, 10, 0), "Afternoon"),
(datetime(2016, 5, 20, 18, 0, 0), "Evening"),
(datetime(2016, 5, 20, 19, 10, 0), "Evening"),
],
)
def test_get_time_of_day(datetime_obj, expect, mocker):
mock_now = mocker.patch("src.example.datetime")
mock_now.now.return_value = datetime_obj
assert get_time_of_day() == expect
load_data
function loads the data and returns the data. We use time.sleep
to mimic the time taken e.g. loading data from database.# src/dataset.py
import time
def load_data():
time.sleep(4)
# loading data...
return {"key1":"val1", "key2":"val2"}
process_data()
function that loads the dataset and process it with certain steps (which were skipped here). Then we returns a processed result, here, assuming it as data["key1"]
.def process_data():
data = load_data()
# process the data in certain ways ...
processed_data = data["key1"]
return processed_data
Mock where it is used, and not where it's defined (source)
"src.example.load_data"
and not "src.dataset.load_data"
.def test_process_data(mocker):
mocker.patch("src.example.load_data", return_value={"key1": "valy", "key2": "val2"})
assert process_data() == "valy"
DBConnector
that initializes the connection to the database in __init__
and get
data from db based on the id
. # src/db_connection.py
import time
class DBConnector:
def __init__(self):
# setup some db connection
time.sleep(3)
pass
def get(self, id):
time.sleep(5)
return 'some data'
Engine
that includes the DBConnector as its attribute. class Engine:
def __init__(self):
self.connector = DBConnector()
def load_data(self):
data = self.connector.get(123)
print(data)
# do some processing
data = data + "xxx"
return data
Engine.load_data
? When mock __init__
function, we must put return_value
with None
. def test_engine_load_data(mocker):
mocker.patch("src.example.DBConnector.__init__",return_value = None)
mocker.patch("src.example.DBConnector.get",return_value = 'xyz')
output = Engine().load_data()
assert output == 'xyzxxx'
GET
request through requests
library. #src/employee.py
class Employee:
"""A sample Employee class"""
def __init__(self, first, last, pay):
self.first = first
self.last = last
self.pay = pay
def monthly_schedule(self, month):
response = requests.get(f'http://company.com/{self.last}/{month}')
if response.ok:
return response.text
else:
return 'Bad Response!'
ok
and text
as following. # test_employee.py
import pytest
from src.employee import Employee
emp_1 = Employee("Corey", "Schafer", 50000)
def test_mock_api_call(mocker):
mock_requests = mocker.patch("requests.get")
mock_requests.return_value.ok = True
mock_requests.return_value.text = "Success"
schedule = emp_1.monthly_schedule("May")
mock_requests.assert_called_with("http://company.com/Schafer/May")
assert schedule == "Success"
monkeypatch
fixture helps you to safely set/delete an attribute, dictionary item or environment variable, or to modify sys.path
for importing.def use_env_var():
contract_class = os.environ['CONTRACT_CLASS']
if contract_class == 'en_cloud':
# do some processing
return "this is en_cloud"
if contract_class == 'en_onprem':
# do some processing
return "this is en_onprem"
raise ValueError(f"contract class {contract_class} not found")
mockeypatch.setenv
to set the environment variable.@pytest.mark.parametrize(
"mock_contract_class,expect", [("en_cloud", "this is en_cloud"), ("en_onprem", "this is en_onprem")]
)
def test_mock_env_var(mock_contract_class, expect, monkeypatch):
# more about monkeypatch
# https://docs.pytest.org/en/6.2.x/monkeypatch.html
monkeypatch.setenv("CONTRACT_CLASS", mock_contract_class)
assert use_env_var() == expect
use_env_var
example, which was just used to demo for Environment Variable, but focusing on the exception scenario.def test_exception(monkeypatch):
monkeypatch.setenv("CONTRACT_CLASS", "something not existed")
with pytest.raises(ValueError, match=r"contract class something not existed not found"):
use_env_var()
You must be a registered user to add a comment. If you've already registered, sign in. Otherwise, register and sign in.
User | Count |
---|---|
3 | |
2 | |
2 | |
2 | |
2 | |
1 | |
1 | |
1 | |
1 |