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
| Tools | function |
|---|---|
| pytest | Basic testing framework |
| pytest-django | Integrated Django test environment |
| pytest-mock | Provide mocker Fixture |
| pytest-cov | Statistical 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
| Scope | life cycle |
|---|---|
| function | Each test function is executed once, by default |
| class | Executed once per test class |
| module | Executed once per test module |
| package | Executed once per test package |
| session | The 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
| method | function |
|---|---|
mocker.patch() | Replace the specified object |
return_value | Set simulation return value |
side_effect | Set 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
| indicator | meaning |
|---|---|
| Line Coverage | row coverage |
| Branch Coverage | branch coverage |
| Function Coverage | function 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:
| Type | Test content | Features |
|---|---|---|
| Unit Test | Single function or module | Fast and easy to locate |
| Integration Test | Collaboration of multiple components | Cover real business processes |
| API Test | HTTP interface behavior | Verify status code, data, permissions |
| Regression Test | Are existing functions broken? | Prevent refactoring from introducing errors |
| End-to-End Test | Complete user operation process | Wide coverage, higher cost |
For the Django + DRF project, it is recommended to give priority to testing:
- Key business logic.
- Serializer data validation.
- IMPORTANT Success and error responses for API.
- User authentication and object-level permissions.
- Whether the database modification is correct.
- Handling when external service call fails.
- 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:
| level | What you need to know |
|---|---|
| Basics | assert, test discovery, run command, parametrize, raises |
| Test dependencies | Fixture、conftest.py、Mock、Factory |
| Django / DRF | django_db, APIClient, authentication authority, database verification |
| engineering practice | Coverage, 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.