測試生成技能
描述
為指定的程式碼自動生成完整測試套件,包含靜態分析、單元測試、整合測試及覆蓋率報告。
觸發條件
- 「生成測試」、「寫測試」、「test this」
- 「建立單元測試」、「建立整合測試」
- 「靜態分析」、「type check」
- 「覆蓋率」、「coverage」
測試金字塔
/\
/ \ E2E Tests (少量)
/----\
/ \ Integration Tests (中等)
/--------\
/ \ Unit Tests (大量)
/------------\
/ Static Analysis (基礎)
Python 測試策略
1️⃣ 靜態分析 (Static Analysis)
工具配置
| 工具 |
用途 |
配置檔 |
| mypy |
類型檢查 |
pyproject.toml / mypy.ini |
| ruff |
Linting + Formatting (取代 pylint/flake8/black) |
pyproject.toml |
| bandit |
安全性掃描 |
.bandit |
mypy 配置範例
# pyproject.toml
[tool.mypy]
python_version = "3.11"
strict = true
warn_return_any = true
warn_unused_ignores = true
disallow_untyped_defs = true
disallow_incomplete_defs = true
check_untyped_defs = true
no_implicit_optional = true
[[tool.mypy.overrides]]
module = ["tests.*"]
disallow_untyped_defs = false
ruff 配置範例
# pyproject.toml
[tool.ruff]
target-version = "py311"
line-length = 88
select = [
"E", # pycodestyle errors
"W", # pycodestyle warnings
"F", # pyflakes
"I", # isort
"B", # flake8-bugbear
"C4", # flake8-comprehensions
"UP", # pyupgrade
"ARG", # flake8-unused-arguments
"SIM", # flake8-simplify
]
ignore = ["E501"] # line too long (handled by formatter)
[tool.ruff.isort]
known-first-party = ["src"]
2️⃣ 單元測試 (Unit Tests)
pytest 配置
# pyproject.toml
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py", "*_test.py"]
python_functions = ["test_*"]
addopts = [
"-v",
"--strict-markers",
"--tb=short",
"-ra",
]
markers = [
"unit: Unit tests",
"integration: Integration tests",
"slow: Slow running tests",
]
filterwarnings = [
"error",
"ignore::DeprecationWarning",
]
測試結構
tests/
├── __init__.py
├── conftest.py # 共用 fixtures
├── unit/ # 單元測試
│ ├── __init__.py
│ ├── conftest.py
│ ├── test_domain/ # Domain 層測試
│ ├── test_application/ # Application 層測試
│ └── test_utils/
├── integration/ # 整合測試
│ ├── __init__.py
│ ├── conftest.py
│ ├── test_api/
│ ├── test_database/
│ └── test_external/
└── e2e/ # 端對端測試
└── ...
單元測試範例
# tests/unit/test_domain/test_user.py
import pytest
from src.domain.entities import User
from src.domain.exceptions import ValidationError
class TestUser:
"""User entity 單元測試"""
# === Happy Path ===
def test_create_user_with_valid_data(self):
"""正常建立使用者"""
user = User(name="Alice", email="alice@example.com")
assert user.name == "Alice"
assert user.email == "alice@example.com"
# === 邊界條件 ===
def test_create_user_with_minimum_name_length(self):
"""名稱最小長度"""
user = User(name="A", email="a@b.c")
assert len(user.name) == 1
@pytest.mark.parametrize("name,expected", [
("A" * 100, 100),
("中文名字", 4),
])
def test_name_length_variations(self, name: str, expected: int):
"""名稱長度變化測試"""
user = User(name=name, email="test@test.com")
assert len(user.name) == expected
# === 錯誤處理 ===
def test_create_user_with_empty_name_raises_error(self):
"""空名稱應拋出 ValidationError"""
with pytest.raises(ValidationError, match="Name cannot be empty"):
User(name="", email="test@test.com")
def test_create_user_with_invalid_email_raises_error(self):
"""無效 email 應拋出 ValidationError"""
with pytest.raises(ValidationError, match="Invalid email format"):
User(name="Test", email="not-an-email")
# === Null/None 處理 ===
def test_create_user_with_none_name_raises_error(self):
"""None 名稱應拋出 TypeError"""
with pytest.raises(TypeError):
User(name=None, email="test@test.com")
3️⃣ 整合測試 (Integration Tests)
API 整合測試
# tests/integration/test_api/test_user_api.py
import pytest
from httpx import AsyncClient
from src.main import app
@pytest.mark.integration
@pytest.mark.asyncio
class TestUserAPI:
"""User API 整合測試"""
async def test_create_user_endpoint(self, async_client: AsyncClient):
"""POST /users 建立使用者"""
response = await async_client.post(
"/api/v1/users",
json={"name": "Test User", "email": "test@example.com"}
)
assert response.status_code == 201
data = response.json()
assert data["name"] == "Test User"
assert "id" in data
async def test_get_user_endpoint(self, async_client: AsyncClient, created_user):
"""GET /users/{id} 取得使用者"""
response = await async_client.get(f"/api/v1/users/{created_user.id}")
assert response.status_code == 200
assert response.json()["id"] == str(created_user.id)
async def test_get_nonexistent_user_returns_404(self, async_client: AsyncClient):
"""取得不存在的使用者應返回 404"""
response = await async_client.get("/api/v1/users/nonexistent-id")
assert response.status_code == 404
資料庫整合測試
# tests/integration/test_database/test_user_repository.py
import pytest
from src.infrastructure.repositories import UserRepository
from src.domain.entities import User
@pytest.mark.integration
class TestUserRepository:
"""UserRepository 整合測試 (實際資料庫)"""
@pytest.fixture
def repository(self, db_session):
return UserRepository(session=db_session)
async def test_save_and_retrieve_user(self, repository: UserRepository):
"""儲存並取回使用者"""
user = User(name="Test", email="test@test.com")
saved_user = await repository.save(user)
retrieved = await repository.get_by_id(saved_user.id)
assert retrieved is not None
assert retrieved.name == "Test"
async def test_find_by_email(self, repository: UserRepository):
"""透過 email 查詢"""
user = User(name="Test", email="unique@test.com")
await repository.save(user)
found = await repository.find_by_email("unique@test.com")
assert found is not None
assert found.email == "unique@test.com"
conftest.py (整合測試 fixtures)
# tests/integration/conftest.py
import pytest
import pytest_asyncio
from httpx import AsyncClient, ASGITransport
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
from src.main import app
from src.infrastructure.database import Base
# === 測試資料庫 ===
TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:"
@pytest.fixture(scope="session")
def event_loop():
"""建立 event loop for async tests"""
import asyncio
loop = asyncio.get_event_loop_policy().new_event_loop()
yield loop
loop.close()
@pytest_asyncio.fixture(scope="function")
async def db_engine():
"""建立測試資料庫引擎"""
engine = create_async_engine(TEST_DATABASE_URL, echo=False)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield engine
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
await engine.dispose()
@pytest_asyncio.fixture(scope="function")
async def db_session(db_engine):
"""建立測試資料庫 session"""
async_session = sessionmaker(
db_engine, class_=AsyncSession, expire_on_commit=False
)
async with async_session() as session:
yield session
await session.rollback()
# === HTTP Client ===
@pytest_asyncio.fixture
async def async_client():
"""建立非同步 HTTP 測試客戶端"""
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
yield client
5️⃣ E2E 測試 (End-to-End Tests)
E2E 測試工具選擇
| 工具 |
適用場景 |
特點 |
| Playwright |
Web UI 測試 |
跨瀏覽器、自動等待、截圖/錄影 |
| Selenium |
傳統 Web 測試 |
廣泛支援、成熟穩定 |
| pytest + httpx |
API E2E |
輕量、快速 |
| Locust |
負載/效能測試 |
分散式、Python 原生 |
Playwright 配置 (推薦)
# pyproject.toml
[tool.pytest.ini_options]
markers = [
"e2e: End-to-end tests (require running application)",
]
# tests/e2e/conftest.py
import pytest
from playwright.async_api import async_playwright, Browser, Page
@pytest.fixture(scope="session")
def browser_type():
"""可透過環境變數切換瀏覽器"""
import os
return os.getenv("BROWSER", "chromium") # chromium, firefox, webkit
@pytest.fixture(scope="session")
async def browser(browser_type: str):
"""建立瀏覽器實例 (session 級別)"""
async with async_playwright() as p:
browser = await getattr(p, browser_type).launch(
headless=True,
slow_mo=100, # 放慢操作以便觀察
)
yield browser
await browser.close()
@pytest.fixture
async def page(browser: Browser):
"""建立新頁面 (每個測試獨立)"""
context = await browser.new_context(
viewport={"width": 1280, "height": 720},
record_video_dir="test-results/videos", # 錄製影片
)
page = await context.new_page()
yield page
await context.close()
@pytest.fixture
def base_url():
"""應用程式 base URL"""
import os
return os.getenv("APP_URL", "http://localhost:8000")
E2E 測試範例
# tests/e2e/test_user_journey.py
import pytest
from playwright.async_api import Page, expect
@pytest.mark.e2e
@pytest.mark.asyncio
class TestUserJourney:
"""使用者旅程 E2E 測試"""
async def test_user_registration_flow(self, page: Page, base_url: str):
"""測試完整註冊流程"""
# 1. 前往註冊頁面
await page.goto(f"{base_url}/register")
await expect(page).to_have_title("Register")
# 2. 填寫表單
await page.fill("input[name='username']", "testuser")
await page.fill("input[name='email']", "test@example.com")
await page.fill("input[name='password']", "SecureP@ss123")
await page.fill("input[name='confirm_password']", "SecureP@ss123")
# 3. 提交表單
await page.click("button[type='submit']")
# 4. 驗證結果
await expect(page).to_have_url(f"{base_url}/dashboard")
await expect(page.locator(".welcome-message")).to_contain_text("Welcome, testuser")
async def test_login_logout_flow(self, page: Page, base_url: str):
"""測試登入登出流程"""
# 登入
await page.goto(f"{base_url}/login")
await page.fill("input[name='email']", "test@example.com")
await page.fill("input[name='password']", "SecureP@ss123")
await page.click("button[type='submit']")
await expect(page.locator(".user-menu")).to_be_visible()
# 登出
await page.click(".logout-button")
await expect(page).to_have_url(f"{base_url}/")
async def test_create_item_flow(self, page: Page, base_url: str, authenticated_page):
"""測試建立項目流程 (需登入)"""
await authenticated_page.goto(f"{base_url}/items/new")
await authenticated_page.fill("input[name='title']", "Test Item")
await authenticated_page.fill("textarea[name='description']", "Description")
await authenticated_page.click("button[type='submit']")
await expect(authenticated_page.locator(".success-toast")).to_be_visible()
API E2E 測試 (無 UI)
# tests/e2e/test_api_e2e.py
import pytest
import httpx
@pytest.mark.e2e
@pytest.mark.asyncio
class TestAPIEndToEnd:
"""API E2E 測試 - 測試完整 API 流程"""
@pytest.fixture
async def client(self, base_url: str):
async with httpx.AsyncClient(base_url=base_url) as client:
yield client
async def test_complete_crud_flow(self, client: httpx.AsyncClient):
"""測試完整 CRUD 流程"""
# Create
response = await client.post("/api/v1/items", json={"name": "Test"})
assert response.status_code == 201
item_id = response.json()["id"]
# Read
response = await client.get(f"/api/v1/items/{item_id}")
assert response.status_code == 200
assert response.json()["name"] == "Test"
# Update
response = await client.put(
f"/api/v1/items/{item_id}",
json={"name": "Updated"}
)
assert response.status_code == 200
# Delete
response = await client.delete(f"/api/v1/items/{item_id}")
assert response.status_code == 204
# Verify deletion
response = await client.get(f"/api/v1/items/{item_id}")
assert response.status_code == 404
6️⃣ 覆蓋率 (Coverage)
pytest-cov 配置
# pyproject.toml
[tool.coverage.run]
source = ["src"]
branch = true
parallel = true
omit = [
"*/tests/*",
"*/__init__.py",
"*/migrations/*",
]
[tool.coverage.paths]
source = ["src"]
[tool.coverage.report]
exclude_lines = [
"pragma: no cover",
"def __repr__",
"raise AssertionError",
"raise NotImplementedError",
"if TYPE_CHECKING:",
"if __name__ == .__main__.:",
"@abstractmethod",
]
fail_under = 80
show_missing = true
skip_covered = true
[tool.coverage.html]
directory = "htmlcov"
[tool.coverage.xml]
output = "coverage.xml"
執行覆蓋率
# 單元測試覆蓋率
pytest tests/unit -v --cov=src --cov-report=term-missing --cov-report=html
# 整合測試覆蓋率
pytest tests/integration -v --cov=src --cov-report=xml --cov-append
# 全部測試 + 覆蓋率報告
pytest --cov=src --cov-report=term-missing --cov-report=html --cov-report=xml
測試框架對照表
| 語言 |
單元測試 |
整合測試 |
覆蓋率 |
靜態分析 |
| Python |
pytest |
pytest + httpx |
pytest-cov |
mypy, ruff, bandit |
| JavaScript |
Jest / Vitest |
Supertest |
c8 / istanbul |
ESLint, TypeScript |
| TypeScript |
Jest / Vitest |
Supertest |
c8 / istanbul |
tsc --noEmit, ESLint |
| Go |
testing |
testing + testcontainers |
go test -cover |
golangci-lint |
| Rust |
cargo test |
cargo test |
cargo-tarpaulin |
clippy |
CI 整合 Checklist
生成測試時應同步確認:
測試依賴 (Python)
# pyproject.toml [project.optional-dependencies] 或 requirements-dev.txt
[project.optional-dependencies]
dev = [
# Testing - Core
"pytest>=7.4.0",
"pytest-cov>=4.1.0",
"pytest-asyncio>=0.21.0",
"pytest-xdist>=3.3.0", # 平行測試
"pytest-mock>=3.11.0",
"pytest-timeout>=2.1.0",
"httpx>=0.24.0", # Async HTTP client for API tests
"factory-boy>=3.3.0", # Test data factories
"faker>=19.0.0", # Fake data generation
# E2E Testing
"playwright>=1.40.0", # Browser automation
"pytest-playwright>=0.4.0", # Playwright pytest plugin
"locust>=2.20.0", # Load testing (optional)
# Static Analysis
"mypy>=1.5.0",
"ruff>=0.0.290",
"bandit[toml]>=1.7.5",
# Type stubs
"types-requests",
"types-python-dateutil",
]
輸出格式
## 測試套件生成報告
### 📁 檔案結構
[生成的測試目錄結構]
### 📋 測試清單
#### 靜態分析
- [ ] mypy 類型檢查
- [ ] ruff linting
- [ ] bandit 安全掃描
#### 單元測試 (`tests/unit/`)
- ✅ 正常流程 (Happy Path)
- ✅ 邊界條件 (Edge Cases)
- ✅ 錯誤處理 (Error Handling)
- ✅ Null/None 處理
#### 整合測試 (`tests/integration/`)
- ✅ API 端點測試
- ✅ 資料庫操作測試
- ✅ 外部服務測試 (mocked)
#### E2E 測試 (`tests/e2e/`)
- ✅ 使用者旅程測試
- ✅ 關鍵流程驗證
- ✅ 跨瀏覽器測試 (Playwright)
### 📊 覆蓋率目標
- 單元測試:≥ 90%
- 整合測試:≥ 70%
- 總體覆蓋:≥ 80%
### ⚙️ 執行指令
[相關測試執行命令]