This article assumes that you already have pytest running and you can basically interact with your computer's command interface. If you are just starting out, you may need to start with the following articles.

In this article, you will learn how to write and run test functions with pytest in a practical approach.

Writing test Functions

To Pytest, test functions are basically functions whose names begins with "test_"

Pytest automatically detects and executes these functions.

For the test functions to be automatically discovered, they should be placed in files whose names match the following patterns.

  1. test_*.py
  2. *_test.py

This means that a test file must either begin with test_or end with _test.

For example, assume we have a directory called project, with the following structure.

project
├── areas.py
└── test_areas.py

#areas.py

def rectangle_area(length, width):
    area = length * width

    return area

To write simple tests for the rectangle_area() function, we will define a function test_rectangle_area() in the test_areas.py file.

#test_areas.py

from areas import rectangle_area

def test_rectangle_area():	
    assert rectangle_area(3, 4) == 12
    assert rectangle_area(6, 8) == 48
    assert rectangle_area(10, 10) == 100

In the test_rectangle_function() above, we used the basic assert statement to check whether the value returned by the rectangle_area() function is equivalent to an expected value. We will talk about assert statements  in a while, for now let us focus on the structure of the test function.

To run the above tests just type pytest and click enter in your computers command-line interface from the project directory.

================================================= test session starts =================================================
platform win32 -- Python 3.13.2, pytest-8.3.4, pluggy-1.5.0
rootdir: C:\Users\Administrator\Desktop\project
collected 1 item

test_areas.py .                                                                                                  [100%]

================================================== 1 passed in 0.26s ==================================================

In the above case, all our assertions are assertions evalueates to True, thus the test passes. In case where a test fails, Pytest provides detailed information about the failure, including the line number and the expected vs actual values.

Assert statements.

In Pytest, the native assert statement is the primary tools for writing assertions. This simplicity is one of the main reasons why pytest is so popular among developers.

for more details see:

In other test frameworks including unittest which exists in the standard library, assert helper functions are normally used to create assertions. Pytest, on the other hand, intercepts the basic assert statements making it more convenient and  easy to achieve the same.

The following table contrasts some basic assertions in pytest and their equivalent in unittest to demonstrate how Pytest simplifies testing with native assert statement 

pytest unittest
assert a == b .assertEqual(a, b)
assert a != b .assertNotEqual(a, b)
assert a is True .assertTrue(a)

The assert statement basically has the following syntax.

assert <expression>

expression can be any valid Python expression, it should evaluate to a boolean value i.e True or False. If the expression evaluates to True, the assertion passes, if False the assertion fails.

In our test_rectangle_area() function, we created some assertion statements. e.g 

assert rectangle_area(3, 4) == 12

In this case the assertion passes, because the value that is returned when we call rectangle_area(3, 4) is 12. If the returned value is not equal to the expected value, the assertion fails and consequently, the entire test fails. Let us deliberately make the  test_rectangle_area() to fail to understand this better.

#test_areas.py

from areas import rectangle_area

def test_rectangle_area():	
    assert rectangle_area(3, 4) == 15 #assertion fails
    assert rectangle_area(6, 8) == 48
    assert rectangle_area(10, 10) == 100

If you make the modifications and then call pytest you will see that the entire test fails because that single assertion did not pass.

platform win32 -- Python 3.13.2, pytest-8.3.4, pluggy-1.5.0
rootdir: C:\Users\Administrator\Desktop\project
collected 1 item

test_areas.py F                                                                                                  [100%]

====================================================== FAILURES =======================================================
_________________________________________________ test_rectangle_area _________________________________________________

    def test_rectangle_area():
>       assert rectangle_area(3, 4) == 15
E    assert 12 == 15
E     +  where 12 = rectangle_area(3, 4)

test_areas.py:4: AssertionError
=============================================== short test summary info ===============================================
FAILED test_areas.py::test_rectangle_area - assert 12 == 15
================================================== 1 failed in 1.50s ==================================================

For each failing test, the line which caused the failure is shown with a >, you can see this from the above output.

Expecting exceptions

In some cases, we may expect an exception to be raised. Pytests allows us to test that the exception is actually raised using the .raises() function. This function can be used as a context manager meaning that we can use it in a with statement.

If you look at our rectangle_area() function in areas.py, you will realize that it will work even when the length or width is negative, but mathematically, a rectangle with negative dimensions is undefined. Let us improve the function so that a ValueError is raised in such a scenario.

#areas.py

def rectangle_area(length, width):
    if width <= 0 or length <= 0:
        raise ValueError('Invalid rectangle dimensions.')
    area = length * width

    return area

Let us now add a test in test_rectangle_area() function that will check that the function actually raises an error if it is called with invalid values.

from areas import rectangle_area
import pytest 

def test_rectangle_area():
    assert rectangle_area(3, 4) == 12
    assert rectangle_area(6, 8) == 48
    assert rectangle_area(10, 10) == 100

    with pytest.raises(ValueError):
        rectangle_area(3, -4)

Pytest will expect that the code inside the with block to raise the exception that is given as argument. in the above case, the test will only pass if a ValueError, is raised. If no error is raised, the test fails. if a different type of exception is raised, test fails.

If you call pytest now, you will see that the test passes. 

platform win32 -- Python 3.13.2, pytest-8.3.4, pluggy-1.5.0
rootdir: C:\Users\Administrator\Desktop
collected 1 item

project\test_areas.py .                                                                                          [100%]

================================================== 1 passed in 0.39s ==================================================

Try altering the with block in test_rectangle_area() function to see what happens in various scenarios. For example, you can call the rectangle_area() with a non-negative valid values for width and length in which case the test will fail.

Marking tests

Marking tests allows you to control the execution of test function. This is achieved using the pytest.mark decorators. You can run the following command to see all the builtin marker decorators. 

pytest --markers

The following is a list of some of the built-in markers, that we will look at in this article.

  • @pytest.mark.skip
  • @pytest.mark.skipif
  • @pytest.mark.xfail
  • @pytest.mark.parametrize

Skipping test functions

For varying reasons, we may want to skip some test functions so that they are not executed by pytest. Basically, we can achieve this using the @pytest.mark.skip marker. This decorator makes the decorated function to be skipped unconditionally.

The skip marker has the following syntax.

@pytest.mark.skip(reason = None)

Where, reason is an optional text indicating why the function was skipped.

We will add more code to areas.py file to demonstrate this.

#areas.py

import math

def rectangle_area(length, width):
    if width <= 0 or length <= 0:
        raise ValueError('Invalid rectangle dimensions.')
        
    area = length * width
    return area

def circle_area(r):
    if r <= 0:
        raise ValueError(f'a circle with radius {r} is undefined.')
    
    are = math.pi * r * r 
    return area

Let us add a test function for the circle_area() function.

#test_areas.py

from areas import rectangle_area, circle_area
import pytest 

@pytest.mark.skip(reason = 'Already Tested').
def test_rectangle_area():
    assert rectangle_area(3, 4) == 12
    assert rectangle_area(6, 8) == 48
    assert rectangle_area(10, 10) == 100
    with pytest.raises(ValueError):
        rectangle_area(3, -4)

def test_circle_area():
    assert circle_area(7) == pytest.approx(153.86, 0.001 )

    with pytest.raises(ValueError):
        circle_area(-7)

We have added the test_circle_area() function. The older test_rectangle_area() is decorated with the @pytest.mark.skip decorator. If you call pytest after updating the files, you will see that the test_rectangle_area() function will  be skipped . The output will be similar to one shown below.

================================================= test session starts =================================================
platform win32 -- Python 3.13.2, pytest-8.3.4, pluggy-1.5.0
rootdir: C:\Users\Administrator\Desktop
collected 2 items

project\test_areas.py s.                                                                                         [100%]

============================================ 1 passed, 1 skipped in 0.37s =============================================

Skipping tests conditionally

The @pytest.mark.skipif decorator can be used to skip tests conditionally. This means that a test function will only be skipped if specified condition evaluates to True.

The syntax of this decorator is as follows.

@pytest.mark.skipif(condition, reason = None)

condition is any valid Python expression, it should evaluate to a boolean value i.e True or False. If True, the function is skipped, if False, the function is executed normally.

Marking test as expected to fail

When you use skip or skipif decorators, the tests are not executed at all. We can use the @pytest.mark.xfail decorator to tell pytest to execute the test function but we expect that its tests will fail. 

In the output, the decorated function is indicated with xfailed if it actually failed as expected or xpassed if it unexpectedly passes.

#test_areas.py

from areas import rectangle_area, circle_area
import pytest 

@pytest.mark.xfail
def test_rectangle_area():
    assert rectangle_area(3, 4) == 12
    assert rectangle_area(6, 8) == 48
    assert rectangle_area(10, 10) == 100
    assert rectangle_area(3, -4)  == -12

def test_circle_area():
    assert circle_area(7) == pytest.approx(153.86, 0.001 )

    with pytest.raises(ValueError):
        circle_area(-7)

In the above example, we expect the test_rectangle_area() test to fail because of the last assertion.

If you run pytest after the modification, you will get the following output.

================================================= test session starts =================================================
platform win32 -- Python 3.13.2, pytest-8.3.4, pluggy-1.5.0
rootdir: C:\Users\Administrator\Desktop
collected 2 items

project\test_areas.py x.                                                                                         [100%]

============================================ 1 passed, 1 xfailed in 1.32s =============================================

Parametrized Testing

Test parametrization is one of the most powerful features of Pytest. It is used to achieve what is known as data-driven testing. Basically, it allows you to run the same test function with multiple set of inputs and expected outputs. This removes duplication and makes the tests more manageable.

To parametrize a test function, we use the @pytest.mark.parametrize decorator.

The decorator takes in two arguments:

  • a string containing the comma-separated parameter names
  • a list of tuples, where each tuple represents a set of inputs.

To better understand this, let us remove redundancy in the test_rectangle_area() function by parametrizing it.

#test_areas.py

from areas import rectangle_area
import pytest 

@pytest.mark.parametrize('length, width, expected', [
    (3, 4, 12),
    (6, 8, 48),
    (10, 10, 100),
    (-3, -4, 12)
    ]
    )
def test_rectangle_area(length, width, expected):
    if length <= 0 or width <= 0 :
        with pytest.raises(ValueError):
            rectangle_area(length, width)
    else:
        assert rectangle_area(length, width) == expected

In the test function above, the first argument to the parametrize decorator is a string containing the parameter names separated with a comma  i.e 'length, width, expected'. The second argument is a list of tuples, where each tuple is an individual instance of the three parameters, i.e length, width and expected.

The decorated test function i.e test_rectangle_area() defines the three parameters.When pytest is runs, it calls the test function with the individual instances from the tuples given in the decorator.

As you can see, this approach is more flexible and clean than defining  redundant assert statements inside the test function.

If you call pytest after the modification, the output will be as follows.

================================================= test session starts =================================================
platform win32 -- Python 3.13.2, pytest-8.3.4, pluggy-1.5.0
rootdir: C:\Users\Administrator\Desktop\project
collected 4 items

test_areas.py ....                                                                                               [100%]

================================================== 4 passed in 0.37s ==================================================

Go through the parametrized test_rectangle_area() function above to conceptualize the functionality of parametrization in pytest.

As a task, create a parametrized version of the test_circle_area() function.