Unit Testing in Python Simplified with PyTest and Sanic


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?
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)