Skip to content

Unit Testing#

2024 iThome Ironman

This is the 29th article in the Django Ninja tutorial series.

"Does your project have unit tests?"

If you ask this question during an interview, it might make the interviewer feel a bit awkward.

Most developers are well aware of the importance of testing. It's just that not many are willing to take it seriously.

However, if you truly want to improve code quality, reduce bugs, and make the project easier to maintain, unit testing remains an indispensable tool.

Good testing not only helps us discover problems early, but also ensures that existing features are not broken when the project is refactored or new features are added.

Although writing tests increases initial development time and requires effort to maintain—which is never an easy task—in the long run, it brings continuous health and stability to the project.

So, let's write tests properly!

GitHub Example Project#

👉 Django-Ninja-Tutorial


Table of Contents#

This is the only tutorial in the entire series that has a full-text outline.

The reason is that this article has a great deal to cover. How could a topic as large as Unit Testing fit into a single 2,500-word article? Space prevents us from discussing every detail, but we cannot omit the subject altogether.

Therefore, a bird's-eye view of the entire outline is needed for readers to easily understand and absorb. The outline is as follows:

  1. The Ideal and Reality of Unit Testing.
  2. Key Concepts in Django API Testing.
    • The Meaning and Purpose of Test Client.
    • Introduction to pytest and pytest-django.
    • pytest Fixtures and Test Functions.
  3. Implementation and Explanation of Test Code.
  4. Conclusion.

Simply put, this article will not explain all code changes, but will mention them when necessary. The rest is directly implemented by me and included in the example project for readers' reference.

In a limited space, understanding the overall concept is more important than focusing on details. Once you master the basic concepts, looking at the code will be much easier.

For more discussion on unit testing, please refer to this article: Why You Should Write Unit Tests — "Python Craftsman". This is a well-argued book, and I believe you will benefit from it.

For all code changes in this article, please refer to this PR.


I. The Ideal and Reality of Unit Testing#

I think discussing unit testing requires facing reality first.

The software testing field is full of various enthusiasms and dogmas about testing, which sometimes discourages people instead.

The Ideal of Unit Testing#

Theoretically, writing unit tests should be done by every developer (I certainly think so).

In addition, there is the concept of "Test-Driven Development (TDD)", which is a test-led development pattern that requires writing tests before writing functional code.

A few people even believe that test coverage must be 100%. Because if it is not 100%, say 70%, then we can ask, "Why not some other number?"

Reality: Most People Do Not Care About Those Ideals#

However, in reality, we rarely see ideal tests—often, there are no tests at all.

Due to constraints such as time and resources, projects in reality are often reluctant to invest effort in writing tests.

In some legacy projects, because there was no test foundation in the early stage, it becomes more difficult to add tests later (after all, it's all a mess 💩), which is what we often call "technical debt".

Furthermore, excessive "test idealism" sometimes deters beginners. Many beginners worry that they cannot achieve 100% coverage when they first start testing, resulting in resistance or skepticism toward testing.

Such perfectionism is often more harmful than helpful, and we need to find a compromise between the ideal and reality.

Compromise: A Pragmatic Testing Strategy#

In real development, we should adopt a pragmatic and achievable testing strategy, focusing on the project's most important behavior, such as API calls and successful Responses.

In most cases, as long as 60-70% of features can be covered, it can significantly improve the quality of the project code and provide a certain sense of security for subsequent development—this is truly important.

There is no need to chase perfect test coverage. As long as you start writing tests, they can deliver real value.


II. Key Concepts in Django API Testing#

Back to the project itself.

Although this article cannot mention too many details of API unit testing, important concepts should not be omitted. They are explained below.

The Meaning and Purpose of Test Client#

A Test Client is crucial for API testing because it simulates HTTP Requests without sending them over the network.

API testing differs slightly from general code testing. In addition to test functions and assertions, API tests need a mock client that can simulate Requests.

To test APIs manually, we usually use an API client, such as Postman. Automated unit testing, on the other hand, requires writing this "mock client" directly in the test code—that is, the test client.

It acts as an "internal API client of the project" and can run automatically.

Django Ninja provides its own test client, but I suggest you avoid using it for now because it is not mature enough.

In the example project, I use Django's built-in test client—time-tested, stable, and reliable.

Introduction to pytest#

pytest (yes, its 'p' is lowercase, same as pyenv) is a widely popular Python testing framework with its own ecosystem—containing a large number of practical plugins.

Compared to Python's built-in unittest module, pytest's syntax is more intuitive and offers better flexibility. Especially its fixtures, parameterized testing, and other features make writing tests simpler and more efficient.

pytest-django#

pytest-django is a pytest integration package designed specifically for Django. It provides rich Django integration features, including many built-in fixtures and practical decorators.

Among them, the @pytest.mark.django_db decorator is the most commonly used, which automatically manages the database state during testing.

It allows pytest to automatically create a brand new database before each test run and delete it after the test ends. This ensures that the environment is consistent for each test, preventing inaccurate test results caused by residual data.

pytest Fixtures and Test Functions#

Fixtures are a mechanism provided by pytest to set up the initial environment required for testing. They are essentially functions, but they are not used like general functions. Once defined, they can be referenced as parameters in test functions.

Fixtures can be defined in a Django app's tests.py, but we usually place them in the conftest.py module, which is shared across the entire project.

When testing APIs, we often need some initial data, such as users and products. This data can be automatically generated through fixtures without having to recreate it manually every time.

This improves the efficiency of writing tests and avoids repetitive state setups.


III. Implementation and Explanation of Test Code#

In this article, I implemented 3 fixtures and 3 test functions, all of which are related to users. Let me explain the details of key points.

Powerful and Flexible pytest Fixtures#

These are the 3 fixtures of the project, defined in conftest.py: (I omitted the docstrings to save space)

import pytest
from django.test import Client
...

@pytest.fixture(scope='session')
def client() -> Client:
    return Client()

@pytest.fixture
def user() -> User:
    return User.objects.create_user(
        username='testuser',
        email='testuser@example.com',
        password='testpassword123'
    )

@pytest.fixture
def authenticated_client(client: Client, user: User) -> Client:
    response = client.post(
        '/users/login/',
        {'username': 'testuser', 'password': 'testpassword123'},
        content_type='application/json',
    )
    assert response.status_code == 200
    # Set cookies after login
    client.cookies.update(response.cookies)
    return client

In this code:

  1. client: Provides a Django test client that can be used to simulate sending requests. (Unauthenticated)
  2. user: Automatically creates a test user for test functions or other fixtures to reference.
  3. authenticated_client: References the two fixtures above, combining and simulating a logged-in client to test APIs protected by authentication.

The definition, combination, and usage of fixtures is a major feature of pytest.

It not only simplifies the test environment setup but also improves the readability of test code—separating test state from test logic, which is also a form of "separation of concerns".

In test functions, we need only pass the required Fixtures as parameters; pytest automatically handles their initialization and cleanup.

This design significantly reduces boilerplate code, letting tests focus on API logic validation rather than environment setup.

Test Functions#

Finally, the test functions. Let's look at two of them: (I omitted the parameter type hints so you can focus on the fixtures themselves)

def test_get_users(authenticated_client) -> None:
    """
    Test retrieving all users
    """
    response = authenticated_client.get('/users/')
    assert response.status_code == 200

def test_login_user(client, user) -> None:
    """
    Test user login
    """
    response = client.post(
        '/users/login/',
        data={'username': 'testuser',
              'password': 'testpassword123'},
        content_type='application/json',
    )
    assert response.status_code == 200

Choosing these two functions has pedagogical purposes:

  1. The test_login_user function tests the "user login" API, which is accessible to "unauthenticated" users, so referencing the general client (unauthenticated) is sufficient.
    • The function also references the user fixture, because the premise of a successful login is that the user already "exists".
    • And the role of the user fixture is to create the user before the test starts.
  2. test_get_users tests the "authentication-protected" API, which requires logging in to access, so we reference authenticated_client.
    • This test function "only" references authenticated_client, but the actual test result will show that a user exists in the list (code not shown here).
    • Because authenticated_client references both user and client fixtures, simply referencing authenticated_client is equivalent to referencing both.

Which fixtures to reference depends on what kind of test state and conditions each function needs.

Fixtures themselves are reusable, and this design makes tests highly "modular"—which is one of the reasons pytest is so popular.

Running Unit Tests#

Finally, let's run the tests!

You can run the pytest command directly in the root directory of the project, or run unit tests through VS Code's Testing UI:

VS Code - Testing

Beautiful!


IV. Conclusion#

There is always a gap between the ideal and reality. Through a pragmatic testing strategy, we can provide sufficient quality assurance for the project without excessively chasing perfection.

Tools like test client and pytest make API testing simple and organized. Test coverage does not have to be 100%; as long as it reaches a certain level, it can bring huge benefits to the development process.

This tutorial series is nearing its end. We explored the core and advanced features of Django Ninja—from routing design to unit testing. This has been a tough but rewarding process—for both you and me.

The next article will be the final one. We will briefly review the entire series and share my experience of creating and completing this Ironman competition.