iT邦幫忙

2025 iThome 鐵人賽

DAY 27
0

「測試都通過了,為什麼上線還是出問題?」

你寫了完美的單元測試,整合測試也都綠燈,但使用者還是回報:「我點了按鈕,什麼事都沒發生!」這時你才發現,原來是前端和後端的 API 版本不相容...

這就是為什麼我們需要 E2E(End-to-End)測試!今天,讓我們預覽這個強大的測試武器。

🗺️ 我們的 TDD 旅程

基礎測試 → Kata 實戰 → 框架特色 → 整合部署
  1-10        11-17       18-27       28-30

                            ↓ 我們在這裡(Day 27)🎬
[=============================================>....]  

經過 26 天的學習,我們已經建立了完整的測試金字塔。今天要站在金字塔頂端,俯瞰整個測試版圖!

🎭 E2E 測試是什麼?

想像一下,你是一個真實的使用者:

  1. 打開瀏覽器
  2. 輸入網址
  3. 點擊按鈕
  4. 填寫表單
  5. 檢查結果

E2E 測試就是模擬這整個過程!

測試金字塔回顧 🏔️

        /\
       /E2E\      ← 今天的主角!
      /------\
     /整合測試\
    /----------\
   /  單元測試   \
  /--------------\
  • 單元測試:快速、獨立、大量(Day 1-10 學習)
  • 整合測試:模組間互動(Day 25 學習)
  • E2E 測試:完整使用者流程(今天預覽)

🚀 E2E 測試工具選擇

1. Playwright - 現代化的瀏覽器自動化

# 建立 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")

2. Selenium - 經典選擇

# 建立 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 App 的 E2E 測試

讓我們為 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}"')

🔍 進階測試技巧

API 與前端的整合測試

# 建立 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

最佳實踐

1. 頁面物件模式 (Page Object Model)

# 建立 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)
    # 驗證完成狀態...

2. 資料準備策略

# 建立 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")

🚨 常見陷阱與解決方案

1. 測試不穩定(Flaky Tests)

# ❌ 錯誤:依賴固定等待時間
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 測試的強大功能:

✅ 學到的技能

  1. E2E 測試概念:理解測試金字塔頂端
  2. Playwright 設定:建立 E2E 測試環境
  3. 實作技巧:Page Object Model
  4. 視覺測試:截圖比較
  5. 效能整合:結合效能監控

🔄 測試策略總結

單元測試 → 快速回饋、大量覆蓋
整合測試 → 模組協作、API 測試
E2E 測試 → 使用者視角、關鍵流程

💡 最佳實踐

  • 只測試關鍵使用者流程
  • 使用 Page Object 模式組織程式碼
  • 避免測試實作細節、保持測試獨立性、設定合理的超時時間

小提醒:E2E 測試雖然強大,但執行時間較長。記得合理安排測試策略,確保關鍵流程都有保護!


上一篇
Day 26 - 效能測試 ⚡
下一篇
Day 28 - 整合準備 🔧
系列文
Python pytest TDD 實戰:從零開始的測試驅動開發29
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言