「測試都通過了,為什麼上線還是出問題?」
你寫了完美的單元測試,整合測試也都綠燈,但使用者還是回報:「我點了按鈕,什麼事都沒發生!」這時你才發現,原來是前端和後端的 API 版本不相容...
這就是為什麼我們需要 E2E(End-to-End)測試!今天,讓我們預覽這個強大的測試武器。
基礎測試 → Kata 實戰 → 框架特色 → 整合部署
1-10 11-17 18-27 28-30
↓ 我們在這裡(Day 27)🎬
[=============================================>....]
經過 26 天的學習,我們已經建立了完整的測試金字塔。今天要站在金字塔頂端,俯瞰整個測試版圖!
想像一下,你是一個真實的使用者:
E2E 測試就是模擬這整個過程!
/\
/E2E\ ← 今天的主角!
/------\
/整合測試\
/----------\
/ 單元測試 \
/--------------\
# 建立 tests/e2e/test_todo_flow.py
import pytest
from playwright.sync_api import Page, expect
def test_complete_todo_workflow(page: Page):
# 1. 訪問應用
page.goto("http://localhost:3000")
# 2. 建立新任務
page.fill('[data-testid="todo-input"]', "寫測試文章")
page.press('[data-testid="todo-input"]', "Enter")
# 3. 驗證任務出現
todo_item = page.locator('[data-testid="todo-item"]')
expect(todo_item).to_have_text("寫測試文章")
# 4. 標記完成
page.click('[data-testid="todo-checkbox-0"]')
# 5. 驗證狀態更新
expect(todo_item).to_have_class("completed")
# 建立 tests/e2e/test_with_selenium.py
import pytest
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys
from selenium.webdriver.support.wait import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
@pytest.fixture
def driver():
driver = webdriver.Chrome()
driver.implicitly_wait(10)
yield driver
driver.quit()
def test_todo_creation(driver):
# 開啟應用
driver.get("http://localhost:3000")
# 輸入新任務
input_field = driver.find_element(By.ID, "todo-input")
input_field.send_keys("學習 E2E 測試")
input_field.send_keys(Keys.RETURN)
# 等待並驗證
WebDriverWait(driver, 10).until(
EC.presence_of_element_located((By.CLASS_NAME, "todo-item"))
)
todo_items = driver.find_elements(By.CLASS_NAME, "todo-item")
assert len(todo_items) == 1
assert "學習 E2E 測試" in todo_items[0].text
讓我們為 Todo 應用建立完整的 E2E 測試流程。
# 建立 tests/e2e/conftest.py
import pytest
import subprocess
import time
from playwright.sync_api import sync_playwright
@pytest.fixture(scope="session")
def api_server():
"""啟動 FastAPI 後端服務"""
process = subprocess.Popen(
["uvicorn", "main:app", "--port", "8000"],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE
)
time.sleep(3) # 等待服務啟動
yield "http://localhost:8000"
process.terminate()
process.wait()
@pytest.fixture(scope="session")
def browser():
"""初始化瀏覽器"""
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
yield browser
browser.close()
@pytest.fixture
def page(browser):
"""為每個測試建立新頁面"""
page = browser.new_page()
yield page
page.close()
# 建立 tests/e2e/test_user_journey.py
import pytest
from playwright.sync_api import Page, expect
class TestUserJourney:
"""測試完整的使用者操作流程"""
def test_new_user_experience(self, page: Page, api_server: str):
"""新使用者的完整體驗流程"""
# 1. 首次訪問
page.goto("http://localhost:3000")
# 驗證歡迎訊息
welcome = page.locator('[data-testid="welcome-message"]')
expect(welcome).to_contain_text("開始管理您的任務")
# 2. 建立第一個任務
self._create_todo(page, "我的第一個任務")
# 3. 驗證任務列表
todos = page.locator('[data-testid="todo-item"]')
expect(todos).to_have_count(1)
# 4. 建立更多任務
tasks = ["學習 pytest", "撰寫測試", "重構程式碼"]
for task in tasks:
self._create_todo(page, task)
# 5. 驗證計數器
counter = page.locator('[data-testid="todo-counter"]')
expect(counter).to_have_text("4 個待辦事項")
# 6. 完成任務
page.click('[data-testid="todo-checkbox-1"]') # 完成 "學習 pytest"
# 7. 驗證完成狀態
completed = page.locator('.todo-item.completed')
expect(completed).to_have_count(1)
# 8. 篩選功能
page.click('[data-testid="filter-active"]')
visible_todos = page.locator('[data-testid="todo-item"]:visible')
expect(visible_todos).to_have_count(3)
# 9. 清除完成項目
page.click('[data-testid="clear-completed"]')
todos = page.locator('[data-testid="todo-item"]')
expect(todos).to_have_count(3)
def _create_todo(self, page: Page, text: str):
"""輔助方法:建立新任務"""
input_field = page.locator('[data-testid="todo-input"]')
input_field.fill(text)
input_field.press("Enter")
# 等待任務出現
page.wait_for_selector(f'text="{text}"')
# 建立 tests/e2e/test_api_integration.py
import pytest
import requests
from playwright.sync_api import Page, expect
def test_data_persistence(page: Page, api_server: str):
"""測試資料持久化"""
# 1. 透過 API 建立任務
response = requests.post(
f"{api_server}/api/todos",
json={"title": "API 建立的任務", "completed": False}
)
assert response.status_code == 201
todo_id = response.json()["id"]
# 2. 重新載入頁面
page.goto("http://localhost:3000")
# 3. 驗證任務顯示
todo_item = page.locator(f'[data-testid="todo-{todo_id}"]')
expect(todo_item).to_be_visible()
expect(todo_item).to_contain_text("API 建立的任務")
# 4. 在前端完成任務
page.click(f'[data-testid="todo-checkbox-{todo_id}"]')
# 5. 透過 API 驗證狀態
response = requests.get(f"{api_server}/api/todos/{todo_id}")
assert response.json()["completed"] is True
def test_error_handling(page: Page):
"""測試錯誤處理"""
# 1. 模擬網路中斷
page.route("**/api/todos", lambda route: route.abort())
# 2. 嘗試建立任務
page.goto("http://localhost:3000")
input_field = page.locator('[data-testid="todo-input"]')
input_field.fill("無法儲存的任務")
input_field.press("Enter")
# 3. 驗證錯誤訊息
error_message = page.locator('[data-testid="error-message"]')
expect(error_message).to_be_visible()
expect(error_message).to_contain_text("網路錯誤")
# 建立 tests/e2e/test_performance.py
import time
import pytest
from playwright.sync_api import Page
def test_page_load_time(page: Page):
"""測試頁面載入時間"""
start_time = time.time()
page.goto("http://localhost:3000")
page.wait_for_load_state("networkidle")
load_time = time.time() - start_time
# 設定效能基準
assert load_time < 3.0, f"頁面載入時間過長: {load_time:.2f}秒"
def test_api_response_time(page: Page):
"""測試 API 回應時間"""
page.goto("http://localhost:3000")
# 監聽 API 請求
with page.expect_response("**/api/todos") as response_info:
page.reload()
response = response_info.value
# 檢查回應時間(毫秒)
timing = response.request.timing
assert timing["responseEnd"] - timing["requestStart"] < 500
# 建立 tests/e2e/pages/todo_page.py
class TodoPage:
"""Todo 頁面的抽象"""
def __init__(self, page):
self.page = page
self.url = "http://localhost:3000"
# 定義選擇器
self.input_selector = '[data-testid="todo-input"]'
self.todo_item_selector = '[data-testid="todo-item"]'
self.counter_selector = '[data-testid="todo-counter"]'
def navigate(self):
"""導航到頁面"""
self.page.goto(self.url)
def add_todo(self, text: str):
"""新增任務"""
self.page.fill(self.input_selector, text)
self.page.press(self.input_selector, "Enter")
def get_todos_count(self) -> int:
"""取得任務數量"""
return self.page.locator(self.todo_item_selector).count()
def complete_todo(self, index: int):
"""完成指定任務"""
checkbox = f'[data-testid="todo-checkbox-{index}"]'
self.page.click(checkbox)
# 使用頁面物件
def test_with_page_object(page: Page):
todo_page = TodoPage(page)
todo_page.navigate()
todo_page.add_todo("使用頁面物件模式")
assert todo_page.get_todos_count() == 1
todo_page.complete_todo(0)
# 驗證完成狀態...
# 建立 tests/e2e/fixtures/data_fixtures.py
@pytest.fixture
def test_data():
"""準備測試資料"""
return {
"todos": [
{"title": "測試任務 1", "completed": False},
{"title": "測試任務 2", "completed": True},
{"title": "測試任務 3", "completed": False}
]
}
@pytest.fixture
def seeded_database(api_server: str, test_data: dict):
"""預先填充資料庫"""
# 清空資料庫
requests.delete(f"{api_server}/api/todos/all")
# 插入測試資料
created_todos = []
for todo in test_data["todos"]:
response = requests.post(f"{api_server}/api/todos", json=todo)
created_todos.append(response.json())
yield created_todos
# 清理
requests.delete(f"{api_server}/api/todos/all")
# ❌ 錯誤:依賴固定等待時間
import time
def test_bad_practice(page):
page.click('button')
time.sleep(1) # 避免使用
assert page.is_visible('.result')
# ✅ 正確:等待特定條件
def test_good_practice(page):
page.click('button')
page.wait_for_selector('.result', state='visible')
assert page.is_visible('.result')
今天我們預覽了 E2E 測試的強大功能:
單元測試 → 快速回饋、大量覆蓋
整合測試 → 模組協作、API 測試
E2E 測試 → 使用者視角、關鍵流程
小提醒:E2E 測試雖然強大,但執行時間較長。記得合理安排測試策略,確保關鍵流程都有保護!