← ProgrammingBack to top

pytest: Python automated testing

1. Basic concepts

pytest is an automated testing framework widely used in the Python ecosystem.

Mainly used for verification:

  • Is the business logic correct?
  • Whether API is returned as expected.
  • Whether the authentication and permissions are valid.
  • Is the database operation correct?
  • Are exceptions and edge cases handled correctly.
  • Whether existing functions are destroyed after modifying the code (Regression Testing).

Basic process:

write code
    ↓
Write tests
    ↓
run pytest
    ↓
Execute test cases
    ↓
Pass / failed

Basic example

Business code:

def add(a, b):
    return a + b

Test:

def test_add():
    assert add(1, 2) == 3

If the business code is incorrectly modified to:

def add(a, b):
    return a - b

The test will fail:

AssertionError: assert -1 == 3

pytest uses Python natively assert and provides detailed failure information.

2. Installation and project configuration

Django + DRF projects are commonly used:

pip install pytest pytest-django pytest-mock pytest-cov
Toolsfunction
pytestBasic testing framework
pytest-djangoIntegrated Django test environment
pytest-mockProvide mocker Fixture
pytest-covStatistical code test coverage

pytest.ini

Create in the project root directory:

[pytest]
DJANGO_SETTINGS_MODULE = config.settings
python_files = tests.py test_*.py *_tests.py
testpaths = tests apps common/tests
addopts = --strict-config --strict-markers --import-mode=importlib

Among them, testpaths needs to be adjusted according to the actual project directory, and the listed directories should exist.

Important configuration:

  • DJANGO_SETTINGS_MODULE: Specify Django Settings.
  • python_files: Specify test file naming rules.
  • testpaths: Specifies the default directory to search for tests.
  • strict-config: Strictly check the configuration.
  • strict-markers: Prevents the use of unregistered custom Marker.
  • import-mode=importlib: Use importlib to import test modules to reduce path and module naming conflicts.

3. Commonly used running commands

# Execute all tests
pytest

# Execute specified file
pytest tests/test_user.py

# Execute specified function
pytest tests/test_user.py::test_get_profile

# Show details
pytest -v

# Stop after first failure
pytest -x

# Filter by name
pytest -k profile

# show print output
pytest -s

# Only execute the last failed test
pytest --lf

# Show the slowest 10 tests
pytest --durations=10

4. Fixture: Test dependencies and environment preparation

Fixture is used to reuse test data, dependent objects and environment initialization logic.

Use:

import pytest


@pytest.fixture
def user(db):
    return User.objects.create_user(
        email="test@example.com",
        password="password123",
    )

Test function:

def test_user_email(user):
    assert user.email == "test@example.com"

pytest will automatically find and execute Fixture based on the function parameter name, and then inject the return value into the test function.

Fixture dependency

Fixture can depend on other Fixture:

@pytest.fixture
def api_client():
    return APIClient()


@pytest.fixture
def authenticated_client(api_client, user):
    api_client.force_authenticate(user=user)
    return api_client


@pytest.fixture
def itinerary(db, user):
    return Itinerary.objects.create(
        user=user,
        title="Original Trip",
    )

Use:

def test_update_itinerary(
    authenticated_client,
    itinerary,
):
    response = authenticated_client.patch(
        f"/api/v1/itineraries/{itinerary.id}/",
        {"title": "Tokyo Trip"},
        format="json",
    )

    assert response.status_code == 200

It is assumed here that the interface allows the user to modify his or her itinerary.

Execution process:

test_update_itinerary
          ↓
authenticated_client ──→ api_client
          │
          └──→ user
                 ↓
              Database

itinerary ──→ user
          └─→ Database

In the same test, Fixture in the same scope is usually executed only once, and the results can be reused by other dependencies.

conftest.py

When multiple test files share Fixture, it can be defined in:

tests/
├── conftest.py
├── test_users.py
├── test_pois.py
└── test_itineraries.py

Fixture in conftest.py will be automatically discovered by tests in the corresponding directory range without explicit import.

Fixture Scope

Scopelife cycle
functionEach test function is executed once, by default
classExecuted once per test class
moduleExecuted once per test module
packageExecuted once per test package
sessionThe entire pytest test session is executed once

For example:

@pytest.fixture(scope="module")
def sample_config():
    return {"timeout": 30}

For Fixture involving the Django database, the larger Scope cannot be used arbitrarily, and the life cycle and data isolation of the database Fixture need to be considered.

yield Fixture

Used when resources need to be cleaned up:

@pytest.fixture
def temporary_file(tmp_path):
    file_path = tmp_path / "test.txt"
    file_path.write_text("hello")

    yield file_path

    file_path.unlink(missing_ok=True)

yield is responsible for initialization before, and cleaning after.

5. Unit test (Unit Test)

Unit testing mainly verifies the business logic of a single function or module.

def calculate_score(a, b):
    return a * 2 + b

Test:

def test_calculate_score():
    assert calculate_score(3, 4) == 10

Features:

  • Usually does not rely on a database.
  • Execution speed is fast.
  • Easy to locate the problem.
  • Suitable for testing pure business logic such as calculation, conversion, and verification.

It is recommended to split complex business calculations into independent functions whenever possible for unit testing.

6. Django database test

pytest-django By default, database access that is not explicitly authorized is prohibited.

You can use:

import pytest


@pytest.mark.django_db
def test_create_user():
    user = User.objects.create(
        username="alice"
    )

    assert user.username == "alice"
    assert User.objects.count() == 1

You can also rely on the built-in Fixture:

def test_create_user(db):
    user = User.objects.create(
        username="alice"
    )

    assert User.objects.filter(
        username="alice"
    ).exists()

Both methods allow database access.

test database

pytest-django typically uses the Django test database mechanism to create independent test databases and isolate data between tests.

The production database will not be modified directly under normal configuration, but the test environment and database connection configuration must be set up correctly.

database transaction

Common django_db tests typically use mechanisms such as transaction rollback to isolate data.

If you need to test real transaction commit, on_commit() and other behaviors:

@pytest.mark.django_db(transaction=True)
def test_transaction():
    ...

This mode uses a transaction testing mechanism, which is typically slower than normal database testing.

Reuse test database

pytest --reuse-db

Test databases can be retained between multiple test runs, reducing the overhead of repeated database creation.

After the model structure or Migration changes, you can use:

pytest --reuse-db --create-db

Force the test database to be recreated.

7. API integration test (Integration Test)

Django REST Framework provides APIClient, which emulates HTTP requests.

from rest_framework.test import APIClient

Fixture:

@pytest.fixture
def api_client():
    return APIClient()

Test:

@pytest.mark.django_db
def test_get_profile(api_client, user):
    api_client.force_authenticate(user=user)

    response = api_client.get(
        "/api/v1/users/me/"
    )

    assert response.status_code == 200
    assert response.data["email"] == user.email

Test process:

APIClient
    ↓
Django URL Routing
    ↓
Authentication / Permissions
    ↓
View
    ↓
Serializer
    ↓
Database
    ↓
Response

What is simulated here is the request processing inside Django/DRF, and there is no need to start the real HTTP server.

Therefore it can test the interface logic, but it does not mean that Nginx, TLS, the browser or the complete network transmission link has been tested.

POST test

response = api_client.post(
    "/api/v1/pois/",
    {
        "name": "Tokyo Tower",
        "score": 90,
    },
    format="json",
)

assert response.status_code == 201

PATCH test

response = api_client.patch(
    "/api/v1/pois/1/",
    {"name": "New Name"},
    format="json",
)

DELETE test

response = api_client.delete(
    "/api/v1/pois/1/"
)

assert response.status_code == 204

8. Authentication and authority testing

Authentication (Authentication) verifies the user's identity, and permissions (Authorization) determine whether the user can perform operations.

These two should be tested separately.

Access without login

@pytest.mark.django_db
def test_anonymous_access(api_client):
    response = api_client.get(
        "/api/v1/users/me/"
    )

    assert response.status_code in (401, 403)

DRF Under different authentication class configurations, unauthenticated requests may return 401 or 403.

Formal tests are better off asserting the exact status code based on the project configuration, rather than allowing both in the long run.

Logged in to access

@pytest.mark.django_db
def test_authenticated_access(api_client, user):
    api_client.force_authenticate(user=user)

    response = api_client.get(
        "/api/v1/users/me/"
    )

    assert response.status_code == 200

Object level permissions

Assume that users can only modify their own itinerary:

@pytest.mark.django_db
def test_cannot_update_other_user_itinerary(
    api_client,
    user,
    other_user,
    other_user_itinerary,
):
    api_client.force_authenticate(user=user)

    response = api_client.patch(
        f"/api/v1/itineraries/{other_user_itinerary.id}/",
        {"title": "Unauthorized"},
        format="json",
    )

    assert response.status_code in (403, 404)

    other_user_itinerary.refresh_from_db()

    assert other_user_itinerary.title != "Unauthorized"

403 or 404 depending on the resource visibility design of API.

In addition to checking status codes, permission testing should also check whether unexpected modifications have occurred in the database.

force_authenticate and real authentication

api_client.force_authenticate(user=user)

It will force the user to be in the authenticated state, bypassing the verification logic of the actual authentication class.

Suitable for testing:

  • Whether authenticated users can access.
  • Object-level permissions.
  • Business rules.
  • Data updates.

But cannot verify:

  • Is Session Cookie correct?
  • JWT signature and expiration verification.
  • Is the login process normal?
  • Is CSRF effective?

Session Certification Test

For Django Session based authentication, you can use:

api_client.login(
    email="test@example.com",
    password="password123",
)

login() uses Authentication Backend configured with Django, so the parameters need to match the actual authentication method of the project.

CSRF test

APIClient for DRF does not enforce CSRF checks by default.

If you need to verify CSRF:

api_client = APIClient(
    enforce_csrf_checks=True
)

Then verify the corresponding request by logging in with real Session, carrying or missing CSRF Token.

Note: CSRF protection is related to the behavior of SessionAuthentication, and specific tests should be designed according to the certification architecture adopted by the project.

9. Parametric test (parametrize)

Using @pytest.mark.parametrize allows the same test function to automatically run multiple sets of data.

@pytest.mark.parametrize(
    "username",
    [
        "",
        "a",
        "x" * 101,
    ],
)
def test_invalid_username(username):
    ...

This is equivalent to performing three sets of tests.

Multi-parameter testing

@pytest.mark.parametrize(
    "a,b,expected",
    [
        (1, 2, 3),
        (0, 0, 0),
        (-1, 1, 0),
    ],
)
def test_add(a, b, expected):
    assert add(a, b) == expected

API Parametric Test

@pytest.mark.django_db
@pytest.mark.parametrize(
    "payload",
    [
        {"username": ""},
        {"username": "x" * 101},
        {"username": None},
    ],
)
def test_invalid_user_creation(
    api_client,
    payload,
):
    response = api_client.post(
        "/api/v1/users/",
        payload,
        format="json",
    )

    assert response.status_code == 400

This way of writing is very suitable for testing the field validation of Serializer.

10. Exception testing

Use pytest.raises() to verify whether the function throws the specified exception.

def parse_age(age):
    if age < 0:
        raise ValueError("Age must be positive")
    return age

Test:

def test_invalid_age():
    with pytest.raises(ValueError):
        parse_age(-1)

Verification error message:

def test_invalid_age():
    with pytest.raises(
        ValueError,
        match="Age must be positive",
    ):
        parse_age(-1)

Exception objects can also be checked:

def test_invalid_age():
    with pytest.raises(ValueError) as exc_info:
        parse_age(-1)

    assert str(exc_info.value) == (
        "Age must be positive"
    )

11. Mock: Simulate external dependencies

Mock is used to replace external dependencies that are not expected to be actually executed in the test.

Common scenarios:

  • External HTTP API.
  • Email sent.
  • Third party payment.
  • LLM API。
  • File storage.
  • Time-consuming tasks.
  • External services that are difficult to reproduce stably.

pytest-mock

mocker Fixture can be used after installing pytest-mock.

Business code:

# services/weather.py
import requests


def get_weather():
    response = requests.get(
        "https://api.example.com/weather",
        timeout=5,
    )
    response.raise_for_status()
    return response.json()

Test:

def test_get_weather(mocker):
    mock_get = mocker.patch(
        "services.weather.requests.get"
    )

    mock_get.return_value.json.return_value = {
        "temperature": 20
    }

    result = get_weather()

    assert result["temperature"] == 20
    mock_get.assert_called_once()

The core concept of Mock

methodfunction
mocker.patch()Replace the specified object
return_valueSet simulation return value
side_effectSet exceptions or custom behavior
assert_called_once()Verify call once
assert_called_with()Verify call parameters
assert_not_called()Verify not called
mocker.spy()Monitor real function calls

simulate exception

def test_weather_error(mocker):
    mock_get = mocker.patch(
        "services.weather.requests.get"
    )

    mock_get.side_effect = requests.Timeout()

    with pytest.raises(requests.Timeout):
        get_weather()

Important principles of Patch

Patch The location where the dependency should be found for the code under test is not necessarily where the dependency was originally defined.

For example, business code uses:

from services.email import send_email

Then the tests typically require Patch references in the send_email business module, rather than blindly Patch in the original module.

Limitations of Mock

Mock can improve test speed and stability, but does not prove that the external system is actually usable.

Therefore, a small number of integration tests or contract tests should be retained for critical third-party integrations.

12. Database query times test

Django can check how many times the test code has been executed SQL.

from django.test.utils import CaptureQueriesContext
from django.db import connection

For example:

@pytest.mark.django_db
def test_query_count():
    with CaptureQueriesContext(connection) as queries:
        list(POI.objects.select_related("city"))

    assert len(queries) <= 2

pytest-django also offers django_assert_num_queries Fixture:

def test_query_count(
    db,
    django_assert_num_queries,
):
    with django_assert_num_queries(1):
        list(
            POI.objects.select_related("city")
        )

Particularly useful for detecting N+1 Query issues.

For example, if an interface originally executed SQL twice, but after modifying Serializer, it becomes SQL 101 times, the problem can be discovered through the query count test.

Note: The number of queries may be affected by factors such as authentication, Session, caching, and database initialization. It is best to make assertions against stable and clear code paths.

13. Factory: Test Data Factory

When testing requires the creation of a large number of complex Model instances, you can use Factory tools such as factory_boy.

Normal Fixture:

@pytest.fixture
def user(db):
    return User.objects.create_user(
        email="test@example.com",
        password="password123",
    )

If there are a large number of test users, POI, itinerary and other models, duplicate codes will appear in manual creation.

factory_boy example

import factory


class UserFactory(factory.django.DjangoModelFactory):
    class Meta:
        model = User

    email = factory.Sequence(
        lambda n: f"user{n}@example.com"
    )
    password = factory.PostGenerationMethodCall(
        "set_password",
        "password123",
    )

Use:

@pytest.mark.django_db
def test_users():
    user1 = UserFactory()
    user2 = UserFactory()

    assert user1.email != user2.email

The difference between Factory and Fixture:

  • Fixture: Control the creation and life cycle of test dependencies.
  • Factory: Conveniently generate test objects with different properties.

Both can be used in combination.

14. Test coverage (Coverage)

Test coverage indicates how much code is covered during test execution.

Use pytest-cov:

pytest --cov=apps

Show missing rows:

pytest --cov=apps --cov-report=term-missing

Generate HTML report:

pytest --cov=apps --cov-report=html

will generate:

htmlcov/
└── index.html

Coverage Metrics

indicatormeaning
Line Coveragerow coverage
Branch Coveragebranch coverage
Function Coveragefunction coverage

Enable branch coverage:

pytest --cov=apps --cov-branch

Set minimum coverage threshold:

pytest --cov=apps --cov-fail-under=80

If coverage is less than 80%, the command fails.

Note: High coverage does not mean high test quality.

For example, if a test executes a certain piece of code but does not have valid check results, it is still possible to obtain a high coverage rate.

Rather than simply pursuing 100%, you should focus more on critical business logic and higher-risk code paths.

15. Test classification and priority

Common test types:

TypeTest contentFeatures
Unit TestSingle function or moduleFast and easy to locate
Integration TestCollaboration of multiple componentsCover real business processes
API TestHTTP interface behaviorVerify status code, data, permissions
Regression TestAre existing functions broken?Prevent refactoring from introducing errors
End-to-End TestComplete user operation processWide coverage, higher cost

For the Django + DRF project, it is recommended to give priority to testing:

  1. Key business logic.
  2. Serializer data validation.
  3. IMPORTANT Success and error responses for API.
  4. User authentication and object-level permissions.
  5. Whether the database modification is correct.
  6. Handling when external service call fails.
  7. Whether significant performance degradation occurs for critical queries.

Rather than writing separate tests for each simple Model field, priority should be given to covering functionality with business rules, branching logic, and risk of failure.

16. Run pytest in CI/CD

pytest is usually used as an automatic testing link in continuous integration (CI).

For example GitHub Actions:

name: Backend Tests

on:
  push:
  pull_request:

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"

      - name: Install dependencies
        run: pip install -r requirements.txt

      - name: Run tests
        run: pytest -v

      - name: Validate OpenAPI
        run: |
          python manage.py spectacular \
            --file openapi.yaml \
            --validate \
            --fail-on-warn

This is a simplified example, assuming the project test configuration uses SQLite, or is already able to connect to the required test database.

If the project depends on services such as PostgreSQL and Redis, the corresponding service container, environment variables and dependencies need to be additionally configured in CI.

Typical process:

Push / Pull Request
         ↓
GitHub Actions
         ↓
Install Dependencies
         ↓
      pytest
         ↓
  OpenAPI Validation
         ↓
      Build
         ↓
      Deploy

When tests fail, subsequent deployments can be prevented, preventing obviously problematic code from entering the production environment.

17. Test Writing Principles

1. Arrange / Act / Assert

It is recommended to organize testing according to three stages:

def test_add():
    # Arrange
    a = 1
    b = 2

    # Act
    result = add(a, b)

    # Assert
    assert result == 3

Corresponding respectively:

  • Arrange: Prepare data and environment.
  • Act: Execute the operation under test.
  • Assert: Verification result.

2. Testing should be independent

Different tests should not depend on the order of execution, nor should they depend on data produced by other tests.

3. Avoid excessive Mock

Only Mock the necessary external dependencies. Replacing every component with a fake object prevents tests from verifying real integration behavior.

4. Prioritize checking behavior, not implementation details

For example, when testing a API, you should usually focus on checking:

assert response.status_code == 200
assert response.data["name"] == "Tokyo"

Instead of relying too much on how many times an internal function is called.

5. Cover both success and failure scenarios

For example, the itinerary modification interface:

Users modify their itinerary → 200
Modification by non-logged in users → 401/403
Modify other user's itinerary → 403/404
The itinerary does not exist → 404
Illegal parameter → 400
Database modification successful → Data is correct

The status code should be based on the actual API contract.

Summary

The focus of pytest can be divided into four levels:

levelWhat you need to know
Basicsassert, test discovery, run command, parametrize, raises
Test dependenciesFixture、conftest.py、Mock、Factory
Django / DRFdjango_db, APIClient, authentication authority, database verification
engineering practiceCoverage, query times, CI/CD, regression testing

For the Django REST Framework project, the most common combinations are:

pytest
    +
pytest-django
    +
DRF APIClient
    +
Fixture
    +
pytest-mock
    +
pytest-cov

Among them, Fixture, APIClient, django_db, parameterization and Mock are the most worthy of priority in daily development.


Reference documentation