Unit Testing in Python Simplified with PyTest and Sanic

LakshayLakshay
9 min read

Speed and reliability are two of the most sought-after skills when selecting a fast-paced software engineer. Engineers are expected to deliver quickly and get the code right on the first try. In such an agile environment, quality control measures often take a backseat. So, how can you, as an engineer, balance speed with quality? The answer is Test Driven Development (TDD). Unit testing helps discover potential bugs early and gives you the confidence to ship new features. In today’s blog, I will guide you through unit testing in Python with easy-to-follow code samples. Make sure to read until the end, as we will discuss the role of unit testing in catching bugs early.

Why is Testing Needed?

Quality control measures like black box testing and integration testing are crucial for catching critical bugs that impact user flows. However, they can be time-consuming and often get rushed when deadlines are tight. This happens more frequently with lean and high-performing teams. So, how do you ensure overall system stability without relying solely on your QA team? Developer testing is the answer. Think of them as tests written at the code level. With modern testing tools, they are quick to run and analyze. Instead of manually testing all the flows, you can run these test suites locally to ensure no major flows are breaking. These tests focus on specific pieces of code and are relatively easier to write. The mental shift towards writing tests before actual code is known as test-driven development.

Testing in Python with Sanic and Why It Can Be Challenging

Sanic is an asynchronous framework in Python for building fast and highly scalable systems. However, there is limited documentation on testing your Sanic application. While the official Sanic documentation provides some examples, they lack depth and diverse coding examples. Additionally, resources for testing async Sanic apps are even scarcer. In this blog, we will cover testing an eCommerce application using Sanic and PyTest.

Setting Up the App

For the purpose of this blog, imagine we have an eCommerce application that sells bamboo t-shirts. Let's focus on the code responsible for placing orders.

class OrderPlacementManager:
    def __init__(self, user, cart, amount_payable):
        self.user = user
        self.cart = cart
        self.amount_payable = amount_payable

    async def place_order(self):
        """
        Place an order by validating the cart, processing payment, and creating the order.
        """
        self.validate_cart()
        payment_mode = self.get_payment_mode()

        order_id = await self.generate_order_id()
        order = await self.create_order(
            order_id=order_id,
            user=self.user,
            cart=self.cart,
            amount_payable=self.amount_payable,
            payment_mode=payment_mode
        )

        return order

    def validate_cart(self):
        """
        Validate the cart to ensure it is not empty and contains valid items.
        """
        if not self.cart or len(self.cart) == 0:
            raise ValueError("Cart is empty. Cannot place an order.")

    def get_payment_mode(self):
        """
        Get the payment mode for the order.
        """
        return "COD" if self.amount_payable > 0 else "ONLINE"

The OrderPlacementManager class is responsible for placing orders. Any unexpected change in this class can directly impact order creation. Now, let's write unit test cases for the place_order function using the pytest-asyncio library. If you don’t have it installed, you can do so with the following command:

pip install pytest-asyncio

Setting Up the Testing Files

We begin by importing the required libraries and setting up the test directory. For PyTest to recognize them, the file name should begin with “test_[identifier of test]”, for example, test_order_creation.py. This convention is also followed when naming classes and marking functions for testing with PyTest. Here’s a sample file:

import pytest # the testing library
from unittest.mock import AsyncMock, Mock
from app.managers.order_placement_manager import OrderPlacementManager # the manager to test

class TestOrderPlacementManager:

    def test_validate_cart_with_empty_cart(self):
        manager = OrderPlacementManager(user="test_user", cart=[], amount_payable=100.0)
        with pytest.raises(ValueError, match="Cart is empty. Cannot place an order."):
            manager.validate_cart()

    def test_validate_cart_with_non_empty_cart(self):
        manager = OrderPlacementManager(user="test_user", cart=["item1", "item2"], amount_payable=100.0)
        # Should not raise any exception
        manager.validate_cart()

In the first test case, we check if the cart validation function raises an error for empty input. By using pytest.raises, we expect the function manager.validate_cart() to raise a ValueError, and we validate that part. In the second test case, we ensure the validation function does not throw any error for proper input.

Running the tests

To run these tests, use the following command in your terminal. Pytest automatically identifies the files with test prefix in the file name and execute the available test functions inside them

pytest

As we can see, both of our test cases passed successfully. Now, let's expand our scope to test the payment mode function.

    async def place_order(self):
        """
        Place an order by validating the cart, processing payment, and creating the order.
        """
        self.validate_cart()
        payment_mode = self.get_payment_mode()

        if payment_mode == "COD":
            payment_status = "pending"
        else:
            await self.payment_processor.process_payment(self.amount_payable)
            payment_status = "completed"

        # existing implementation

    def get_payment_mode(self):
        """
        Get the payment mode for the order.
        """
        return "COD" if self.amount_payable > 0 else "ONLINE"

We have introduced a payment processor to handle online payments. To distinguish between online and Cash-on-Delivery payments, it is important for the get_payment_mode function to work as expected. Let’s add some unit test cases for the same:

class TestOrderPlacementManager:    

    def test_get_payment_mode_cod(self):
        manager = OrderPlacementManager(user="test_user", cart=["item1"], amount_payable=100.0)
        assert manager.get_payment_mode() == "COD"

    def test_get_payment_mode_online(self):
        manager = OrderPlacementManager(user="test_user", cart=["item1"], amount_payable=0.0)
        assert manager.get_payment_mode() == "ONLINE"

Notice how we pass dummy values as arguments when initializing the manager. These are referred to as mock data, used to test the core logic of the function. Depending on the complexity of the function, it can be hardcoded or generated dynamically for testing. Now, let us see if the newly added test cases are running as expected.

Test Mocks

Great, let’s move on to the next step: testing the order placement function as a whole. This function is critical and depends on other modules like the PaymentProcessor. While unit testing, you may want to focus on a specific piece of code. You can achieve this by mocking the response of such function calls. This is where Mock comes into play. Here’s an example:

from unittest.mock import AsyncMock
import pytest

class TestOrderPlacementManager:

    @pytest.mark.asyncio
    async def test_place_order(self):
        manager = OrderPlacementManager(user="test_user", cart=["item1", "item2"], amount_payable=100.0)

        # Mock generate_order_id and create_order
        manager.generate_order_id = AsyncMock(return_value="ORDER123")
        manager.create_order = AsyncMock(return_value={"order_id": "ORDER123", "status": "created"})

        # Call place_order
        order = await manager.place_order()

        manager.generate_order_id.assert_called_once()
        manager.create_order.assert_called_once_with(
            order_id="ORDER123",
            user="test_user",
            cart=["item1", "item2"],
            amount_payable=100.0,
            payment_mode="COD"
        )

We use the @pytest.mark.asyncio decorator on this test function because, unlike other test cases, this one tests an asynchronous function. This decorator allows PyTest to set up an event loop and execute this asynchronous function for us.

Method Mocking

# Mock generate_order_id and create_order
manager.generate_order_id = AsyncMock(return_value="ORDER123")
manager.create_order = AsyncMock(return_value={"order_id": "ORDER123", "status": "created"})

Here, we specify that whenever the generate_order_id function is called, it should return a coroutine mock object with a value of “ORDER123” instead of executing its actual implementation. Notice that we used AsyncMock instead of Mock, as generate_order_id is an async function.

Mocking the value of a dependency allows us to test specific parts of our code. This is referred to as unit testing, as we are testing by breaking our code into small units. When we test multiple units of our code together, it is referred to as integration testing.

In the above example, we passed return_value as an argument while initializing the AsyncMocks. In scenarios where you want these functions to raise an error, we can use side effects. For example:

class TestOrderPlacementManager:
    @pytest.mark.asyncio
    async def test_place_order(self, mocked_generate_order_id):
        manager = OrderPlacementManager(user="test_user", cart=["item1", "item2"], amount_payable=100.0)
        manager.validate_cart = Mock(side_effect=ValueError("Cart is empty. Cannot place an order."))

        # Call place_order
        with pytest.raises(ValueError, match="Cart is empty. Cannot place an order."):
            await manager.place_order()

Mocking using Patch

Another way of mocking the value of a method is by using the @patch decorator:

"""
syntax for @patch decorator
"""

@patch.object(Object to patch, target method, return value [optional)mocked_generate_order_id.assert_called_once()
@pytest.mark.asyncio
@patch.object(OrderPlacementManager, 'generate_order_id', return_value="ORDER123")
async def test_place_order(self, mocked_generate_order_id):
    manager = OrderPlacementManager(user="test_user", cart=["item1", "item2"], amount_payable=100.0)
    mocked_generate_order_id.assert_called_once()

The patch decorator, based on the target (generate_order_id in our case), automatically decides whether to return a Mock or an AsyncMock. This helps reduce our efforts and speeds up the process of writing tests. We have to pass a reference to the mocked function as part of the function signature. As a result, we can then use the mocked_generate_order_id variable to update its return value or assert some facts using this instance.

Running All Test Cases

import pytest
from unittest.mock import AsyncMock, Mock, patch
from app.managers.order_placement_manager import OrderPlacementManager

class TestOrderPlacementManager:
    def test_validate_cart_with_empty_cart(self):
        manager = OrderPlacementManager(user="test_user", cart=[], amount_payable=100.0)
        with pytest.raises(ValueError, match="Cart is empty. Cannot place an order."):
            manager.validate_cart()

    def test_validate_cart_with_non_empty_cart(self):
        manager = OrderPlacementManager(user="test_user", cart=["item1", "item2"], amount_payable=100.0)
        # Should not raise any exception
        manager.validate_cart()

    def test_get_payment_mode_cod(self):
        manager = OrderPlacementManager(user="test_user", cart=["item1"], amount_payable=100.0)
        assert manager.get_payment_mode() == "COD"

    def test_get_payment_mode_online(self):
        manager = OrderPlacementManager(user="test_user", cart=["item1"], amount_payable=0.0)
        assert manager.get_payment_mode() == "ONLINE"

    @pytest.mark.asyncio
    @patch.object(OrderPlacementManager, 'generate_order_id', return_value="ORDER123")
    async def test_place_order(self, mocked_generate_order_id):
        manager = OrderPlacementManager(user="test_user", cart=["item1", "item2"], amount_payable=100.0)
        manager.create_order = AsyncMock(return_value=Mock())

        order = await manager.place_order()

        mocked_generate_order_id.assert_called_once()
        manager.create_order.assert_called_once_with(
            order_id="ORDER123",
            user="test_user",
            cart=["item1", "item2"],
            amount_payable=100.0,
            payment_mode="COD"
        )

Catching Issues with Tests

What good are tests if they can’t catch issues? Let us see what happens when a new modification is made to our original order placement manager, introducing a bug.

def get_payment_mode(self):
    """
    Get the payment mode for the order.
    """
    # Introduced bug: Incorrect condition for determining payment mode
    return "COD" if self.amount_payable >= 0 else "ONLINE"

Say someone modified the get_payment_mode function by replacing the strictly greater than condition (>) with a flexible greater than or equal to condition (>=). This will result in ONLINE orders being marked as COD. Let’s try running our test cases after this change.

As we can see, one of our test cases failed, and rightfully so. This might have been missed if the developer was focused on testing whether the orders are being created or not. They may have missed checking the scenario regarding payment mode. However, having unit test cases helped flag this bug early, thus resulting in less damage.

For this reason, in many projects, these tests are part of the CI/CD pipeline. Every code change must pass the testing pipeline before being deployed to production. It helps flag faulty code, which often gets missed when multiple people are working on the same module.

Here’s a link to the project repository. Feel free to clone it and play around.

Conclusion

In this blog, we covered the need for developer-driven testing. We discussed how to perform unit testing of specific modules in a Python Sanic application using PyTest. AsyncMocks and the pytest.mark decorator are some of the many utilities provided in Python for testing asynchronous code. We also looked at how unit tests can help catch bugs early during development and save a lot of testers' bandwidth.

I hope this blog helps fill part of the void regarding insufficient coding samples for testing in Sanic. While this blog focused on testing modules, in the next blog, we will cover testing endpoints in a Sanic application. So make sure you follow me to never miss an update. If you liked this blog, please don’t forget to like it. If you have any other concerns, feel free to drop a comment.

Oh, by the way, what are your thoughts about writing test cases as a developer? Do you think this is something that should be handled by a tester? What are your thoughts on the same?

0
Subscribe to my newsletter

Read articles from Lakshay directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Lakshay
Lakshay

My friends used to poke me for writing long messages while texting. I thought why not benefit from it? I have led communities, small startups, hackathon teams. Made web apps, NFTs, youtube vlogs.. yes, I am an engineer (almost)