Unit Testing with pytest

Deeksha Sharma
6 min readOct 29, 2023

--

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!!

--

--