API测试技能 api-testing

专家级 API 测试技能,涵盖 FastAPI 后端和 React/Next.js 前端的单元测试、集成测试和端到端测试,强调测试覆盖率和质量保障。

测试 0 次安装 0 次浏览 更新于 3/2/2026

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 设置

conftest.py(共享固定装置)

# 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 测试验证错误消息