Python Unit Testing with Pytest

Python Unit Testing with Pytest

Introduction

Unit testing is a vital practice for any software developer who wants to ensure the quality and reliability of their code. By writing tests that check the functionality and behavior of your code, you can catch errors before they cause problems in production.

However, writing tests can also be tedious and time-consuming, especially if you have to write a lot of boilerplate code and run the tests manually. That's why you need a tool that can help you write, run, and manage your tests more efficiently and effectively. Pytest is one of the most popular and powerful tools for unit testing in Python. It offers a simple and expressive syntax, a rich set of features, and a flexible plugin system that can enhance your testing experience.

In this article, we will cover the basics of pytest, including how to install it, how to write test cases, how to run tests, and how to use some of its advanced features.

What is unit testing?

Unit testing is a type of software testing that checks whether individual units or components of a software application are working as expected. A unit is the smallest testable part of an application, such as a function, method, or class.

Unit tests are typically written by the developers who wrote the code that they are testing. They are written early in the development process, and they are often automated so that they can be run quickly and frequently.

Why are unit tests necessary?

  • Bug Detection and Prevention: Unit tests help identify bugs and issues in the code early in the development process. By testing individual units (small sections) of code in isolation, developers can catch and address errors before they propagate to other parts of the system, making them easier and cheaper to fix.

  • Quality Assurance: Unit tests ensure the correctness of the code. They provide a safety net that helps maintain code quality, preventing regressions when new features are added or existing code is modified

  • Documentation and Code Understanding: Unit tests serve as living documentation for the codebase. They provide examples of how the code should be used and can help new team members understand the code's behavior and expected outcomes

  • Reduced Debugging Time: Unit tests pinpoint the location and cause of defects, making debugging faster and more efficient. Developers can focus their efforts on fixing issues rather than spending excessive time searching for them.

  • Continuous Integration (CI) and Continuous Deployment (CD): Unit tests are a fundamental component of CI/CD pipelines. They are run automatically whenever code changes are pushed, ensuring that new code additions meet quality standards before they are integrated into the main codebase or deployed to production.

Python unit testing frameworks

Python offers several unit testing frameworks that developers can use to create and run unit tests. Some of the most commonly used Python unit testing frameworks include:

  1. Unittest (also known as PyUnit): unittest is part of Python's standard library, making it readily available for Python developers. It follows the xUnit-style test pattern, similar to JUnit for Java. unittest provides features for test discovery, test fixtures, and test result reporting.

  2. Nose2 (successor of nose): nose2 is a test discovery and execution framework that extends unittest and offers additional features. It provides automatic test discovery, test parallelization, and a plugin system. nose2 aims to be an improvement over the older nose framework.

  3. Doctest: doctest is a testing framework that extracts test cases from docstrings and verifies that they produce the expected output. It is useful for embedding test cases directly within documentation, ensuring that code examples in documentation remain up-to-date.

  4. Pytest: pytest is a popular third-party testing framework known for its simplicity and powerful features. It offers an easy-to-use and concise syntax for writing test functions. Pytest provides advanced features such as fixtures, parametrized testing, and plugin support. It can automatically discover and run tests, making it a preferred choice for many Python developers.

Getting Started with Pytest

Why Pytest?

  1. Concise and Readable Syntax: Pytest offers a simple and intuitive syntax for writing test functions using plain assert statements, which many find more readable compared to other testing frameworks.

  2. Powerful Test Discovery: Pytest excels at test discovery, automatically finding and running test functions and classes, making it easier to organize and execute tests without the need for extensive boilerplate code.

  3. Rich Ecosystem of Plugins: Pytest has a vast ecosystem of plugins that extend its functionality. Developers can choose from a wide range of plugins to customize and enhance their testing experience.

  4. Parametrized Testing: Pytest provides built-in support for parametrized testing, allowing you to run the same test with different input data, reducing code duplication and making tests more concise.

  5. Fixtures for Setup and Teardown: Pytest's fixture system simplifies common setup and teardown tasks, making it easier to manage test environments and resources.

  6. Detailed Failure Reports: Pytest's failure reports are highly informative, providing detailed information about test failures, including the values of variables involved in assertions.

  7. Third-Party Integration: Pytest seamlessly integrates with various third-party libraries and tools, such as code coverage tools, continuous integration systems, and test runners.

  8. No Need to Inherit from a Base Class: Unlike some other testing frameworks (e.g., unittest), pytest doesn't require you to inherit from a base test class, making it more flexible and allowing you to use plain Python functions for tests.

Installing Pytest

To work with pytest, you need to install it first. You can install it in a virtual environment using pip:

Windows:

python -m venv venv
.\venv\Scripts\activate
python -m pip install pytest

Linux and MacOS:

python -m venv venv
source venv/bin/activate
python -m pip install pytest

You can verify pytest's installation by running the following command:

pytest --version

This should display the pytest version installed, confirming that it's ready to use in your Python environment.

Writing test cases

To demonstrate writing test cases, I have created a simple script called calculate.py.

def add(x, y):
    return x + y
def subtract(x, y):
    return x - y
def multiply(x, y):
    return x * y

Pytest recognizes test files by looking for files with names that start with "test_" or end with "test.py". This convention allows you to organize your tests intuitively and keep them separate from your actual application code. Also, test functions should be named with a "test" prefix, which makes it easy for pytest to identify and run them automatically.

Create a new file in the same directory called test_calculate.py and paste the following script to test the add function:

from calculate import add

def test_add():
    assert add(2, 3) == 5
  • from calculate import add: This line imports the add function from calculate.py. In other words, it's bringing the add function into the current script's namespace, so it can be used in the test.

  • assert add(2, 3) == 5: Checks if the result of calling the add function with arguments 2 and 3 is equal to 5. In other words, it's verifying that the add function correctly adds the two numbers.

To run all the tests:

pytest

To run a single test file:

pytest test_calculate.py

To run a single test case:

pytest test_calculate.py::test_add

Output:

Pytest provides clear and concise test results, including:

  • Pass: Green ✔️

  • Fail: Red ❌

  • Skip: Yellow ⚠️

When writing tests, it is recommended to test as many edge cases as possible:

def test_add():
    assert add(2, 3) == 5
    assert add(0, 0) == 0
    assert add(-1, 1) == 0
    assert add(-1, -1) == -2
    assert add(2.5, 3.5) == 6

Output:

The 1 passed message indicates that only one test function was executed successfully because all our assertions are contained within a single function.

Now, let's consider a scenario where the developer is writing the calculate.py script. If, during the implementation of the add function, they accidentally use the (++) operator instead of the correct (+) operator, it would result in a clear syntax error, making it relatively easy for the developer to detect and correct the mistake before running the script.

However, if the developer mistakenly uses the backslash (\) character instead of the plus sign (+) in the add function, the script would technically still run without any syntax errors because the syntax is valid. Unfortunately, this mistake would only become apparent during the testing phase when the tests are executed. In this case, the tests would fail because the behavior of the add function would not be as expected, highlighting the error in the code.

def add(x, y):
    return x / y

Output:

As you can see, the test failed. This is one of the benefits of unit testing.

Now, let’s write tests for the other functions:

from calculate import add, subtract, multiply

def test_add():
    assert add(2, 3) == 5
    assert add(0, 0) == 0
    assert add(-1, 1) == 0
    assert add(-1, -1) == -2
    assert add(2.5, 3.5) == 6

def test_subtract():
    assert subtract(2, 3) == -1
    assert subtract(0, 0) == 0
    assert subtract(-1, 1) == -2
    assert subtract(-1, -1) == 0
    assert subtract(2.5, 3.5) == -1

def test_multiply():
    assert multiply(2, 3) == 6
    assert multiply(0, 0) == 0
    assert multiply(-1, 1) == -1
    assert multiply(-1, -1) == 1
    assert multiply(2.5, 3.5) == 8.75

Output:

We can also test for strings:

from calculate import add, subtract, multiply

def test_add():
    assert add(2, 3) == 5
    assert add(0, 0) == 0
    assert add(-1, 1) == 0
    assert add(-1, -1) == -2
    assert add(2.5, 3.5) == 6

def test_subtract():
    assert subtract(2, 3) == -1
    assert subtract(0, 0) == 0
    assert subtract(-1, 1) == -2
    assert subtract(-1, -1) == 0
    assert subtract(2.5, 3.5) == -1

def test_multiply():
    assert multiply(2, 3) == 6
    assert multiply(0, 0) == 0
    assert multiply(-1, 1) == -1
    assert multiply(-1, -1) == 1
    assert multiply(2.5, 3.5) == 8.75

def test_add_strings():
    result = add("Hello", "World")
    assert result == "HelloWorld"
    assert type(result) is str
    assert "howdy" not in result
    assert add("Hello", "") == "Hello"
    assert add("", "") == ""

Output:

More Examples:

Here is a Python class that represents a simple shopping cart:

class ShoppingCart:
    def __init__(self):
        self.items = {}


    def add_item(self, item, quantity):
        if item in self.items:
            self.items[item] += quantity
        else:
            self.items[item] = quantity


    def remove_item(self, item, quantity):
        if item in self.items:
            if self.items[item] >= quantity:
                self.items[item] -= quantity
            else:
                del self.items[item]


    def calculate_total(self, prices):
        total = 0
        for item, quantity in self.items.items():
            if item in prices:
                total += prices[item] * quantity
        return total

This class represents a simple shopping cart with methods to add, remove, and calculate the total cost of items.

Create a file named test_shopping_cart.py with the following content:

from shopping_cart import ShoppingCart


class TestShoppingCart:
    def test_add_item(self):
        cart = ShoppingCart()
        cart.add_item("apple", 2)
        assert cart.items == {"apple": 2}


    def test_remove_item(self):
        cart = ShoppingCart()
        cart.add_item("apple", 2)
        cart.remove_item("apple", 1)
        assert cart.items == {"apple": 1}


    def test_calculate_total(self):
        cart = ShoppingCart()
        cart.add_item("apple", 2)
        cart.add_item("banana", 3)
        prices = {"apple": 0.5, "banana": 0.25}
        assert cart.calculate_total(prices) == 1.75

In the above code, we define a class called TestShoppingCart contains three test methods: test_add_item, test_remove_item, and test_calculate_total. Each method tests a specific functionality of the ShoppingCart class.

In the test_add_item method, we create an instance of the ShoppingCart class and add an item to it. We then use the assert statement to check if the item was added correctly.

In the test_remove_item method, we add an item to the cart and then remove a certain quantity of that item. We then use the assert statement to check if the item was removed correctly.

In the test_calculate_total method, we add two items to the cart and then calculate the total cost of those items using a dictionary of prices. We then use the assert statement to check if the total cost was calculated correctly.

By writing these unit tests, we can ensure that our ShoppingCart class works as expected and that any changes we make to it in the future do not break its existing functionality.

Output:

Here’s another example of a simple "Guess the Number" game. In this game, the player needs to guess a randomly generated number:

import random

def generate_random_number():
    return random.randint(1, 100)

def check_guess(random_number, guess):
    if not isinstance(guess, int):
        raise ValueError("Guess must be an integer")

    if guess < random_number:
        return "Too low!"
    elif guess > random_number:
        return "Too high!"
    else:
        return "Correct!"

def play_game():
    random_number = generate_random_number()
    attempts = 0

    while True:
        guess = int(input("Guess the number (1-100): "))
        attempts += 1

        result = check_guess(random_number, guess)
        print(result)

        if result == "Correct!":
            print(f"Congratulations! You guessed the number in {attempts} attempts.")
            break

Now, let's create some test cases for this game using Pytest.

import pytest
from game import generate_random_number, check_guess

def test_generate_random_number():
    num = generate_random_number()
    assert 1 <= num <= 100

def test_check_guess():
    assert check_guess(50, 50) == "Correct!"
    assert check_guess(75, 50) == "Too low!"
    assert check_guess(25, 50) == "Too high!"

def test_check_guess_out_of_range():
    assert check_guess(50, 0) == "Too low!"
    assert check_guess(50, 101) == "Too high!"

def test_check_guess_invalid_input():
    with pytest.raises(ValueError):
        check_guess(50, "abc")

def test_check_guess_large_range():
    assert check_guess(1000, 999) == "Too low!"
    assert check_guess(1000, 1001) == "Too high!"

def test_check_guess_negative_numbers():
    assert check_guess(-50, -50) == "Correct!"
    assert check_guess(-75, -50) == "Too high!"
    assert check_guess(-25, -50) == "Too low!"

def test_check_guess_negative_and_positive_numbers():
    assert check_guess(-50, 50) == "Too high!"
    assert check_guess(50, -50) == "Too low!"

test_generate_random_number(): This test checks if the generate_random_number(): function returns a random number between 1 and 100.

test_check_guess(): This test verifies the behavior of the check_guess() function by passing different combinations of random numbers and guesses. It checks if the function returns the correct response based on whether the guess is too low, too high, or correct.

test_check_guess_out_of_range(): This test checks if the check_guess() function handles out-of-range guesses correctly. It verifies if the function returns “Too low!” for a guess below 1 and “Too high!” for a guess above 100.

test_check_guess_invalid_input(): This test ensures that the check_guess() function raises a ValueError when an invalid input (e.g., a non-integer) is provided as a guess.

test_check_guess_large_range(): This test examines how the check_guess() function handles large ranges of random numbers and guesses. It checks if the function returns the correct response for guesses that are slightly below or above the random number.

test_check_guess_negative_numbers(): This test focuses on negative numbers as both random numbers and guesses. It verifies if the check_guess() function handles negative numbers correctly.

test_check_guess_negative_and_positive_numbers(): This test combines negative and positive numbers as random numbers and guesses to ensure that the check_guess() function handles such scenarios accurately.

These tests cover various scenarios and edge cases to ensure that the game logic functions as expected. You can add more test cases to further enhance the test coverage if needed.

Output:

Pytest flags

Pytest offers a variety of command-line flags (also called options) that you can use to customize the behavior of your test runs. These flags provide flexibility and control over how pytest executes your tests and reports the results. Here are some common pytest flags:

  • -v (--verbose): Increases the verbosity of test output, providing more details about test execution. Use this flag to see more information about each test case. When you run ``pytest -v`` it outputs:

  • -k EXPRESSION: This flag allows you to run tests that match a specific keyword expression. For example, you can run tests with names containing "add" by using ``pytest -k add``.

  • -q (--quiet): The opposite of verbose, this flag reduces the output verbosity and only displays essential information.

  • -x (--exitfirst): Stops test execution after the first failure, allowing you to focus on debugging the first encountered issue before running the remaining tests.

  • --maxfail=num: Specifies the maximum number of allowed test failures before pytest stops execution. For example, pytest --maxfail=2 will stop after two failures.

These are just a selection of commonly used pytest flags. You can explore more flags and options by running pytest --help in your terminal.

By using these flags strategically, you can tailor pytest's behavior to suit your testing needs and workflow.

Conclusion

I hope this article on pytest has helped get you started with writing tests for your Python code. We covered the installation process, how to write test cases, how to run tests, and some advanced features of pytest. Remember that testing is an essential part of software development, and pytest makes it easy and intuitive to write and run tests. With its powerful features and flexibility, pytest is a popular choice for testing Python code in the community.