Robert Nogueira [Profile]

Executive Officer at Square Cloud

Article written by Robert Nogueira in 28/01/2024.

Introduction

Software development is a complex process that demands precision, reliability, and flexibility. Among the fundamental practices ensuring the delivery of high-quality code, unit tests emerge as a crucial piece in the puzzle of modern development. Some of the benefits of having unit tests include:

In this article, we will cover how to perform unit tests in Python using the Pytest framework.

What is Pytest?

Pytest is a testing framework in Python, a more “Pythonic” alternative to the native unittest, known for its simplicity, ease of use, and expressive power.

Writing and Running Tests

Now that we’ve set the stage by understanding the significance of unit tests and the advantages of using Pytest, let’s dive into the practical aspect of writing and running tests in Python.

Writing Unit Tests

There’s no way to implement tests if we don’t have anything to test, right? So let’s start by creating a simple code that will serve as an example for this article.

Let’s begin by creating a file called calculator.py and some functions that perform mathematical operations:

calculator.py
def add(x: int, y: int) -> int:
    return x + y

def subtract(x: int, y: int) -> int:
    return x - y

def multiply(x: int, y: int) -> int:
    return x * y

def divide(x: float, y: float) -> float:
    if y == 0:
        raise ValueError('Cannot divide by zero')
    return x / y

Now, let’s create a directory called tests where our test files will be located.

In the tests directory, we’ve created a file named test_calculator.py to test the functionalities of a simple calculator module. Each test function within this file is responsible for verifying a specific operation in the calculator module.

tests/test_basic_usage.py
from calculator import add, subtract, multiply, divide

def test_add_numbers():
    result: int = add(3, 5)
    assert result == 8

def test_subtract_numbers():
    result: int = subtract(8, 3)
    assert result == 5

def test_multiply_positive_numbers():
    result: int = multiply(6, 9)
    assert result == 54

def test_divide_positive_numbers():
    result: float = divide(15, 3)
    assert result == 5

def test_raise_on__divide_by_zero():
    divide(10, 0)

In these test functions, we use the assert statement to check whether the actual results match the expected results. If any assertion fails during the test run, Pytest will report it as a failure.

For the folder containing your test files to be recognized as a test module by pytest, two requirements must be met:

  • There must be a file named __init__.py for it to be considered a module.
  • The module’s name should be test or tests for pytest to recognize it as a valid test module.
Name your test files and functions in the clearest and most expressive way possible in relation to what is being tested, this is a good practice.

Running Tests with Pytest

To execute our tests, we need to install Pytest first. Open your terminal and run one of the following commands based on your preferred package manager:

After installing Pytest, navigate to the directory containing your test files and run the following command:

pytest

Pytest will discover and execute all the test functions in the specified test directory. It will then provide a detailed report on the test results, including any failures or errors encountered.

Let’s see the output in the terminal:

Interestingly, we have useful information from our tests. As we can see, four of our tests passed and one failed, pytest also tells you the file, line, and function where the test failed, in addition to the error message.

But in this case we wait for the error to be raised, so the test should not fail, what can we do?

Asserting Errors

In Pytest, you can create asserts with expected errors using the with pytest.raises(Exception) block. This allows you to specify which exception you expect to be raised during the execution of code in a particular part of the test. This is particularly useful when testing scenarios where you expect a specific exception to occur.

Let’s illustrate this with an example using the calculator we discussed earlier. Suppose you want to test the divide function of the calculator and expect it to raise a ValueError when attempting to divide by zero.

tests/test_calculator.py
import pytest
from calculator import Calculator

@pytest.fixture
def calculator():
    return Calculator()

def test_divide_by_zero_raises_error(calculator):
    # Using the 'with pytest.raises(Exception)' block
    with pytest.raises(ValueError):
        calculator.divide(10, 0)

In the example above, the with pytest.raises(ValueError) block wraps the call to the calculator.divide(10, 0) function. Within this block, the test will pass if a ValueError exception is raised during the execution of the function. Otherwise, the test will fail.

You can also check for specific exception messages within the with pytest.raises block. For example, if you expect a specific error message, you can modify the block like this:

with pytest.raises(ValueError, match="Cannot divide by zero"):
    calculator.divide(10, 0)

This ensures that the test passes only if a ValueError with the specified error message is raised.

When you run the test we created, you will see an output similar to the following in the terminal:

In the above result, the dot (.) indicates that the test_divide_by_zero_raises_error test passed successfully. If there were any issues, for example, if the ValueError exception was not raised, the result would indicate a test failure.

Advanced Testing Techniques

Pytest Fixtures

Pytest provides a powerful feature called fixtures, allowing you to set up preconditions or shared resources for your tests. Fixtures help keep your test code clean and maintainable.

Let’s enhance our test file by introducing fixtures. First, we’ll modify the calculator.py file to include a simple class, and then we’ll create a fixture in the test_calculator.py file:

calculator.py
class Calculator:
    def add(self, x: int, y: int) -> int:
        return x + y

    def subtract(self, x: int, y: int) -> int:
        return x - y

    def multiply(self, x: int, y: int) -> int:
        return x * y

    def divide(self, x: float, y: float) -> float:
        if y == 0:
            raise ValueError("Cannot divide by zero")
        return x / y

Now, let’s modify our test file to use fixtures:

tests/test_calculator.py
import pytest
from calculator import Calculator

@pytest.fixture
def calculator() -> Calculator:
    return Calculator()

def test_add_numbers(calculator: Calculator):
    result: int = calculator.add(3, 5)
    assert result == 8

# ... (similar modifications for other test functions)

In this example, the calculator fixture is a function that returns an instance of the Calculator class. By including calculator as an argument in our test functions, Pytest automatically injects the fixture, making it available for use in the tests.

Parametrized Tests

Pytest allows you to write parametrized tests, enabling you to run the same test logic with different inputs. This is particularly useful when testing a function with various scenarios.

Let’s modify one of our test functions to be parametrized:

tests/test_calculator.py
import pytest
from calculator import Calculator

@pytest.fixture
def calculator():
    return Calculator()

@pytest.mark.parametrize(
    "input_a, input_b, expected_result",
    [
        (3, 5, 8),
        (8, 3, 5),
        (6, 9, 54),
        (15, 3, 5),
    ]
)
def test_add_numbers(
    calculator: Calculator,
    input_x: int,
    input_y: int,
    expected_result: int
):
    result: int = calculator.add(input_a, input_b)
    assert result == expected_result

These advanced testing techniques demonstrate how Pytest provides a robust framework for writing comprehensive and maintainable unit tests. As your codebase grows, incorporating these practices will contribute to the overall stability and reliability of your software.

In Pytest, group marks, often referred to as “marking” or “markers,” are a way to categorize and label your tests, allowing for more fine-grained control over test execution. They provide a means to selectively run or exclude specific groups of tests based on their characteristics or requirements.

Here are some common use cases for group marks in Pytest:

Marking Tests with Categories

You can use markers to categorize your tests based on certain characteristics. For example, you might have tests that are categorized as “slow,” “fast,” “smoke,” or “integration.” This helps you in managing different types of tests and running only the relevant ones when needed.

tests/test_calculator.py

import pytest
from calculator import add, subtract, multiply, divide

@pytest.mark.slow
def test_add_numbers():
    result = add(3, 5)
    assert result == 8

@pytest.mark.smoke
def test_subtract_numbers():
    result = subtract(8, 3)
    assert result == 5

To run only the slow tests, you can use the -m option:

pytest -m slow

Conditional Execution

You can use markers to conditionally run tests based on specific criteria. For example, you might have tests that should only run on a specific operating system or Python version.

tests/test_calculator.py
import pytest
from calculator import add, subtract, multiply, divide

@pytest.mark.skipif(
    sys.version_info < (3, 9),
    reason="Requires Python 3.9 or higher"
)
def test_multiply_positive_numbers():
    result = multiply(6, 9)
    assert result == 54

Custom Groupings

You can create your own custom markers to group tests in a way that makes sense for your project. This allows you to express specific requirements or characteristics of your tests.

tests/test_calculator.py
import pytest
from calculator import add, subtract, multiply, divide

@pytest.mark.arithmetic
def test_add_numbers():
    result = add(3, 5)
    assert result == 8

@pytest.mark.arithmetic
def test_subtract_numbers():
    result = subtract(8, 3)
    assert result == 5

To run tests marked with a custom marker, you can use:

pytest -m arithmetic

Combining Marks

You can also combine multiple marks to create more complex conditions for test selection.

tests/test_calculator.py
import pytest
from calculator import add, subtract, multiply, divide

@pytest.mark.slow
@pytest.mark.arithmetic
def test_add_numbers():
    result = add(3, 5)
    assert result == 8

To run tests that are both marked as “slow” and “arithmetic,” you can use:

pytest -m "slow and arithmetic"

By effectively using group marks, you can enhance the flexibility and efficiency of your test suite, enabling you to run specific subsets of tests based on your needs. This is particularly useful in larger projects where running the entire test suite might be time-consuming, and you want to focus on specific aspects during development or debugging.

Conclusion

In this article, we explored the fundamentals of unit testing and delved into the world of Pytest, a versatile testing framework for Python. We discussed the benefits of unit testing, such as early bug identification, development acceleration, and quality assurance.

Pytest, with its simplicity, scalability, rich plugin ecosystem, and maturity, stands out as a preferred choice for many Python developers. We covered the installation process and walked through the creation of a basic test suite using Pytest for a simple calculator module.

As your testing requirements evolve, Pytest offers advanced features such as fixtures, parametrized tests and group mark capabilities. Leveraging these features enhances the maintainability and comprehensiveness of your test suite.

By following best practices, organizing your tests effectively, and incorporating Pytest’s powerful features, you can build a robust testing foundation for your Python projects. Unit tests not only validate the correctness of your code but also contribute to the overall reliability and longevity of your software.