Django Testing 進階:Mock、Factory Boy 與 Coverage | Django 教學
在上一篇 Django Testing 基礎中,我們學會了使用 TestCase 撰寫 Model、View、Form 測試。本文將進入進階領域,介紹 Factory Boy 測試資料工廠來取代手動建立資料、unittest.mock 的 Mock 與 Patch 技巧來隔離外部依賴、DRF APITestCase 來測試 REST API、assertNumQueries 進行效能測試、Coverage 覆蓋率報告追蹤測試品質,以及 pytest-django 整合與 CI/CD 中的測試策略,全面提升你的 Django 測試能力。
Factory Boy:測試資料工廠
在上一篇中,我們在 setUp 或 setUpTestData 中手動呼叫 Model.objects.create() 來建立測試資料。當 Model 的欄位數量多、關聯複雜時,手動建立資料會變得冗長且難以維護。
Factory Boy 是 Python 生態系中最受歡迎的測試資料工廠套件,它能根據 Model 定義自動產生測試資料,並支援自動建立關聯物件。
安裝
pip install factory-boy
定義 Factory
# articles/tests/factories.py
import factory
from factory.django import DjangoModelFactory
from django.contrib.auth.models import User
from articles.models import Article, Category
class UserFactory(DjangoModelFactory):
"""使用者資料工廠。"""
class Meta:
model = User
username = factory.Sequence(lambda n: f'user_{n}') # 自動遞增:user_0, user_1...
email = factory.LazyAttribute(lambda obj: f'{obj.username}@example.com') # 依據 username 動態生成
first_name = factory.Faker('first_name', locale='zh_TW') # 使用 Faker 產生假資料
last_name = factory.Faker('last_name', locale='zh_TW')
password = factory.PostGenerationMethodCall('set_password', 'testpass123')
class CategoryFactory(DjangoModelFactory):
"""文章分類資料工廠。"""
class Meta:
model = Category
name = factory.Sequence(lambda n: f'Category {n}')
slug = factory.LazyAttribute(lambda obj: obj.name.lower().replace(' ', '-'))
class ArticleFactory(DjangoModelFactory):
"""文章資料工廠。"""
class Meta:
model = Article
title = factory.Faker('sentence', nb_words=5) # 隨機生成 5 個單詞的句子
content = factory.Faker('paragraphs', nb=3, as_text=True)
author = factory.SubFactory(UserFactory) # 自動建立關聯的 User
category = factory.SubFactory(CategoryFactory) # 自動建立關聯的 Category
is_published = True
核心功能說明
| 功能 | 說明 | 範例 |
|---|---|---|
factory.Sequence | 自動遞增的序號值 | factory.Sequence(lambda n: f'user_{n}') |
factory.LazyAttribute | 依據其他欄位動態計算值 | factory.LazyAttribute(lambda obj: f'{obj.username}@example.com') |
factory.SubFactory | 自動建立關聯的外鍵物件 | factory.SubFactory(UserFactory) |
factory.Faker | 使用 Faker 產生隨機假資料 | factory.Faker('email') |
factory.PostGenerationMethodCall | 物件建立後呼叫指定方法 | factory.PostGenerationMethodCall('set_password', 'pass') |
使用 Factory
# articles/tests/test_with_factory.py
from django.test import TestCase
from articles.tests.factories import UserFactory, ArticleFactory
class ArticleWithFactoryTest(TestCase):
"""使用 Factory Boy 建立測試資料。"""
def test_create_single_article(self):
"""建立單篇文章(自動建立 author)。"""
article = ArticleFactory(title='Factory 建立的文章')
self.assertEqual(article.title, 'Factory 建立的文章')
self.assertIsNotNone(article.author) # author 自動建立
def test_create_article_with_specific_author(self):
"""指定作者建立文章。"""
user = UserFactory(username='custom_author')
article = ArticleFactory(author=user)
self.assertEqual(article.author.username, 'custom_author')
def test_create_batch(self):
"""批量建立多篇文章。"""
user = UserFactory()
articles = ArticleFactory.create_batch(10, author=user)
self.assertEqual(len(articles), 10)
self.assertTrue(all(a.author == user for a in articles))
def test_build_without_saving(self):
"""只建立物件實例,不存入資料庫。"""
article = ArticleFactory.build() # 不呼叫 save()
self.assertIsNone(article.pk) # 尚未儲存,pk 為 None
Mock 與 Patch:隔離外部依賴
在測試中,你的程式碼可能會呼叫外部服務(如第三方支付 API、Email 寄送、雲端儲存),這些外部依賴不應該在測試中真正執行。Mock(模擬物件)讓你可以用假的回傳值取代真實的外部呼叫。
Python 標準函式庫的 unittest.mock 模組提供了完整的 Mock 工具。
@patch 裝飾器
@patch 是最常用的 Mock 方式,它暫時將指定路徑的物件替換為 MagicMock:
# articles/tests/test_services.py
from unittest.mock import patch, MagicMock
from django.test import TestCase
from articles.services import publish_article
class PublishArticleTest(TestCase):
"""測試文章發布服務(包含外部 API 呼叫)。"""
@patch('articles.services.send_notification')
def test_publish_sends_notification(self, mock_send):
"""測試發布文章後會發送通知。"""
article = ArticleFactory()
publish_article(article)
# 驗證 send_notification 被呼叫了一次
mock_send.assert_called_once()
@patch('articles.services.external_api.post')
def test_publish_calls_external_api(self, mock_api_post):
"""測試發布文章後會呼叫外部 API。"""
# 設定 Mock 的回傳值
mock_api_post.return_value = MagicMock(
status_code=200,
json=lambda: {'id': 'ext_123', 'status': 'published'},
)
article = ArticleFactory()
result = publish_article(article)
# 驗證 API 被正確呼叫
mock_api_post.assert_called_once_with(
'https://api.example.com/articles',
json={'title': article.title, 'content': article.content},
)
self.assertEqual(result['external_id'], 'ext_123')
@patch('articles.services.external_api.post')
def test_publish_handles_api_failure(self, mock_api_post):
"""測試外部 API 失敗時的錯誤處理。"""
mock_api_post.side_effect = ConnectionError('API 無法連線')
article = ArticleFactory()
with self.assertRaises(ConnectionError):
publish_article(article)
使用 with 語法的 patch
除了裝飾器,也可以使用 with 語法來限定 Mock 的作用範圍:
def test_email_sending(self):
"""測試 Email 寄送功能。"""
with patch('django.core.mail.send_mail') as mock_send_mail:
mock_send_mail.return_value = 1 # 表示成功寄送 1 封
from articles.services import notify_author
result = notify_author(self.article)
self.assertTrue(result)
mock_send_mail.assert_called_once_with(
subject='你的文章已發布',
message='...',
from_email='noreply@example.com',
recipient_list=[self.article.author.email],
)
MagicMock 的常用斷言
# 驗證是否被呼叫
mock.assert_called() # 至少被呼叫一次
mock.assert_called_once() # 恰好被呼叫一次
mock.assert_not_called() # 從未被呼叫
# 驗證呼叫的參數
mock.assert_called_with(arg1, arg2) # 最後一次呼叫的參數
mock.assert_called_once_with(arg1, kwarg='value') # 唯一一次呼叫的參數
mock.assert_any_call(arg1) # 任何一次呼叫中有此參數
# 檢查呼叫次數
self.assertEqual(mock.call_count, 3)
# 取得所有呼叫記錄
print(mock.call_args_list) # [call(arg1), call(arg2), ...]
什麼該 Mock,什麼不該 Mock?
- 應該 Mock:外部 API 呼叫、Email 寄送、檔案上傳到雲端、第三方 SDK、時間相關函式(
datetime.now()) - 不該 Mock:自己的 Model、View、Form 等核心業務邏輯。如果你發現需要大量 Mock 自己的程式碼,可能是程式架構需要重構
DRF API 測試
如果你的專案使用 Django REST Framework(DRF),DRF 提供了專門的 APITestCase 和 APIClient 來測試 API:
# articles/tests/test_api.py
from django.contrib.auth.models import User
from rest_framework import status
from rest_framework.test import APITestCase, APIClient
from rest_framework.authtoken.models import Token
from articles.models import Article
from articles.tests.factories import UserFactory, ArticleFactory
class ArticleAPITest(APITestCase):
"""測試 Article REST API。"""
def setUp(self):
self.user = UserFactory()
self.token = Token.objects.create(user=self.user)
self.client = APIClient()
# Token 認證
self.client.credentials(
HTTP_AUTHORIZATION=f'Token {self.token.key}'
)
def test_list_articles(self):
"""測試列表 API 回傳正確格式。"""
ArticleFactory.create_batch(3, author=self.user)
response = self.client.get('/api/articles/')
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(response.data['results']), 3)
def test_create_article(self):
"""測試建立文章 API。"""
response = self.client.post('/api/articles/', {
'title': 'API 建立的文章',
'content': '透過 API 測試建立',
})
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertEqual(Article.objects.count(), 1)
self.assertEqual(Article.objects.first().author, self.user)
def test_create_article_unauthenticated(self):
"""測試未認證時被拒絕。"""
self.client.force_authenticate(user=None) # 清除認證
response = self.client.post('/api/articles/', {
'title': '未認證的文章',
'content': '...',
})
self.assertEqual(
response.status_code, status.HTTP_401_UNAUTHORIZED
)
def test_update_article_by_owner(self):
"""測試作者可以更新自己的文章。"""
article = ArticleFactory(author=self.user)
response = self.client.patch(
f'/api/articles/{article.pk}/',
{'title': '更新後的標題'},
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
article.refresh_from_db()
self.assertEqual(article.title, '更新後的標題')
def test_delete_article_by_non_owner(self):
"""測試非作者無法刪除文章。"""
other_user = UserFactory()
article = ArticleFactory(author=other_user)
response = self.client.delete(f'/api/articles/{article.pk}/')
self.assertEqual(
response.status_code, status.HTTP_403_FORBIDDEN
)
APIClient 的認證方式
# 方式一:Token 認證
self.client.credentials(HTTP_AUTHORIZATION=f'Token {token_key}')
# 方式二:force_authenticate(最簡潔,跳過認證流程)
self.client.force_authenticate(user=self.user)
# 方式三:Session 認證
self.client.login(username='user', password='pass')
# 清除認證
self.client.force_authenticate(user=None)
self.client.credentials() # 清除所有 credentials
效能測試:assertNumQueries
N+1 查詢問題(N+1 Query Problem) 是 Django 開發中常見的效能陷阱。assertNumQueries 斷言可以確保你的程式碼在預期的 SQL 查詢次數內完成:
# articles/tests/test_performance.py
from django.test import TestCase
from articles.tests.factories import ArticleFactory
class ArticlePerformanceTest(TestCase):
"""測試文章相關功能的 SQL 查詢效能。"""
def test_list_view_query_count(self):
"""確保列表頁的查詢次數不隨資料量線性增長。"""
ArticleFactory.create_batch(20)
# 預期:1 次查詢取文章列表 + 1 次查詢取作者(若使用 select_related)
with self.assertNumQueries(2):
response = self.client.get('/articles/')
self.assertEqual(response.status_code, 200)
def test_api_list_query_count(self):
"""確保 API 列表端點的查詢數量在合理範圍。"""
self.client.force_login(self.user)
ArticleFactory.create_batch(10, author=self.user)
with self.assertNumQueries(3): # 認證 + 查文章 + 計數(分頁)
response = self.client.get('/api/articles/')
self.assertEqual(response.status_code, 200)
如果 with 區塊內的實際查詢次數超過預期,測試會失敗並顯示實際執行的 SQL 語句,幫助你快速定位效能瓶頸。
你也可以使用 CaptureQueriesContext 取得更詳細的查詢資訊:
from django.test.utils import CaptureQueriesContext
from django.db import connection
def test_detect_n_plus_1(self):
"""偵測 N+1 查詢問題。"""
ArticleFactory.create_batch(10)
with CaptureQueriesContext(connection) as ctx:
response = self.client.get('/articles/')
# 印出所有 SQL 查詢(除錯用)
for query in ctx.captured_queries:
print(query['sql'])
# 確保查詢次數在合理範圍
self.assertLessEqual(
len(ctx.captured_queries), 5,
f'查詢次數過多:{len(ctx.captured_queries)} 次'
)
Coverage:測試覆蓋率報告
Coverage(覆蓋率)衡量你的測試實際執行了多少比例的程式碼。它是評估測試品質的重要指標之一。
安裝
pip install coverage
執行覆蓋率分析
# 使用 coverage 執行 Django 測試
coverage run --source='.' manage.py test
# 在終端機顯示覆蓋率摘要
coverage report
# 顯示未覆蓋的行號
coverage report --show-missing
# 生成 HTML 格式的詳細報告
coverage html
# 報告會輸出到 htmlcov/ 目錄,用瀏覽器開啟 htmlcov/index.html 查看
設定檔
建立 .coveragerc 或在 pyproject.toml 中設定,排除不需要計算覆蓋率的檔案:
# .coveragerc
[run]
source = apps
omit =
*/migrations/*
*/tests/*
*/admin.py
manage.py
*/wsgi.py
*/asgi.py
[report]
show_missing = True
fail_under = 80 # 覆蓋率低於 80% 時視為失敗
[html]
directory = htmlcov
或在 pyproject.toml 中設定(適合整合到專案的統一設定檔):
# pyproject.toml
[tool.coverage.run]
source = ["apps"]
omit = ["*/migrations/*", "*/tests/*", "*/admin.py"]
[tool.coverage.report]
show_missing = true
fail_under = 80
Coverage 報告的解讀
Name Stmts Miss Cover Missing
------------------------------------------------------------
articles/models.py 25 2 92% 45-46
articles/views.py 40 8 80% 33-35, 60-64
articles/services.py 30 15 50% 20-35
articles/forms.py 15 0 100%
------------------------------------------------------------
TOTAL 110 25 77%
- Stmts:程式碼總行數
- Miss:未被測試覆蓋的行數
- Cover:覆蓋率百分比
- Missing:未覆蓋的具體行號
重點不是追求 100% 覆蓋率,而是確保核心業務邏輯、邊界條件和錯誤處理路徑都有被測試到。
pytest-django 整合
pytest 是 Python 生態系中最流行的測試框架,語法更簡潔、功能更強大。透過 pytest-django 套件,可以無縫整合 Django 的測試工具。
安裝
pip install pytest pytest-django pytest-cov
設定
# pytest.ini
[pytest]
DJANGO_SETTINGS_MODULE = myproject.settings.test
python_files = tests.py test_*.py *_tests.py
python_classes = Test*
python_functions = test_*
addopts = --strict-markers -v
markers =
slow: 標記為耗時測試
integration: 標記為整合測試
或在 pyproject.toml 中設定:
# pyproject.toml
[tool.pytest.ini_options]
DJANGO_SETTINGS_MODULE = "myproject.settings.test"
python_files = ["tests.py", "test_*.py", "*_tests.py"]
addopts = "--strict-markers -v"
markers = [
"slow: 標記為耗時測試",
"integration: 標記為整合測試",
]
conftest.py:共用 Fixture
pytest 的 fixture 機制比 Django 的 setUp 更靈活,支援組合與共享:
# conftest.py(放在專案根目錄或 App 目錄)
import pytest
from rest_framework.test import APIClient
from articles.tests.factories import UserFactory
@pytest.fixture
def user(db):
"""提供一個已建立的普通使用者。
db fixture 確保測試可以存取資料庫。
"""
return UserFactory()
@pytest.fixture
def admin_user(db):
"""提供一個超級使用者。"""
return UserFactory(is_staff=True, is_superuser=True)
@pytest.fixture
def authenticated_client(client, user):
"""提供已登入的 Django Test Client。
client 是 pytest-django 內建的 fixture。
"""
client.force_login(user)
return client
@pytest.fixture
def api_client():
"""提供 DRF APIClient。"""
return APIClient()
@pytest.fixture
def authenticated_api_client(api_client, user):
"""提供已認證的 DRF APIClient。"""
api_client.force_authenticate(user=user)
return api_client
pytest 風格的測試
pytest 最大的特色是不需要繼承 TestCase 類別,直接寫函式就能當測試:
# articles/tests/test_articles_pytest.py
import pytest
from articles.tests.factories import ArticleFactory
from articles.models import Article
@pytest.mark.django_db
def test_article_creation(user):
"""測試文章建立(使用 conftest.py 的 user fixture)。"""
article = ArticleFactory(title='pytest 建立的文章', author=user)
assert article.title == 'pytest 建立的文章'
assert article.author == user
@pytest.mark.django_db
def test_article_list_view(authenticated_client):
"""測試列表頁(使用已登入的 Client)。"""
ArticleFactory.create_batch(5)
response = authenticated_client.get('/articles/')
assert response.status_code == 200
@pytest.mark.django_db
def test_article_api_create(authenticated_api_client):
"""測試 API 建立文章。"""
response = authenticated_api_client.post('/api/articles/', {
'title': 'API 文章',
'content': '內容',
})
assert response.status_code == 201
assert Article.objects.count() == 1
@pytest.mark.slow
@pytest.mark.django_db
def test_bulk_creation_performance(user):
"""測試批量建立效能(標記為 slow)。"""
ArticleFactory.create_batch(100, author=user)
assert Article.objects.count() == 100
執行 pytest
# 執行所有測試
pytest
# 執行特定檔案
pytest articles/tests/test_articles_pytest.py
# 執行特定測試函式
pytest articles/tests/test_articles_pytest.py::test_article_creation
# 排除慢速測試
pytest -m "not slow"
# 只跑整合測試
pytest -m integration
# 搭配 Coverage
pytest --cov=apps --cov-report=html --cov-report=term-missing
# 平行執行(需安裝 pytest-xdist)
pip install pytest-xdist
pytest -n auto # 自動偵測 CPU 核心數
CI/CD 中的測試策略
將測試整合到 CI/CD(持續整合 / 持續部署)流程中,確保每次程式碼變更都自動執行測試。以下是一個 GitHub Actions 的完整範例:
# .github/workflows/test.yml
name: Django Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15
env:
POSTGRES_DB: test_mydb
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports: ['5432:5432']
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis:7
ports: ['6379:6379']
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: 'pip'
- name: Install dependencies
run: pip install -r requirements.txt
- name: Run tests with coverage
env:
DJANGO_SETTINGS_MODULE: myproject.settings.test
DATABASE_URL: postgresql://postgres:postgres@localhost/test_mydb
run: |
pytest --cov=apps --cov-report=xml --cov-fail-under=80 -x
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v4
with:
file: coverage.xml
CI/CD 測試策略建議
| 階段 | 執行的測試 | 目的 |
|---|---|---|
| Pre-commit Hook | Linting(flake8、black) | 確保程式碼風格一致 |
| Pull Request | 全部測試 + Coverage | 確保新程式碼不破壞現有功能 |
| Merge to Main | 全部測試 + E2E 測試 | 完整的回歸測試 |
| 部署前 | Smoke Test(核心功能快速測試) | 確認部署環境正常 |
在 CI 中分層執行測試
# 快速回饋:只跑 Unit Test(排除 slow 和 integration 標記)
pytest -m "not slow and not integration" --cov=apps --cov-fail-under=70
# 完整測試:包含 Integration Test
pytest --cov=apps --cov-fail-under=80
# Nightly Build:包含所有測試(含 slow)
pytest --cov=apps --cov-fail-under=80
測試最佳實踐
最後整理幾個撰寫測試時的最佳實踐:
命名慣例
測試方法的命名應該清楚描述「測試什麼」和「預期結果」:
# 好的命名:清楚描述測試的行為與預期
def test_create_article_requires_authentication(self):
def test_unpublished_article_not_in_list(self):
def test_author_can_delete_own_article(self):
# 不好的命名:過於模糊
def test_article(self):
def test_view(self):
def test_1(self):
AAA 模式
每個測試方法遵循 AAA(Arrange-Act-Assert)三步驟模式:
def test_publish_article_sends_notification(self):
# Arrange(準備):建立測試資料與前置條件
article = ArticleFactory(is_published=False)
# Act(執行):執行要測試的操作
article.publish()
# Assert(斷言):驗證結果符合預期
self.assertTrue(article.is_published)
self.assertIsNotNone(article.published_at)
Fixtures vs Factory 選擇指南
| 方式 | 適用場景 | 優點 | 缺點 |
|---|---|---|---|
| Django Fixtures(JSON) | 靜態參考資料(分類表、城市清單) | 直觀、搭配 loaddata | 維護成本高、容易與 Model 脫節 |
| Factory Boy | 動態測試資料、關聯資料 | 彈性高、與 Model 同步更新 | 需安裝第三方套件 |
| pytest fixtures | 共用測試環境設定 | 與 pytest 生態整合、可組合 | 學習曲線 |
Model.objects.create() | 極簡測試 | 零依賴 | 程式碼冗長 |
推薦組合:使用 Factory Boy 建立測試資料(90% 的場景)、使用 pytest fixtures 封裝常用的前置條件、Django Fixtures 僅用於靜態參考資料。
總結
本文涵蓋了 Django 進階測試的核心技術與最佳實踐:
- Factory Boy:使用
SubFactory、LazyAttribute、Sequence等功能自動產生測試資料,取代冗長的手動建立 - Mock 與 Patch:透過
unittest.mock的@patch和MagicMock隔離外部依賴,測試程式碼的邏輯而非外部服務 - DRF API 測試:使用
APITestCase和APIClient搭配force_authenticate測試 REST API 端點 - 效能測試:
assertNumQueries防止 N+1 查詢問題,CaptureQueriesContext取得詳細查詢資訊 - Coverage 覆蓋率:安裝
coverage、設定.coveragerc、生成 HTML 報告,以 80% 作為基準線 - pytest-django:使用
conftest.py定義 fixture、@pytest.mark.django_db標記需要資料庫的測試、-m標記分類執行 - CI/CD 整合:GitHub Actions 設定 PostgreSQL 和 Redis 服務、執行測試與覆蓋率檢查、上傳報告到 Codecov
完善的測試不僅是程式碼品質的保障,更是團隊協作與持續交付的基石。將本文介紹的工具與策略融入你的開發流程,將能大幅提升專案的可靠性與可維護性。