Why Writing Test Cases in Python and Django is Important: A Simple Guide with pytest

The CodeCrafter
5 min readSep 8, 2024

--

In software development, it’s important to make sure your code works correctly. One of the best ways to do this is by writing test cases. In Python, and especially in Django projects, writing tests helps you catch bugs early and make sure your code stays reliable, even when you make changes. Let’s look at why testing is important and how you can use a tool called pytest to write simple tests.

django-testcase

Why Should You Write Test Cases?

1. Ensure Code Works Correctly: Test cases check if your code is working as expected. This helps you find and fix problems before they become big issues.

2. Avoid Breaking Code: When you add new features or make changes, test cases make sure that you don’t accidentally break anything that was already working.

3. Easier to Improve Code: Tests give you confidence when you improve or change your code because they help you quickly find any new problems.

4. Act as Documentation: Tests show how your code should behave in different situations, making it easier for others to understand your code.

5. Save Time: Automated tests run quickly and help you find problems faster, so you don’t have to test everything manually.

What is pytest?

pytest is a testing tool that makes writing and running tests in Python easy. It has many useful features like:

  • Simple syntax to write tests.
  • Detailed reports when tests pass or fail.
  • Powerful tools to handle complex tests.

A Simple Example Using pytest

Let’s start with a simple Python function that adds two numbers:

# function_to_test.py
def add_numbers(a, b):
return a + b

Now, let’s write a test for this function using pytest:

# test_function_to_test.py
from function_to_test import add_numbers

def test_add_numbers():
assert add_numbers(2, 3) == 5
assert add_numbers(0, 0) == 0
assert add_numbers(-1, 1) == 0

To run this test, you can type the following command in your terminal:

pytest test_function_to_test.py

pytest will automatically find and run the test, showing you if it passes or fails.

Handling Special Cases (Edge Cases)

It’s important to write tests for edge cases, or special situations, that might break your code. For example, what happens if someone tries to add non-numbers?

Here’s how you can update the function to handle invalid input:

# function_to_test.py
def add_numbers(a, b):
if not (isinstance(a, (int, float)) and isinstance(b, (int, float))):
raise ValueError("Both inputs must be numbers")
return a + b

Now, we can write more tests to handle these special cases:

# function_to_test.py

def add_numbers(a, b):
if not (isinstance(a, (int, float)) and isinstance(b, (int, float))):
raise ValueError("Both inputs must be numbers")
return a + b

In these tests, we check both valid input and invalid input. We use pytest.raises to check that the function raises a. ValueError when it gets non-number inputs.

Advanced pytest Features

1. pytest Fixtures: Reusable Test Setup

Fixtures are a powerful way to set up and tear down data needed for your tests. For example, if multiple tests need the same database object or a shared resource, you can create a fixture to provide that data to all the relevant tests.

Setting Up a Database Fixture

# conftest.py
import pytest
from myapp.models import User

@pytest.fixture
def create_user(db):
user = User.objects.create(username='test_user', email='user@example.com')
return user

Now, you can use this fixture in your test functions:

# test_models.py
def test_user_creation(create_user):
assert create_user.username == 'test_user'
assert create_user.email == 'user@example.com'

Here, create_user is automatically available in any test that needs a user object. pytest makes sure the fixture runs before the test and cleans up afterward.

2. Parameterized Tests: Testing Multiple Scenarios Easily

Parameterized tests let you run the same test function with different sets of data, making it easy to test multiple scenarios without duplicating code.

Testing Multiple Input Combinations

# test_math.py
import pytest
from math_operations import add_numbers

@pytest.mark.parametrize("a, b, expected", [
(2, 3, 5),
(0, 0, 0),
(-1, 1, 0),
(1.5, 2.5, 4)
])
def test_add_numbers(a, b, expected):
assert add_numbers(a, b) == expected

In this case, pytest.mark.parametrize runs the test_add_numbers function with multiple values for a, b, and expected. This is an efficient way to test different scenarios without writing separate test functions.

3. Handling Edge Cases and Exceptions

It’s important to test for unusual or “edge” cases that could cause your code to break. For example, you can test whether the function raises an appropriate error when given invalid input.

Testing for Exceptions

# test_math.py
import pytest
from math_operations import add_numbers

def test_add_numbers_invalid_input():
with pytest.raises(ValueError):
add_numbers('a', 2)

Here, pytest.raises ensures that a ValueError is thrown when invalid data is passed to the add_numbers function.

4. Using pytest Coverage to Ensure Complete Testing

It’s important to know how much of your code is actually being tested. pytest-cov is a tool that measures test coverage, showing you how much of your code is covered by tests.

To use it, install pytest-cov:

pip install pytest-cov

Then run your tests with coverage reporting:

pytest --cov=myapp

This will show which parts of your code were tested and which weren’t, helping you find untested areas.

5. Testing Django Views and APIs

When working with Django, you will often need to test views, including API endpoints. Here’s how you can test Django views using the Django test client, which simulates requests to your views.

Testing a Django API View

# test_views.py
import pytest
from django.urls import reverse

@pytest.mark.django_db
def test_api_get_user(client):
url = reverse('get_user', kwargs={'pk': 1})
response = client.get(url)

assert response.status_code == 200
assert 'username' in response.json()

In this example, we use client.get() to make an HTTP GET request to the get_user API endpoint. pytest.mark.django_db is used to access the database for testing.

6. Mocking External Dependencies

Sometimes, your code interacts with external systems (e.g., third-party APIs), which makes it harder to test. pytest-mock allows you to mock these dependencies, simulating their behavior without actually making the external calls.

Mocking an External API Call

# test_external_api.py
import pytest
from unittest.mock import patch
from external_service import fetch_data_from_api

@patch('external_service.requests.get')
def test_fetch_data(mock_get):
mock_get.return_value.status_code = 200
mock_get.return_value.json.return_value = {'key': 'value'}

result = fetch_data_from_api()
assert result == {'key': 'value'}

By using patch(), you can replace the actual API call with a mock object, allowing you to test how your code behaves without depending on the external service.

What Special Cases Should You Test?

  • Invalid Input: Test what happens if the function gets the wrong type of input.
  • Boundary Values: Test the smallest and largest numbers to see if the function handles them correctly.
  • Empty Input: If the function takes lists or other collections, test what happens if the input is empty.

Conclusion

Writing test cases is a very important part of creating reliable and maintainable code. By using tools like pytest, you can easily write tests that save you time and help you find problems quickly. Testing should be part of your development process from the beginning, so you can catch issues early and keep your code working smoothly.

--

--

The CodeCrafter

Writer | Python | Software Engineer | Deep Learning | Natural Language Processing | Mentor | Machine Learning |