Quick Start Guide to Pytest Testing Framework in 5 Minutes

This article will divide the content about Pytest into two parts. The first part mainly introduces the concepts of pytest and its functional components, while the second part will focus on practical applications of pytest in a web project.

Why Unit Testing is Important

Many Python users may have experienced the need to test whether a module or function outputs the expected result. Often, they use the <span>print()</span> function to print the output to the console.
def myfunc(*args, **kwargs):
    do_something()
    data = ...
    print(data)
During the improvement process, one often has to use the <span>print()</span> function frequently to ensure the accuracy of results. However, as the number of modules or functions to be tested increases, various unremoved or uncommented <span>print()</span> calls gradually clutter the code, making it less clean.
In programming, there is a concept called “unit testing,” which refers to checking and validating the smallest testable units in software. This smallest testable unit can be any expression, function, class, module, or package, allowing us to centralize the testing steps using <span>print()</span> into unit tests.
In Python, the official module for unit testing is already built-in as <span>unittest</span>. However, for beginners, <span>unittest</span> has a slightly steep learning curve because it requires encapsulation through inheritance of the test case class (<span>TestCase</span>), necessitating a good understanding of object-oriented knowledge. Being tied to classes means that achieving customization or module decoupling may require additional time spent on design separation.

Quick Start Guide to Pytest Testing Framework in 5 Minutes

Therefore, to simplify testing and provide scalability, a testing framework called pytest was born in the Python community. With pytest, we do not need to worry about how to implement our tests based on <span>TestCase</span>. We only need to keep our original code logic unchanged, adding an <span>assert</span> keyword to assert the results, and pytest will handle the rest.
# main.py

import pytest

raw_data = read_data(...)

def test_myfunc(*args, **kwargs):
    do_something()
    data = ...
    assert data == raw_data

if __name__ == '__main__':
    pytest.main()
Then we only need to run the <span>main.py</span> file containing the above code to see the results of the tests in the terminal console. If the result passes, there will be no excessive information displayed; if the test fails, an error message will be thrown, indicating what the content in <span>data</span> was.
Although pytest is already simple enough, it also provides many practical features (e.g., dependency injection). These features inherently involve some conceptual knowledge; however, this does not deter those who want to use pytest to test their code. Instead, it gives us more choices, and only by better understanding these features and concepts of pytest can we fully leverage its power.

Quickly Implement Your First Pytest Test

After installing pytest via <span>pip install pytest</span>, we can quickly implement our first test.
First, we can create a new Python file, here I will name it <span>test_main.py</span>, and keep the following content:
from typing import Union

import pytest

def add(
    x: Union[int, float], 
    y: Union[int, float],
) -&gt; Union[int, float]:
    return x + y

@pytest.mark.parametrize(
    argnames="x,y,result", 
    argvalues=[
        (1,1,2),
        (2,4,6),
        (3.3,3,6.3),
    ]
)
def test_add(
    x: Union[int, float], 
    y: Union[int, float],
    result: Union[int, float],
):
    assert add(x, y) == result
Next, switch the terminal to the path where this file is located, and run <span>pytest -v</span> to see that pytest has already helped us pass the test parameters into the testing function and achieve the corresponding results:

Quick Start Guide to Pytest Testing Framework in 5 Minutes

We can see that we do not need to repeatedly use a <span>for</span> loop to pass parameters, and we can intuitively see the specific values of the parameters passed in each test from the results. Here we only need to use the <span>mark.parametrize</span> decorator provided by pytest to achieve this. This also indicates that the learning curve of pytest is relatively easy, although we need to understand some concepts within this framework.

Pytest Concepts and Usage

Naming

For pytest to test your code, we first need to ensure that the functions, classes, methods, modules, or even code files to be tested are named starting with <span>test_*</span> or ending with <span>*_test</span>, adhering to the standard testing conventions. If we remove the <span>test_</span> prefix from the filename of the quick start example, we will find that pytest does not collect the corresponding test cases.
Of course, we can also modify different prefixes or suffixes in the pytest configuration file, as shown in the official example:
# content of pytest.ini
# Example 1: have pytest look for "check" instead of "test"
[pytest]
python_files = check_*.py
python_classes = Check
python_functions = *_check
But usually, we can use the default test prefix and suffix. If we only want to select specific test cases or only test specific modules, we can specify them in the command line using a double colon like this:
pytest test.py::test_demo
pytest test.py::TestDemo::test_demo

Marks

In pytest, <span>mark</span> marks are a very useful feature. By decorating our test objects with marking decorators, pytest can perform corresponding operations on our functions during testing based on the <span>mark</span> functionality.
The official documentation provides some preset <span>mark</span> functionalities, but we will only mention the commonly used ones.

Parameterized Testing: pytest.parametrize

As the previous examples and its name suggest, <span>mark.parametrize</span> is mainly used for scenarios where we want to pass different parameters or combinations of parameters to a test object.
As in our previous <span>test_add()</span> example, we tested:
  • When <span>x=1</span> and <span>y=1</span>, whether the result is <span>result=2</span>.
  • When <span>x=2</span> and <span>y=4</span>, whether the result is <span>result=6</span>.
  • When <span>x=3.3</span> and <span>y=3</span>, whether the result is <span>result=6.3</span>.
  • ……
We can also stack parameters to combine them, but the effect is similar:
import pytest

@pytest.mark.parametrize("x", [0, 1])
@pytest.mark.parametrize("y", [2, 3])
@pytest.mark.parametrize("result", [2, 4])
def test_add(x, y, result):
    assert add(x,y) == result
Of course, if we have enough parameters, as long as they are written into <span>parametrize</span>, pytest can still help us test all cases. This way, we no longer need to write redundant code.
However, it is important to note that <span>parametrize</span> and another important concept we will discuss later, <span>fixture</span>, have some differences: the former mainly simulates what output the test object will produce under different parameters, while the latter tests what results can be obtained under fixed parameters or data.

Skipping Tests

In some cases, our code contains parts for different situations, versions, or compatibility. These codes usually only apply under specific conditions; otherwise, execution may cause issues. However, the cause of this problem does not lie in the code logic but rather in the system or version information. Therefore, it would be unreasonable for such cases to fail as test cases. For example, I wrote a compatibility function <span>add()</span> for Python version 3.3, but using it when the version is greater than Python 3.3 will inevitably cause problems.
To accommodate such situations, pytest provides <span>mark.skip</span> and <span>mark.skipif</span>, with the latter being used more frequently.
import pytest
import sys

@pytest.mark.skipif(sys.version_info &gt;= (3,3))
def test_add(x, y, result):
    assert add(x,y) == result
So when we add this mark, every time before the test case, we use the <span>sys</span> module to check whether the Python interpreter version is greater than 3.3. If so, it will be skipped automatically.

Expected Exceptions

Since code is written by humans, it will inevitably contain bugs. Some bugs can be anticipated by the coder, and these special bugs are usually called exceptions. For example, we have a division function:
def div(x, y):
    return x / y
According to our arithmetic rules, the divisor cannot be 0; therefore, if we pass <span>y=0</span>, it will inevitably raise a <span>ZeroDivisionError</span> exception. Thus, in the testing process, if we want to test whether the exception assertion can be correctly raised, we can use the <span>raises()</span> method provided by pytest:
import pytest

@pytest.mark.parametrize("x", [1])
@pytest.mark.parametrize("y", [0])
def test_div(x, y):
    with pytest.raises(ValueError):
        div(x, y)
Here, we need to assert that we are capturing the <span>ValueError</span> that we specified to be raised after the <span>ZeroDivisionError</span>, not the former. Of course, we can use another marked method (<span>pytest.mark.xfail</span>) in combination with <span>pytest.mark.parametrize</span>:

@pytest.mark.parametrize(
    "x,y,result", 
    [
        pytest.param(1,0, None, marks=pytest.mark.xfail(raises=(ValueError))),
    ]
)
def test_div_with_xfail(x, y, result):
    assert div(x,y) == result
This way, the failed parts will be directly marked during the testing process.

Fixture

Among many features of pytest, the most impressive is <span>fixture</span>. Most people directly translate <span>fixture</span> as “夹具” (jig), but if you are familiar with the Java Spring framework, you will find it easier to understand it as something similar to an IoC container. However, I believe it might be more appropriate to call it “载具” (carrier).
Because usually, the role of <span>fixture</span> is to provide a fixed, freely detachable general object for our test cases, functioning like a container that carries something inside; when we use it for our unit tests, pytest will automatically inject the corresponding object into the fixture.
Here, I have simulated a situation where we are using a database. Typically, we would create a database object through a database class, connect it using the <span>connect()</span> method, perform operations, and finally disconnect it using the <span>close()</span><code><span> method to release resources.</span>
# test_fixture.py

import pytest

class Database(object):

    def __init__(self, database):
        self.database = database
    
    def connect(self):
        print(f"\n{self.database} database has been connected\n")

    def close(self):
        print(f"\n{self.database} database has been closed\n")

    def add(self, data):
        print(f"`{data}` has been add to database.")
        return True

@pytest.fixture
def myclient():
    db = Database("mysql")
    db.connect()
    yield db
    db.close()


def test_foo(myclient):
    assert myclient.add(1) == True
In this code, the key to implementing the fixture is the <span>@pytest.fixture</span> decorator. With this decorator, we can directly use a function with resources as our fixture. When using it, we pass the function’s signature (i.e., its name) as a parameter into our test case. During the test run, pytest will automatically help us with the injection.

Quick Start Guide to Pytest Testing Framework in 5 Minutes

During the injection process, pytest will call the <span>connect()</span> method of the <span>db</span> object in <span>myclient()</span>, simulating the method for connecting to the database. After the test is complete, it will call the <span>close()</span><code><span> method again to release resources.</span>
The <span>fixture</span> mechanism of pytest is key to achieving complex testing. Imagine that we only need to write a fixture with test data once, and it can be reused in different modules, functions, or methods, truly achieving “one-time creation, everywhere used.”
Of course, pytest provides adjustable fixture scopes, which can range from small to large:
  • <span>function</span>: function scope (default)
  • <span>class</span>: class scope
  • <span>module</span>: module scope
  • <span>package</span>: package scope
  • <span>session</span>: session scope
The fixture will be created and destroyed along with the lifecycle of its scope. Therefore, if we want to increase the scope of the created fixture, we can add a <span>scope</span> parameter to <span>@pytest.fixture()</span> to extend the range of the fixture’s effect.
Although pytest officially provides some built-in general fixtures, we often have more custom fixtures. Therefore, we can place them in a file named <span>conftest.py</span> for unified management:
# conftest.py

import pytest

class Database:
    def __init__(self, database):
        self.database:str = database
    
    def connect(self):
        print(f"\n{self.database} database has been connected\n")

    def close(self):
        print(f"\n{self.database} database has been closed\n")

    def add(self, data):
        print(f"\n`{data}` has been add to database.")
        return True

@pytest.fixture(scope="package")
def myclient():
    db = Database("mysql")
    db.connect()
    yield db
    db.close()
Since we declared the scope to be the same package, we can slightly modify the previous <span>test_add()</span> test part without explicitly importing the <span>myclient</span> fixture, and it can be injected and used directly:
from typing import Union

import pytest

def add(
    x: Union[int, float], 
    y: Union[int, float],
) -&gt; Union[int, float]:
    return x + y

@pytest.mark.parametrize(
    argnames="x,y,result", 
    argvalues=[
        (1,1,2),
        (2,4,6),
    ]
)
def test_add(
    x: Union[int, float], 
    y: Union[int, float],
    result: Union[int, float],
    myclient
):
    assert myclient.add(x) == True
    assert add(x, y) == result
Then run <span>pytest -vs</span> to see the output results:

Quick Start Guide to Pytest Testing Framework in 5 Minutes

Pytest Extensions

Every framework user knows that the quality of a framework’s ecosystem indirectly affects its development (for example, Django and Flask). Pytest reserves enough space for extensions, along with many user-friendly features, allowing for numerous plugins or third-party extensions to exist for pytest.
According to the official plugin list, there are currently around 850 plugins or third-party extensions for pytest. We can find the Plugin List page in the official pytest Reference to view it. Here, I will mainly mention two plugins related to the next chapter’s practice:
We can install the related plugins as needed using the <span>pip</span> command, and finally, we only need to refer to the plugin’s documentation to write the corresponding parts and start pytest testing.

pytest-xdist

pytest-xdist is a pytest plugin maintained by the pytest team that allows us to perform parallel testing to improve our testing efficiency because if our project is of a certain scale, there will inevitably be many tests. Since pytest collects test cases synchronously, it cannot fully utilize multi-core processors.
Therefore, with pytest-xdist, we can significantly speed up each round of testing. We only need to add the <span>-n <CPU_NUMBER></span> parameter when starting pytest testing, where the CPU number can directly use <span>auto</span> as a substitute; it will automatically adjust the number of CPU cores used for pytest testing:

Quick Start Guide to Pytest Testing Framework in 5 Minutes

pytest-asyncio

pytest-asyncio is an extension that allows pytest to test asynchronous functions or methods, also maintained by the official pytest team. Since most asynchronous frameworks or libraries are often based on Python’s official asyncio, pytest-asyncio can further integrate asynchronous testing and asynchronous fixtures into test cases.
We can directly use the <span>@pytest.mark.asyncio</span> decorator to decorate asynchronous functions or methods in the test:
import asyncio

import pytest


async def foo():
     await asyncio.sleep(1)
     return 1

@pytest.mark.asyncio
async def test_foo():
    r = await foo()
    assert r == 1

Conclusion

This content mainly provides a simple introduction to the concepts and core features of pytest. We can see how easy it is to use pytest for testing. The features and usage examples of pytest go far beyond this; the official documentation is comprehensive enough for interested readers to further explore.
In the next part, we will further integrate pytest in practice using a web project as an example.

Author: 100gle, a non-serious liberal arts student with less than two years of practice, enjoys coding, writing articles, and experimenting with various new things; currently engaged in big data analysis and mining work.

Support the Author

Quick Start Guide to Pytest Testing Framework in 5 Minutes

More Reading

Top 10 Best Popular Python Libraries of 2020

Top 10 Hot Articles in the Python Chinese Community of 2020

Master Python Object References in 5 Minutes

Special Recommendations

Quick Start Guide to Pytest Testing Framework in 5 Minutes

Quick Start Guide to Pytest Testing Framework in 5 Minutes

Click below to read the original text and join the community membership

Leave a Comment