Django Testing 進階:Mock、Factory Boy 與 Coverage | Django 教學

2026/06/26 2026/05/26
Django Testing 進階:Mock、Factory Boy 與 Coverage | Django 教學

在上一篇 Django Testing 基礎中,我們學會了使用 TestCase 撰寫 Model、View、Form 測試。本文將進入進階領域,介紹 Factory Boy 測試資料工廠來取代手動建立資料、unittest.mockMockPatch 技巧來隔離外部依賴、DRF APITestCase 來測試 REST API、assertNumQueries 進行效能測試、Coverage 覆蓋率報告追蹤測試品質,以及 pytest-django 整合與 CI/CD 中的測試策略,全面提升你的 Django 測試能力。

Factory Boy:測試資料工廠

在上一篇中,我們在 setUpsetUpTestData 中手動呼叫 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 提供了專門的 APITestCaseAPIClient 來測試 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 HookLinting(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 進階測試的核心技術與最佳實踐:

  1. Factory Boy:使用 SubFactoryLazyAttributeSequence 等功能自動產生測試資料,取代冗長的手動建立
  2. Mock 與 Patch:透過 unittest.mock@patchMagicMock 隔離外部依賴,測試程式碼的邏輯而非外部服務
  3. DRF API 測試:使用 APITestCaseAPIClient 搭配 force_authenticate 測試 REST API 端點
  4. 效能測試assertNumQueries 防止 N+1 查詢問題,CaptureQueriesContext 取得詳細查詢資訊
  5. Coverage 覆蓋率:安裝 coverage、設定 .coveragerc、生成 HTML 報告,以 80% 作為基準線
  6. pytest-django:使用 conftest.py 定義 fixture、@pytest.mark.django_db 標記需要資料庫的測試、-m 標記分類執行
  7. CI/CD 整合:GitHub Actions 設定 PostgreSQL 和 Redis 服務、執行測試與覆蓋率檢查、上傳報告到 Codecov

完善的測試不僅是程式碼品質的保障,更是團隊協作與持續交付的基石。將本文介紹的工具與策略融入你的開發流程,將能大幅提升專案的可靠性與可維護性。

BenZ Software Developer

熱愛技術的軟體開發者,在這裡分享程式開發經驗與學習筆記。