測試 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權限
這表示我們的測試需要驗證三種情境:
- 未登入:嘗試建立文章應被拒絕(401)
- 已登入但無權限:嘗試建立文章應被拒絕(403)
- 已登入且有權限:可以成功建立文章(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¶
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¶
force_authenticate 是 DRF 測試客戶端提供的方法,可以強制以指定使用者身份進行後續的請求,不需要實際進行登入流程。
HTTP 狀態碼¶
DRF 的 status 模組提供了具名的 HTTP 狀態碼常數,讓程式碼更易讀:
status.HTTP_200_OKstatus.HTTP_201_CREATEDstatus.HTTP_401_UNAUTHORIZEDstatus.HTTP_403_FORBIDDENstatus.HTTP_404_NOT_FOUND
執行測試¶
執行 API 測試:
如果一切正常,你應該會看到:
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 的認證與權限