跳轉到

使用 Playwright 測試頁面

開始之前

任務目標

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

  • 了解 Django 的 StaticLiveServerTestCase
  • 安裝並設定 Playwright
  • 撰寫端對端測試來驗證登入頁面

為什麼需要端對端測試

在前面的任務中,我們學習了如何使用 Django 來開發功能使用了 Model、View 和 Form 等等的功能。

但每次開發都需要確認這些功能在真實的瀏覽器環境中可以順利且正確的執行。每次都手動操作顯然是不切實際的,所以我們會借助自動化工具的協助來完成測試,例如:

  • JavaScript 互動效果
  • 表單提交與頁面跳轉
  • CSS 樣式是否正確載入
  • 多個頁面之間的導航流程

這就是「端對端測試」(End-to-End Testing,簡稱 E2E)的用途。它模擬真實使用者的操作,從瀏覽器端驗證整個應用程式的運作。

Django 的 LiveServerTestCase

Django 提供了 LiveServerTestCaseStaticLiveServerTestCase 兩個測試類別,它們會在測試期間啟動一個真實的 HTTP 伺服器,讓你可以用瀏覽器自動化工具來測試。

  • LiveServerTestCase:啟動測試伺服器,但不處理靜態檔案
  • StaticLiveServerTestCase:啟動測試伺服器,並自動提供靜態檔案服務

因為我們的頁面會使用靜態檔,所以需要使用 StaticLiveServerTestCase 來確保樣式正確載入。

安裝 Playwright

Playwright 是一個強大的瀏覽器自動化工具,支援 Chromium、Firefox 和 WebKit 三種瀏覽器引擎。

首先,安裝 Playwright:

uv add --group=test playwright  # (1)!
  1. 只有測試環境我們才會需要他所以把它安裝在測試的 group 中

接著,安裝瀏覽器引擎。Playwright 需要下載瀏覽器二進位檔才能運作:

uv run playwright install

這個指令會下載 Chromium、Firefox 和 WebKit 的瀏覽器引擎。如果只想安裝特定瀏覽器,可以指定名稱:

# 只安裝 Chromium
uv run playwright install chromium

為什麼不用 Selenium?

你可能聽過 Selenium,它是老牌的瀏覽器自動化工具,Django 官方文件中也有提到。但 Playwright 有幾個明顯的優勢:

  • 自動等待:Playwright 會自動等待元素可互動後才執行操作,不需要手動加 time.sleep() 或設定 explicit wait
  • 內建瀏覽器:Playwright 自帶瀏覽器引擎,不需要另外下載 ChromeDriver 並處理版本相容問題
  • 更快的執行速度:Playwright 使用 WebSocket 與瀏覽器通訊,比 Selenium 的 HTTP 協定更快
  • 更好的 API 設計:語法更簡潔直覺,例如 page.fill() vs Selenium 的 element.clear() + element.send_keys()

建立測試檔案

我們將在 blog App 中建立測試。請編輯 blog/tests.py

blog/tests.py
import os

from django.contrib.staticfiles.testing import StaticLiveServerTestCase
from playwright.sync_api import sync_playwright

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


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 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()

程式碼解析

讓我們來看看這段測試程式碼的結構:

setUpClass 和 tearDownClass

@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()

這兩個方法是類別層級的設定和清理。setUpClass 在所有測試執行前只會執行一次,用來啟動 Playwright 和瀏覽器。tearDownClass 在所有測試結束後執行,用來關閉瀏覽器和停止 Playwright。

這樣做的好處是避免每個測試都重新啟動瀏覽器,可以加快測試速度。

DJANGO_ALLOW_ASYNC_UNSAFE

os.environ["DJANGO_ALLOW_ASYNC_UNSAFE"] = "true"

Playwright 的 sync API 內部實際上是在 async event loop 中執行的。當 Django 偵測到你在 async 環境中執行資料庫操作時,會拋出 SynchronousOnlyOperation 錯誤。設定這個環境變數可以告訴 Django 允許這種「不安全」的操作。

在測試環境中這是可以接受的,但是如果發生在非測試的程式碼則需要去解決它。

live_server_url

StaticLiveServerTestCase 會自動提供 self.live_server_url 屬性,這是測試伺服器的 URL(例如 http://localhost:12345)。每次測試執行時,Django 會選擇一個可用的 port。

Playwright 的定位器

Playwright 使用 locator() 方法來尋找頁面元素:

  • page.locator("form"):尋找 <form> 元素
  • page.locator("input[name='username']"):尋找 name 屬性為 username<input> 元素
  • page.locator("form button[type='submit']"):尋找表單內的提交按鈕(使用後代選擇器可以更精確定位)
  • page.locator("a", has_text="立即註冊"):尋找包含「立即註冊」文字的 <a> 元素

執行測試

使用 Django 內建的測試指令來執行測試:

uv run manage.py test

如果一切正常,你應該會看到類似以下的輸出:

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

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

顯示瀏覽器視窗

預設情況下,Playwright 會以「無頭模式」(headless mode)執行,也就是不會顯示瀏覽器視窗。如果想要看到瀏覽器的操作過程,可以在啟動瀏覽器時加上 headless=False

cls.browser = cls.playwright.chromium.launch(headless=False)

這在除錯時非常有用,可以直接觀察測試的執行過程。

測試登入功能

讓我們新增一個測試來驗證實際的登入流程。首先需要在測試中建立一個使用者,然後嘗試登入:

blog/tests.py
import os

from django.contrib.auth import get_user_model
from django.contrib.staticfiles.testing import StaticLiveServerTestCase
from playwright.sync_api import sync_playwright

# 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()
            == "輸入正確的 使用者名稱 和密碼。請注意兩者皆區分大小寫。"
        )

        page.close()

新增的測試說明

setUp 方法

def setUp(self):
    self.user = User.objects.create_user(
        username="testuser",
        password="testpass123",
    )

setUpClass 不同,setUp 方法會在每個測試方法執行前執行。這裡我們建立一個測試用的使用者。由於 Django 的測試框架會在每個測試後清空資料庫,所以每個測試都會有一個新的使用者。

Playwright 的互動方法

  • page.fill(selector, value):在輸入欄位中填入文字
  • page.click(selector):點擊元素
  • page.wait_for_url(url):等待頁面 URL 變成指定的值

任務結束

完成!

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

  • 了解 Django 的 StaticLiveServerTestCase
  • 安裝並設定 Playwright
  • 撰寫端對端測試來驗證登入頁面