跳轉到

測試 DRF API

開始之前

任務目標

在這個任務中,你將學習:

  • 了解 DRF 的測試工具
  • 使用 APITestCase 測試 API
  • 測試 API 的認證與權限

DRF 測試工具

Django REST Framework 提供了 APITestCase 類別,它繼承自 Django 的 TestCase,並額外提供了一些方便測試 API 的工具:

  • self.client:一個 APIClient 實例,可以模擬 API 請求
  • self.client.force_authenticate(user):強制以指定使用者身份進行請求
  • self.client.credentials():設定認證憑證(如 Token)

API 權限說明

在開始撰寫測試之前,先了解目前 API 的權限設定。我們的 ArticleViewSet 使用 DjangoModelPermissionsOrAnonReadOnly

blog/drf_views.py
class ArticleViewSet(ModelViewSet):
    permission_classes = [DjangoModelPermissionsOrAnonReadOnly]
    ...

DjangoModelPermissionsOrAnonReadOnly 會根據 Django 的 Model 權限系統來檢查:

  • GET(讀取):允許所有人(包含未登入)
  • POST(建立):需要登入 + add 權限
  • PUT/PATCH(修改):需要登入 + change 權限
  • DELETE(刪除):需要登入 + delete 權限

這表示我們的測試需要驗證三種情境:

  1. 未登入:嘗試建立文章應被拒絕(401)
  2. 已登入但無權限:嘗試建立文章應被拒絕(403)
  3. 已登入且有權限:可以成功建立文章(201)

建立測試

我們將在 blog/tests.py 中新增 API 測試。請在現有的測試類別下方加入以下內容:

blog/tests.py
import os

from django.contrib.auth import get_user_model
from django.contrib.auth.models import Permission
from django.contrib.staticfiles.testing import StaticLiveServerTestCase
from playwright.sync_api import sync_playwright
from rest_framework import status
from rest_framework.test import APITestCase

from blog.models import Article

# Playwright 內部使用 async event loop, 需要允許 Django 在 async 環境中執行資料庫操作
os.environ["DJANGO_ALLOW_ASYNC_UNSAFE"] = "true"

User = get_user_model()


class LoginPageTests(StaticLiveServerTestCase):
    @classmethod
    def setUpClass(cls):
        super().setUpClass()
        cls.playwright = sync_playwright().start()
        cls.browser = cls.playwright.chromium.launch()

    @classmethod
    def tearDownClass(cls):
        cls.browser.close()
        cls.playwright.stop()
        super().tearDownClass()

    def setUp(self):
        # 建立測試用使用者
        self.user = User.objects.create_user(
            username="testuser",
            password="testpass123",
        )

    def test_login_page_loads(self):
        """測試登入頁面能否正常載入"""
        page = self.browser.new_page()
        page.goto(f"{self.live_server_url}/zh-hant/auth/login/")

        # 確認頁面標題包含「登入」
        assert "登入" in page.title()

        # 確認登入表單存在
        assert page.locator("form").count() > 0

        # 確認有使用者名稱和密碼輸入欄位
        assert page.locator("input[name='username']").count() == 1
        assert page.locator("input[name='password']").count() == 1

        # 確認有登入按鈕
        assert page.locator("form button[type='submit']").count() == 1

        page.close()

    def test_login_page_has_register_link(self):
        """測試登入頁面是否有註冊連結"""
        page = self.browser.new_page()
        page.goto(f"{self.live_server_url}/zh-hant/auth/login/")

        # 確認有「立即註冊」連結
        register_link = page.locator("a", has_text="立即註冊")
        assert register_link.count() == 1

        page.close()

    def test_login_page_has_forgot_password_link(self):
        """測試登入頁面是否有忘記密碼連結"""
        page = self.browser.new_page()
        page.goto(f"{self.live_server_url}/zh-hant/auth/login/")

        # 確認有「忘記密碼」連結
        forgot_link = page.locator("a", has_text="忘記密碼")
        assert forgot_link.count() == 1

        page.close()

    def test_successful_login(self):
        """測試成功登入的流程"""
        page = self.browser.new_page()
        page.goto(f"{self.live_server_url}/zh-hant/auth/login/")

        # 填寫登入表單
        page.fill("input[name='username']", "testuser")
        page.fill("input[name='password']", "testpass123")

        # 點擊登入按鈕
        page.click("form button[type='submit']")

        # 等待頁面跳轉
        page.wait_for_url(f"{self.live_server_url}/zh-hant/blog/articles/")

        # 確認已登入, 導覽列應該顯示登出按鈕
        logout_button = page.locator("button", has_text=f"登出 ({self.user.username})")
        assert logout_button.count() == 1

        page.close()

    def test_failed_login(self):
        """測試登入失敗的情況"""
        page = self.browser.new_page()
        page.goto(f"{self.live_server_url}/zh-hant/auth/login/")

        # 填寫錯誤的密碼
        page.fill("input[name='username']", "testuser")
        page.fill("input[name='password']", "wrongpassword")

        # 點擊登入按鈕
        page.click("form button[type='submit']")

        # 確認還在登入頁面
        assert "/auth/login/" in page.url

        # 確認有錯誤訊息
        error_message = page.locator("form .list-unstyled.text-danger")
        assert error_message.count() > 0

        assert (
            error_message.text_content().strip()  # type: ignore[reportOptionalMemberAccess]
            == "輸入正確的 使用者名稱 和密碼。請注意兩者皆區分大小寫。"
        )

        page.close()


class ArticleAPITests(APITestCase):
    """測試文章建立 API"""

    @classmethod
    def setUpTestData(cls):
        # 建立無權限使用者
        cls.user_without_permission = User.objects.create_user(
            username="normaluser",
            password="testpass123",
        )

        # 建立有權限的使用者
        cls.user_with_permission = User.objects.create_user(
            username="authorizeduser",
            password="testpass123",
        )

        # 賦予建立文章的權限
        add_article_permission = Permission.objects.get(codename="add_article")
        cls.user_with_permission.user_permissions.add(add_article_permission)

    def get_valid_payload(self):
        """取得有效的文章建立資料"""
        return {
            "title": "測試文章",
            "content": "這是測試內容",
            "is_published": False,
        }

    def test_create_article_unauthenticated(self):
        """未登入時建立文章應回傳 403"""
        response = self.client.post(
            "/api-drf/blog/articles",
            self.get_valid_payload(),
            format="json",
        )

        assert response.status_code == status.HTTP_403_FORBIDDEN

    def test_create_article_without_permission(self):
        """已登入但無權限時建立文章應回傳 403"""
        self.client.force_authenticate(user=self.user_without_permission)

        response = self.client.post(
            "/api-drf/blog/articles",
            self.get_valid_payload(),
            format="json",
        )

        assert response.status_code == status.HTTP_403_FORBIDDEN

    def test_create_article_with_permission(self):
        """已登入且有權限時應成功建立文章"""
        self.client.force_authenticate(user=self.user_with_permission)

        response = self.client.post(
            "/api-drf/blog/articles",
            self.get_valid_payload(),
            format="json",
        )

        assert response.status_code == status.HTTP_201_CREATED
        assert response.data["title"] == "測試文章"
        assert response.data["content"] == "這是測試內容"

        # 確認文章已建立在資料庫中
        assert Article.objects.filter(id=response.data["id"]).exists()

        # 確認 created_by 與其他欄位被正確設定
        article = Article.objects.get(id=response.data["id"])
        assert article.created_by == self.user_with_permission
        assert article.title == "測試文章"
        assert article.content == "這是測試內容"

程式碼解析

setUpTestData

@classmethod
def setUpTestData(cls):
    ...

setUpTestData 是類別方法,在整個測試類別執行前只會執行一次。與 setUp 不同的是,這裡建立的資料會在所有測試間共享,可以提升測試效率。

適合放在 setUpTestData 的資料:

  • 測試中不會被修改的資料(如測試用使用者)
  • 多個測試都會用到的共用資料

Permission 設定

add_article_permission = Permission.objects.get(codename="add_article")
cls.user_with_permission.user_permissions.add(add_article_permission)

Django 會自動為每個 Model 建立四個權限:add_change_delete_view_。我們透過 codename 取得權限物件,然後加入使用者的權限清單。

force_authenticate

self.client.force_authenticate(user=self.user_with_permission)

force_authenticate 是 DRF 測試客戶端提供的方法,可以強制以指定使用者身份進行後續的請求,不需要實際進行登入流程。

HTTP 狀態碼

DRF 的 status 模組提供了具名的 HTTP 狀態碼常數,讓程式碼更易讀:

  • status.HTTP_200_OK
  • status.HTTP_201_CREATED
  • status.HTTP_401_UNAUTHORIZED
  • status.HTTP_403_FORBIDDEN
  • status.HTTP_404_NOT_FOUND

執行測試

執行 API 測試:

uv run manage.py test blog.tests.ArticleAPITests

如果一切正常,你應該會看到:

Found 3 test(s).
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
...
----------------------------------------------------------------------
Ran 3 tests in 0.123s

OK
Destroying test database for alias 'default'...

任務結束

完成!

恭喜你完成了這個任務!現在你已經學會:

  • 了解 DRF 的測試工具
  • 使用 APITestCase 測試 API
  • 測試 API 的認證與權限