Unit Testing with pytest
Software Testing is the process of verifying and validating that a software or application is bug free.
🙂Levels of Software Testing
📌Unit Testing →Level of software testing process in which each individual component/unit is tested in an isolated environment.It helps in validating that each unit is performing as per expectations.
📌Integration Testing →In this level individual units are combined and are tested as a group.It helps in finding faults in the interaction between integrated units.
📌System Testing →In this level complete software is tested.It helps in validating system’s compliance with the requirements.
📌Acceptance Testing →System is tested for acceptability in this level.Validates its acceptance for delivery.
🙄Types of Testing
→Manual Testing
→Automated Testing
🔮Three most popular test runners in Python are:
🎶unittest
🎶pytest →Reduces the boilerplate code
🎶nose or nose2
Let’s begin exploring pytest:
🥁Installing pytest
Run the following command in your command line:
pip install -U pytest
Check that you installed the correct version:
$ pytest --version
pytest 7.1.3
🔗Invoking pytest
In general, pytest is invoked with the command pytest.Remember these two things:
→name of the file in which you are writing your test should have test_prefix →functions in which you are writing your test should have test_prefix
otherwise pytest will not be detecting your tests.
test_demo.py -->name of the file
def test_Add(): -->should have test_prefix in name
assert add(1,2) == 3 -->testing an add function
#If you want to write your test cases inside a class then classname should have prefix Test
class TestDemo: -->Test Prefix should be there
def test_Add(self):
assert add(1,2) == 3
👀Asserting Expected Exceptions
import pytest
from demo import validate_demo
#If we want to write a test case for a function which is expected to raise some error.
def test_validate_demo():
with pytest.raises(ValueError): -->it returns an object of class exception_info
validate_demo(2) --> If this will not raise an Value error then this test case will fail
#If we want to test if the message printed in this test is correct or not
def test_validate_demo():
with pytest.raises(ValueError) as exc_info:
validate_demo(2)
assert str(exc_info.value) == "demo is not running"
#In pytest we have a match keyword which does the same thing as above
def test_validate_demo():
with pytest.raises(ValueError, match="demo is not running"):
validate_demo(2)
💥Introduction to Markers
Using pytest.mark helper you can easily set metadata on your test functions and this metadata is going to tell pytest that how to run that test case.
#Suppose I want this test not to be executed when I run pytest then I can use this marker
#Unconditional Marker
@pytest.mark.skip(reason="Please skip it")
def test_demo():
assert mult(10*2) == 20
#Conditional Marker
@pytest.mark.skipif(syd.version_info > (3.7), reason="Please skip it")
def test_demo():
assert mult(10*2) == 20
#If I want that do not run this test case if this test case throws some exception while running with pytest
@pytest.mark.xfail(sys.platform == "win32", reason="Do not run on win dows")
def test_demo2():
assert add("a", "b") == ab
raise Esception()
🌀Parameterized Unit Test
We can run the same test for different arguments.
@pytest.mark.parametrize("a, b, c", [(1,2,3) ("a", "b", "ab")) -->arguments name should be given as comma separated in a string
#This test will run for these two sets of arguments (1,2,3) and ("a","b","ab") where say a=1, b=2 and c=3 and same for second set of arguments
def test_Add(a, b,c):
assert add(a,b) == c
#We can also give ids to the arguments like:
@pytest.mark.parametrize("a, b, c", [(1,2,3) ("a", "b", "ab"), ids=["num", "str"])
#Profit of giving ids is that when these test will be executed you will get to see in terminal like:
test_Add[num] passed
test_Add[str] passed
🫧Introduction to Fixtures
In general, a test fixture is a environment used to consistently test some item ,device or piece of software.Software test fixture initializes test functions.They provide a fixed baseline so that tests execute reliably and produce consistent, ,reliable results.
pytest fixtures can be used in the case of like database connection or request sessions.We have a fixture like tmpdir what it does is, that it creates a temporary directory for the duration test case runs.
#Suppose we have a function like
import json
def save_dict(dict, filepath):
json.dump(dict, open(filepath, 'w'))
print("done")
#Writing a unit test case to check if file is getting created
def test_save_dict(tmpdir, capsys) --> name of the fixture has to be passed in argument
#capsys is a fixture that can read the output from console.
filepath = os.path.join(tmpdir, "test.json")-->adding file name to temporary directory created by fixture to get full file path
d={"a":1, "b":2}
save_dict(d, filepath)
#testing if this file is created or not
assert json.load(open(filepath, 'r')) == d
#testing if done is getting printed to console or not
assert capsys.readouterr().out == "done\n" -->"\n" used because print statement also prints a new line
🎒Creating a custom Fixture
#Let's make a fixture so that we get a dummy object of a class and do not need to create again and again for all tests
#Custom fixture should be decorated with this
@pytest.fixture
def dummy_demo():
return Demo("1" "apple")
#let's use this custom fixture
def test_demo2(dummy_demo):
assert dummy_demo.get_value() == 9
def test_demo3(dummy_demo):
assert dummy_demo.get_credits == 0
#But the thing to note here is that dummy_demo fixture will be creating the object of that class as many times as we will be using that.
#To optimise it we will use concept of scopes in fixture so that object gets created only once and all tests can use that
#By default scope of fixture is function.
#SCopes of fixtures are class, module, package or session
#Class scope means that this fixture will be initialized only once for all the test cases in the class
@pytest.fixture(scope="module")
def dummy_demo():-->Now this fixture will be making the object of Demo class only once for all the functions in the module.
return Demo(1, "apple")
🌄Creating Fixture Factory
Unit test function will call a function to generate fixture dynamically.It can pass some arguments and fixture will be created as per those.
class Demo:
def __init__(self, fruit , vitamins)
self.fruit = fruit
self.vitamins = vitamins
def get_vitamins(self)
return self.vitamins
def get_healthiest(fruits):
healthyOne = max(fruits, key = lambda fruit:fruit.get_vitamins())
#Now we have to write a unit test for get_healthiest function
#Now we need multiple fruits object to check who is healthiest
@pytest.fixture
def dummy_fruits_factory() --> will return a function to generate new objects of fruit class
def make_dummy_fruits(fruit, credits):
return Demo(fruit credits)
return make_dummy_fruits
def test_get_healthiest(dummy_fruits_factory):
fruits = [
dummy_fruits_factory("banana" 21),
dummy_fruits_factory("mango", 15)
]
healthyOne = get_healthiest(fruits)
assert healthiest == fruits[0]
**All fixtures should be placed in a file named conftest.py in order to have a clean code not mandatory.
🏖️Parameterizing Fixtures
#Parameterized Fixure
@pytest.fixture(params=[7,8], ids=["a", "b"])
def dummy_demo(request): -->request provides access to the current unit test and the params passed in fixture
return Demo("abc", 1 ,request.param)
#Now this test will run both the params passed in fixture
def test_dummy(dummy_Demo):
//body of the test function
class findNum:
def __init__(self, num):
self.num = num
def isEven(self):
if self.num%2 == 0:
return True
else:
return False
#Now let's write a unit test for isEven
#One way is that I write two functions one asserting value as True and one asserting value as False.
#More Optimized way is that I cover both in single function like this:
@pytest.fixture
def make_dummy_findNum() --> will return a function to generate new objects of fruit class
def make_dummy_findNum(num):
return findNum(num)
return make_dummy_findNum
@pytest.mark.parameterize("num, expected", [(10, True) (61, False)])
def test_isEven(make_dummy_findNum, num, ,expected)-->at first position fixture should be passed
assert isEven(make_dummy_findNum(num)) is expected
#Now let's learn that how to pass these params to a fixture i.e passing params from unit test to parameterized fixtures
@pytest.fixture
def dummy_find_num(request):
return findNum(request.param)
@pytest.mark.parameterize("dummy_find_num, expected", [(10, True) (61, False)], indirect=["dummy_find_num"])
def test_isEven(dummy_find_num, expected):
assert isEven(dummy_find_num) is expected
#This enabled us to use simple fixture instead of a fixture factory
I know it’s quite complicated, go through these codes once again and I’m sure you will get it.Happy Coding!!