API 测试技能
针对 FastAPI 后端和 React/Next.js 前端的专家级测试,包括单元测试、集成测试和端到端测试模式。
快速参考
| 测试类型 |
工具 |
目的 |
范围 |
| 单元 |
pytest |
纯函数,服务 |
隔离 |
| 集成 |
pytest + TestClient |
数据库 + 认证 + 路由 |
组合 |
| 端到端 |
Playwright/Cypress |
浏览器流程 |
全栈 |
项目结构
backend/
├── tests/
│ ├── __init__.py
│ ├── conftest.py # 共享固定装置
│ ├── unit/
│ │ ├── test_services.py # 业务逻辑测试
│ │ └── test_utils.py # 工具函数测试
│ ├── integration/
│ │ ├── test_students.py # 学生 API 测试
│ │ ├── test_fees.py # 费用 API 测试
│ │ └── test_auth.py # 认证测试
│ └── fixtures/
│ ├── students.json # 测试数据
│ └── users.json
frontend/
├── e2e/
│ ├── specs/
│ │ ├── student.spec.ts
│ │ └── fee.spec.ts
│ ├── pages/
│ │ ├── DashboardPage.ts
│ │ └── StudentPage.ts
│ └── utils/
│ └── test-data.ts
└── playwright.config.ts
后端:Pytest 设置
# backend/tests/conftest.py
import pytest
from typing import Generator
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from app.main import app
from app.db.database import get_db, Base
from app.models import User, Student
from app.auth.jwt import create_access_token
from passlib.context import CryptContext
# 测试数据库设置
SQLALCHEMY_DATABASE_URL = "sqlite:///:memory:"
engine = create_engine(
SQLALCHEMY_DATABASE_URL,
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
@pytest.fixture(scope="function")
def db_session():
"""为每个测试创建一个新的数据库。"""
Base.metadata.create_all(bind=engine)
session = TestingSessionLocal()
try:
yield session
finally:
session.close()
Base.metadata.drop_all(bind=engine)
@pytest.fixture(scope="function")
def client(db_session):
"""使用数据库覆盖创建测试客户端。"""
def override_get_db():
try:
yield db_session
finally:
pass
app.dependency_overrides[get_db] = override_get_db
with TestClient(app) as test_client:
yield test_client
app.dependency_overrides.clear()
@pytest.fixture
def test_user(db_session):
"""创建测试用户。"""
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
hashed_password = pwd_context.hash("testpassword123")
user = User(
email="test@example.com",
hashed_password=hashed_password,
full_name="Test User",
is_active=True,
)
db_session.add(user)
db_session.commit()
db_session.refresh(user)
return user
@pytest.fixture
def auth_token(test_user):
"""为测试用户生成 JWT 令牌。"""
return create_access_token(data={"sub": test_user.email, "roles": ["admin"]})
@pytest.fixture
def auth_headers(auth_token):
"""带有认证令牌的头部。"""
return {"Authorization": f"Bearer {auth_token}"}
单元测试(纯函数)
# backend/tests/unit/test_services.py
import pytest
from app.services.fee_calculator import calculate_fee, FeeCalculationError
class TestCalculateFee:
"""费用计算逻辑的单元测试。"""
def test_basic_fee_calculation(self):
"""测试基本费用计算,不包括折扣。"""
result = calculate_fee(
base_amount=1000.00,
grade_level=9,
has_sibling_discount=False,
is_new_student=False,
)
assert result == 1000.00
def test_sibling_discount(self):
"""测试 10% 的兄弟姐妹折扣。"""
result = calculate_fee(
base_amount=1000.00,
grade_level=9,
has_sibling_discount=True,
is_new_student=False,
)
assert result == 900.00
def test_new_student_discount(self):
"""测试 15% 的新生折扣。"""
result = calculate_fee(
base_amount=1000.00,
grade_level=9,
has_sibling_discount=False,
is_new_student=True,
)
assert result == 850.00
def test_combined_discounts(self):
"""测试组合的兄弟姐妹和新生折扣。"""
result = calculate_fee(
base_amount=1000.00,
grade_level=9,
has_sibling_discount=True,
is_new_student=True,
)
# 10% + 15% = 25% 折扣
assert result == 750.00
def test_invalid_base_amount(self):
"""测试负金额引发错误。"""
with pytest.raises(FeeCalculationError):
calculate_fee(
base_amount=-100.00,
grade_level=9,
has_sibling_discount=False,
is_new_student=False,
)
def test_grade_level_multipliers(self):
"""测试不同的年级水平乘数。"""
# 小学(1-5):1.0x
assert calculate_fee(1000.00, grade_level=3) == 1000.00
# 中学(6-8):1.1x
assert calculate_fee(1000.00, grade_level=7) == 1100.00
# 高中(9-12):1.2x
assert calculate_fee(1000.00, grade_level=10) == 1200.00
集成测试(API 端点)
# backend/tests/integration/test_students.py
import pytest
from fastapi import status
class TestStudentEndpoints:
"""学生 CRUD 端点的集成测试。"""
@pytest.fixture
def create_student_payload(self):
"""学生创建样本负载。"""
return {
"first_name": "John",
"last_name": "Doe",
"email": "john.doe@test.edu",
"date_of_birth": "2008-05-15T00:00:00Z",
"grade_level": 9,
}
def test_create_student_success(self, client, auth_headers, create_student_payload):
"""测试成功创建学生。"""
response = client.post(
"/api/v1/students/",
json=create_student_payload,
headers=auth_headers,
)
assert response.status_code == status.HTTP_201_CREATED
data = response.json()
assert data["first_name"] == "John"
assert data["last_name"] == "Doe"
assert "id" in data
assert data["is_active"] is True
def test_create_student_unauthorized(self, client, create_student_payload):
"""测试未经认证的请求被拒绝。"""
response = client.post(
"/api/v1/students/",
json=create_student_payload,
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_create_student_invalid_email(self, client, auth_headers, create_student_payload):
"""测试无效电子邮件的验证错误。"""
payload = {**create_student_payload, "email": "invalid-email"}
response = client.post(
"/api/v1/students/",
json=payload,
headers=auth_headers,
)
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
def test_create_student_missing_required_field(self, client, auth_headers):
"""测试缺少必填字段的验证错误。"""
payload = {
"first_name": "John",
# 缺少 last_name, email, date_of_birth, grade_level
}
response = client.post(
"/api/v1/students/",
json=payload,
headers=auth_headers,
)
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
def test_get_student_success(self, client, auth_headers, create_student_payload):
"""测试通过 ID 检索学生。"""
# 首先创建学生
create_response = client.post(
"/api/v1/students/",
json=create_student_payload,
headers=auth_headers,
)
student_id = create_response.json()["id"]
# 检索学生
response = client.get(
f"/api/v1/students/{student_id}",
headers=auth_headers,
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["id"] == student_id
def test_get_student_not_found(self, client, auth_headers):
"""测试 404 错误用于不存在的学生。"""
response = client.get(
"/api/v1/students/99999",
headers=auth_headers,
)
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_list_students_pagination(self, client, auth_headers, db_session):
"""测试学生列表分页。"""
# 创建多个学生
for i in range(5):
payload = {
"first_name": f"Student{i}",
"last_name": "Test",
"email": f"student{i}@test.edu",
"date_of_birth": "2008-05-15T00:00:00Z",
"grade_level": 9,
}
client.post("/api/v1/students/", json=payload, headers=auth_headers)
# 获取第一页
response = client.get(
"/api/v1/students/?skip=0&limit=3",
headers=auth_headers,
)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert len(data["data"]) == 3
assert data["total"] == 5
assert data["has_more"] is True
def test_update_student(self, client, auth_headers, create_student_payload):
"""测试学生的部分更新。"""
# 创建学生
create_response = client.post(
"/api/v1/students/",
json=create_student_payload,
headers=auth_headers,
)
student_id = create_response.json()["id"]
# 更新学生
update_payload = {"first_name": "Jane", "grade_level": 10}
response = client.patch(
f"/api/v1/students/{student_id}",
json=update_payload,
headers=auth_headers,
)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert data["first_name"] == "Jane"
assert data["grade_level"] == 10
def test_delete_student(self, client, auth_headers, create_student_payload):
"""测试学生软删除。"""
# 创建学生
create_response = client.post(
"/api/v1/students/",
json=create_student_payload,
headers=auth_headers,
)
student_id = create_response.json()["id"]
# 删除学生
response = client.delete(
f"/api/v1/students/{student_id}",
headers=auth_headers,
)
assert response.status_code == status.HTTP_204_NO_CONTENT
# 验证学生不在活动列表中
list_response = client.get(
"/api/v1/students/",
headers=auth_headers,
)
student_ids = [s["id"] for s in list_response.json()["data"]]
assert student_id not in student_ids
测试固定装置(JSON 数据)
// backend/tests/fixtures/students.json
{
"valid_student": {
"first_name": "John",
"last_name": "Doe",
"email": "john.doe@test.edu",
"date_of_birth": "2008-05-15T00:00:00Z",
"grade_level": 9
},
"invalid_students": [
{
"description": "缺少 first_name",
"data": {
"last_name": "Doe",
"email": "test@test.edu",
"date_of_birth": "2008-05-15T00:00:00Z",
"grade_level": 9
}
},
{
"description": "无效电子邮件格式",
"data": {
"first_name": "John",
"last_name": "Doe",
"email": "not-an-email",
"date_of_birth": "2008-05-15T00:00:00Z",
"grade_level": 9
}
},
{
"description": "年级水平超出范围",
"data": {
"first_name": "John",
"last_name": "Doe",
"email": "test@test.edu",
"date_of_birth": "2008-05-15T00:00:00Z",
"grade_level": 15
}
}
]
}
前端:端到端测试(Playwright)
playwright.config.ts
// frontend/playwright.config.ts
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./e2e/specs",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: "html",
use: {
baseURL: "http://localhost:3000",
trace: "on-first-retry",
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
{
name: "firefox",
use: { ...devices["Desktop Firefox"] },
},
{
name: "webkit",
use: { ...devices["Desktop Safari"] },
},
],
webServer: {
command: "npm run dev",
url: "http://localhost:3000",
reuseExistingServer: !process.env.CI,
},
});
端到端测试规范
// frontend/e2e/specs/student.spec.ts
import { test, expect } from "@playwright/test";
test.describe("学生管理", () => {
test.beforeEach(async ({ page }) => {
// 导航到登录页面
await page.goto("/login");
// 作为管理员登录
await page.fill('input[name="email"]', "admin@test.edu");
await page.fill('input[name="password"]', "adminpassword");
await page.click('button[type="submit"]');
// 验证登录成功
await expect(page).toHaveURL(/\/dashboard/);
await expect(page.locator("text=Admin")).toBeVisible();
});
test("应该成功创建新学生", async ({ page }) => {
// 导航到学生页面
await page.click('a[href="/students"]');
await expect(page).toHaveURL(/\/students/);
// 点击添加学生按钮
await page.click('button:has-text("Add Student")');
// 填写学生表格
await page.fill('input[name="firstName"]', "John");
await page.fill('input[name="lastName"]', "Doe");
await page.fill('input[name="email"]', "john.doe@test.edu");
await page.fill('input[name="dateOfBirth"]', "2008-05-15");
// 选择年级水平
await page.selectOption('select[name="gradeLevel"]', "9");
// 提交表格
await page.click('button:has-text("Create")');
// 验证学生已创建
await expect(page.locator("text=Student created successfully")).toBeVisible();
// 验证学生出现在列表中
await expect(page.locator("text=John Doe")).toBeVisible();
});
test("应该显示无效输入的验证错误", async ({ page }) => {
await page.click('a[href="/students"]');
await page.click('button:has-text("Add Student")');
// 提交空表格
await page.click('button:has-text("Create")');
// 验证验证错误
await expect(page.locator("text=First name is required")).toBeVisible();
await expect(page.locator("text=Last name is required")).toBeVisible();
await expect(page.locator("text=Invalid email address")).toBeVisible();
});
test("应该按年级水平过滤学生", async ({ page }) => {
await page.click('a[href="/students"]');
// 按 9 年级过滤
await page.selectOption('select[name="gradeFilter"]', "9");
await page.click('button:has-text("Apply")');
// 验证只显示 9 年级学生
const rows = page.locator("table.student-list tbody tr");
await expect(rows).toHaveCount(3); // 假设有 3 名 9 年级学生
});
test("应该查看学生详情", async ({ page }) => {
await page.click('a[href="/students"]');
// 点击第一名学生
await page.click('table.student-list tbody tr:first-child a');
// 验证详情页面
await expect(page).toHaveURL(/\/students\/\d+/);
await expect(page.locator("h1")).toContainText("Student Details");
});
});
页面对象模型
// frontend/e2e/pages/StudentsPage.ts
import { Page, Locator, expect } from "@playwright/test";
export class StudentsPage {
readonly page: Page;
readonly addButton: Locator;
readonly studentTable: Locator;
readonly gradeFilter: Locator;
readonly searchInput: Locator;
constructor(page: Page) {
this.page = page;
this.addButton = page.locator('button:has-text("Add Student")');
this.studentTable = page.locator("table.student-list");
this.gradeFilter = page.locator('select[name="gradeFilter"]');
this.searchInput = page.locator('input[name="search"]');
}
async goto() {
await this.page.goto("/students");
}
async createStudent(data: {
firstName: string;
lastName: string;
email: string;
gradeLevel: string;
dateOfBirth?: string;
}) {
await this.addButton.click();
await this.page.fill('input[name="firstName"]', data.firstName);
await this.page.fill('input[name="lastName"]', data.lastName);
await this.page.fill('input[name="email"]', data.email);
await this.page.selectOption('select[name="gradeLevel"]', data.gradeLevel);
if (data.dateOfBirth) {
await this.page.fill('input[name="dateOfBirth"]', data.dateOfBirth);
}
await this.page.click('button:has-text("Create")');
}
async getStudentNames(): Promise<string[]> {
const rows = this.studentTable.locator("tbody tr");
const names: string[] = [];
for (const row of await rows.all()) {
names.push(await row.locator("td:first-child").textContent());
}
return names;
}
async filterByGrade(grade: string) {
await this.gradeFilter.selectOption(grade);
await this.page.click('button:has-text("Apply")');
}
async searchByName(name: string) {
await this.searchInput.fill(name);
await this.page.keyboard.press("Enter");
}
}
测试金字塔
/\\
/ \
/ \ 端到端测试(10%)
/______\
/ \
/ \ 集成测试(30%)
/ \
/______________\
/ \
/ \ 单元测试(60%)
/ \
/______________________\
质量检查表
- [ ] 成功路径 + 边缘情况:测试成功和错误场景
- [ ] CI兼容:测试在 CI 管道中运行,无需手动设置
- [ ] 确定性:无不稳定测试,无随机失败
- [ ] 覆盖率:核心模块 80%+,关键路径 90%+
- [ ] 无真实秘密:使用测试凭据,永不生产密钥
- [ ] 无生产数据库:使用测试数据库或内存 SQLite
- [ ] 隔离:测试不依赖于彼此
- [ ] 快速:单元测试 < 100ms,集成 < 1s
运行测试
# 后端测试
pytest # 运行所有测试
pytest tests/unit/ # 仅单元测试
pytest tests/integration/ # 仅集成测试
pytest -v # 详细输出
pytest --cov=app # 带覆盖率
# 前端端到端测试
npx playwright install # 安装浏览器
npx playwright test # 运行端到端测试
npx playwright test --reporter=line
CI 配置
# .github/workflows/test.yml
name: Tests
on: [push, pull_request]
jobs:
test-backend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- run: pip install -r requirements.txt
- run: pip install pytest pytest-cov
- run: pytest --cov=app --cov-report=xml
- uses: codecov/codecov-action@v3
test-frontend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
- run: npm ci
- run: npm run test
- run: npm run build
集成点
| 技能 |
集成 |
@sqlmodel-crud |
测试数据库的 CRUD 操作 |
@jwt-auth |
测试认证端点的测试令牌 |
@api-route-design |
测试所有 CRUD 路由的各种状态码 |
@error-handling |
测试错误响应和边缘情况 |
@data-validation |
测试验证错误消息 |