🚀 快速安装

复制以下命令并运行,立即安装此 Skill:

npx @anthropic-ai/skills install supercent-io/skills-template/backend-testing

💡 提示:需要 Node.js 和 NPM

后端测试

何时使用此技能

应触发此技能的具体情况:

  • 新功能开发:使用测试驱动开发(TDD)首先编写测试
  • 添加 API 端点:测试 REST API 的成功和失败情况
  • 错误修复:添加测试以防止回归
  • 重构前:编写测试以确保现有行为
  • CI/CD 设置:构建自动化测试流水线

输入格式

需要从用户收集的格式及必需/可选信息:

必需信息

  • 框架:Express、Django、FastAPI、Spring Boot 等
  • 测试工具:Jest、Pytest、Mocha/Chai、JUnit 等
  • 测试目标:API 端点、业务逻辑、数据库操作等

可选信息

  • 数据库:PostgreSQL、MySQL、MongoDB(默认:内存数据库)
  • 模拟库:jest.mock、sinon、unittest.mock(默认:框架内置)
  • 覆盖率目标:80%、90% 等(默认:80%)
  • 端到端测试工具:Supertest、TestClient、RestAssured(可选)

输入示例

为 Express.js API 测试用户认证端点:
- 框架:Express + TypeScript
- 测试工具:Jest + Supertest
- 目标:POST /auth/register, POST /auth/login
- 数据库:PostgreSQL(测试时使用内存数据库)
- 覆盖率:90% 或以上

指示

要精确遵循的逐步任务顺序。

步骤 1:设置测试环境

安装和配置测试框架和工具。

任务

  • 安装测试库
  • 配置测试数据库(内存数据库或单独的数据库)
  • 单独的环境变量(.env.test)
  • 配置 jest.config.js 或 pytest.ini

示例(Node.js + Jest + Supertest):

npm install --save-dev jest ts-jest @types/jest supertest @types/supertest

jest.config.js

module.exports = {
  preset: 'ts-jest',
  testEnvironment: 'node',
  roots: ['<rootDir>/src'],
  testMatch: ['**/__tests__/**/*.test.ts'],
  collectCoverageFrom: [
    'src/**/*.ts',
    '!src/**/*.d.ts',
    '!src/__tests__/**'
  ],
  coverageThreshold: {
    global: {
      branches: 80,
      functions: 80,
      lines: 80,
      statements: 80
    }
  },
  setupFilesAfterEnv: ['<rootDir>/src/__tests__/setup.ts']
};

setup.ts(全局测试配置):

import { db } from '../database';

// 每个测试前重置数据库
beforeEach(async () => {
  await db.migrate.latest();
  await db.seed.run();
});

// 每个测试后清理
afterEach(async () => {
  await db.migrate.rollback();
});

// 所有测试完成后关闭连接
afterAll(async () => {
  await db.destroy();
});

步骤 2:编写单元测试(业务逻辑)

为单个函数和类编写单元测试。

任务

  • 测试纯函数(无依赖)
  • 通过模拟隔离依赖
  • 测试边缘情况(边界值、异常)
  • AAA 模式(准备-执行-断言)

决策标准

  • 无外部依赖(数据库、API) -> 纯单元测试
  • 存在外部依赖 -> 使用模拟/桩
  • 复杂逻辑 -> 测试各种输入情况

示例(密码验证函数):

// src/utils/password.ts
export function validatePassword(password: string): { valid: boolean; errors: string[] } {
  const errors: string[] = [];

  if (password.length < 8) {
    errors.push('密码长度必须至少为 8 个字符');
  }

  if (!/[A-Z]/.test(password)) {
    errors.push('密码必须包含大写字母');
  }

  if (!/[a-z]/.test(password)) {
    errors.push('密码必须包含小写字母');
  }

  if (!/\d/.test(password)) {
    errors.push('密码必须包含数字');
  }

  if (!/[!@#$%^&*]/.test(password)) {
    errors.push('密码必须包含特殊字符');
  }

  return { valid: errors.length === 0, errors };
}

// src/__tests__/utils/password.test.ts
import { validatePassword } from '../../utils/password';

describe('validatePassword', () => {
  it('应该接受有效的密码', () => {
    const result = validatePassword('Password123!');
    expect(result.valid).toBe(true);
    expect(result.errors).toHaveLength(0);
  });

  it('应该拒绝少于 8 个字符的密码', () => {
    const result = validatePassword('Pass1!');
    expect(result.valid).toBe(false);
    expect(result.errors).toContain('密码长度必须至少为 8 个字符');
  });

  it('应该拒绝没有大写字母的密码', () => {
    const result = validatePassword('password123!');
    expect(result.valid).toBe(false);
    expect(result.errors).toContain('密码必须包含大写字母');
  });

  it('应该拒绝没有小写字母的密码', () => {
    const result = validatePassword('PASSWORD123!');
    expect(result.valid).toBe(false);
    expect(result.errors).toContain('密码必须包含小写字母');
  });

  it('应该拒绝没有数字的密码', () => {
    const result = validatePassword('Password!');
    expect(result.valid).toBe(false);
    expect(result.errors).toContain('密码必须包含数字');
  });

  it('应该拒绝没有特殊字符的密码', () => {
    const result = validatePassword('Password123');
    expect(result.valid).toBe(false);
    expect(result.errors).toContain('密码必须包含特殊字符');
  });

  it('对于无效密码应返回多个错误', () => {
    const result = validatePassword('pass');
    expect(result.valid).toBe(false);
    expect(result.errors.length).toBeGreaterThan(1);
  });
});

步骤 3:集成测试(API 端点)

为 API 端点编写集成测试。

任务

  • 测试 HTTP 请求/响应
  • 成功情况(200, 201)
  • 失败情况(400, 401, 404, 500)
  • 认证/授权测试
  • 输入验证测试

检查清单

  • 验证状态码
  • 验证响应体结构
  • 确认数据库状态更改
  • 验证错误消息

示例(Express.js + Supertest):

// src/__tests__/api/auth.test.ts
import request from 'supertest';
import app from '../../app';
import { db } from '../../database';

describe('POST /auth/register', () => {
  it('应成功注册新用户', async () => {
    const response = await request(app)
      .post('/api/auth/register')
      .send({
        email: 'test@example.com',
        username: 'testuser',
        password: 'Password123!'
      });

    expect(response.status).toBe(201);
    expect(response.body).toHaveProperty('user');
    expect(response.body).toHaveProperty('accessToken');
    expect(response.body.user.email).toBe('test@example.com');

    // 验证记录是否实际保存到数据库
    const user = await db.user.findUnique({ where: { email: 'test@example.com' } });
    expect(user).toBeTruthy();
    expect(user.username).toBe('testuser');
  });

  it('应拒绝重复的邮箱', async () => {
    // 创建第一个用户
    await request(app)
      .post('/api/auth/register')
      .send({
        email: 'test@example.com',
        username: 'user1',
        password: 'Password123!'
      });

    // 使用相同邮箱的第二次尝试
    const response = await request(app)
      .post('/api/auth/register')
      .send({
        email: 'test@example.com',
        username: 'user2',
        password: 'Password123!'
      });

    expect(response.status).toBe(409);
    expect(response.body.error).toContain('已存在');
  });

  it('应拒绝弱密码', async () => {
    const response = await request(app)
      .post('/api/auth/register')
      .send({
        email: 'test@example.com',
        username: 'testuser',
        password: 'weak'
      });

    expect(response.status).toBe(400);
    expect(response.body.error).toBeDefined();
  });

  it('应拒绝缺失字段', async () => {
    const response = await request(app)
      .post('/api/auth/register')
      .send({
        email: 'test@example.com'
        // username, password 被省略
      });

    expect(response.status).toBe(400);
  });
});

describe('POST /auth/login', () => {
  beforeEach(async () => {
    // 创建测试用户
    await request(app)
      .post('/api/auth/register')
      .send({
        email: 'test@example.com',
        username: 'testuser',
        password: 'Password123!'
      });
  });

  it('应使用有效凭据登录', async () => {
    const response = await request(app)
      .post('/api/auth/login')
      .send({
        email: 'test@example.com',
        password: 'Password123!'
      });

    expect(response.status).toBe(200);
    expect(response.body).toHaveProperty('accessToken');
    expect(response.body).toHaveProperty('refreshToken');
    expect(response.body.user.email).toBe('test@example.com');
  });

  it('应拒绝无效密码', async () => {
    const response = await request(app)
      .post('/api/auth/login')
      .send({
        email: 'test@example.com',
        password: 'WrongPassword123!'
      });

    expect(response.status).toBe(401);
    expect(response.body.error).toContain('凭据无效');
  });

  it('应拒绝不存在的用户', async () => {
    const response = await request(app)
      .post('/api/auth/login')
      .send({
        email: 'nonexistent@example.com',
        password: 'Password123!'
      });

    expect(response.status).toBe(401);
  });
});

步骤 4:认证/授权测试

测试 JWT 令牌和基于角色的访问控制。

任务

  • 确认无令牌访问时返回 401
  • 确认使用有效令牌时访问成功
  • 测试过期令牌处理
  • 基于角色的权限测试

示例

describe('受保护的路由', () => {
  let accessToken: string;
  let adminToken: string;

  beforeEach(async () => {
    // 普通用户令牌
    const userResponse = await request(app)
      .post('/api/auth/register')
      .send({
        email: 'user@example.com',
        username: 'user',
        password: 'Password123!'
      });
    accessToken = userResponse.body.accessToken;

    // 管理员令牌
    const adminResponse = await request(app)
      .post('/api/auth/register')
      .send({
        email: 'admin@example.com',
        username: 'admin',
        password: 'Password123!'
      });
    // 在数据库中将角色更新为 'admin'
    await db.user.update({
      where: { email: 'admin@example.com' },
      data: { role: 'admin' }
    });
    // 再次登录以获取新令牌
    const loginResponse = await request(app)
      .post('/api/auth/login')
      .send({
        email: 'admin@example.com',
        password: 'Password123!'
      });
    adminToken = loginResponse.body.accessToken;
  });

  describe('GET /api/auth/me', () => {
    it('应使用有效令牌返回当前用户', async () => {
      const response = await request(app)
        .get('/api/auth/me')
        .set('Authorization', `Bearer ${accessToken}`);

      expect(response.status).toBe(200);
      expect(response.body.user.email).toBe('user@example.com');
    });

    it('应拒绝无令牌的请求', async () => {
      const response = await request(app)
        .get('/api/auth/me');

      expect(response.status).toBe(401);
    });

    it('应拒绝无效令牌的请求', async () => {
      const response = await request(app)
        .get('/api/auth/me')
        .set('Authorization', 'Bearer invalid-token');

      expect(response.status).toBe(403);
    });
  });

  describe('DELETE /api/users/:id (仅管理员)', () => {
    it('应允许管理员删除用户', async () => {
      const targetUser = await db.user.findUnique({ where: { email: 'user@example.com' } });

      const response = await request(app)
        .delete(`/api/users/${targetUser.id}`)
        .set('Authorization', `Bearer ${adminToken}`);

      expect(response.status).toBe(200);
    });

    it('应禁止非管理员删除用户', async () => {
      const targetUser = await db.user.findUnique({ where: { email: 'user@example.com' } });

      const response = await request(app)
        .delete(`/api/users/${targetUser.id}`)
        .set('Authorization', `Bearer ${accessToken}`);

      expect(response.status).toBe(403);
    });
  });
});

步骤 5:模拟和测试隔离

模拟外部依赖以隔离测试。

任务

  • 模拟外部 API
  • 模拟邮件发送
  • 模拟文件系统
  • 模拟与时间相关的函数

示例(模拟外部 API):

// src/services/emailService.ts
export async function sendVerificationEmail(email: string, token: string): Promise<void> {
  const response = await fetch('https://api.sendgrid.com/v3/mail/send', {
    method: 'POST',
    headers: { 'Authorization': `Bearer ${process.env.SENDGRID_API_KEY}` },
    body: JSON.stringify({
      to: email,
      subject: '验证您的邮箱',
      html: `<a href="https://example.com/verify?token=${token}">验证</a>`
    })
  });

  if (!response.ok) {
    throw new Error('发送邮件失败');
  }
}

// src/__tests__/services/emailService.test.ts
import { sendVerificationEmail } from '../../services/emailService';

// 模拟 fetch
global.fetch = jest.fn();

describe('sendVerificationEmail', () => {
  beforeEach(() => {
    (fetch as jest.Mock).mockClear();
  });

  it('应成功发送邮件', async () => {
    (fetch as jest.Mock).mockResolvedValueOnce({
      ok: true,
      status: 200
    });

    await expect(sendVerificationEmail('test@example.com', 'token123'))
      .resolves
      .toBeUndefined();

    expect(fetch).toHaveBeenCalledWith(
      'https://api.sendgrid.com/v3/mail/send',
      expect.objectContaining({
        method: 'POST'
      })
    );
  });

  it('如果邮件发送失败应抛出错误', async () => {
    (fetch as jest.Mock).mockResolvedValueOnce({
      ok: false,
      status: 500
    });

    await expect(sendVerificationEmail('test@example.com', 'token123'))
      .rejects
      .toThrow('发送邮件失败');
  });
});

输出格式

定义输出必须遵循的确切格式。

基本结构

项目目录/
├── src/
│   ├── __tests__/
│   │   ├── setup.ts                 # 全局测试配置
│   │   ├── utils/
│   │   │   └── password.test.ts     # 单元测试
│   │   ├── services/
│   │   │   └── emailService.test.ts
│   │   └── api/
│   │       ├── auth.test.ts         # 集成测试
│   │       └── users.test.ts
│   └── ...
├── jest.config.js
└── package.json

测试运行脚本(package.json)

{
  "scripts": {
    "test": "jest",
    "test:watch": "jest --watch",
    "test:coverage": "jest --coverage",
    "test:ci": "jest --ci --coverage --maxWorkers=2"
  }
}

覆盖率报告

$ npm run test:coverage

--------------------------|---------|----------|---------|---------|
文件                        | 语句 %   | 分支 %    | 函数 %   | 行数 %   |
--------------------------|---------|----------|---------|---------|
所有文件                   |   92.5  |   88.3   |   95.2  |   92.8  |
 auth/                    |   95.0  |   90.0   |  100.0  |   95.0  |
  middleware.ts           |   95.0  |   90.0   |  100.0  |   95.0  |
  routes.ts               |   95.0  |   90.0   |  100.0  |   95.0  |
 utils/                   |   90.0  |   85.0   |   90.0  |   90.0  |
  password.ts             |   90.0  |   85.0   |   90.0  |   90.0  |
--------------------------|---------|----------|---------|---------|

约束条件

必须严格遵守的规则和禁令。

必需规则(必须遵守)

  1. 测试隔离:每个测试必须能独立运行
    • 使用 beforeEach/afterEach 重置状态
    • 不依赖测试执行顺序
  2. 清晰的测试名称:名称必须传达测试验证的内容
    • ✅ ‘应拒绝重复邮箱’
    • ❌ ‘test1’
  3. AAA 模式:准备-执行-断言结构
    • 提高可读性
    • 阐明测试意图

禁止事项(不得违反)

  1. 不使用生产数据库:测试必须使用单独的或内存数据库
    • 存在丢失真实数据的风险
    • 无法隔离测试
  2. 不进行真实的外部 API 调用:模拟所有外部服务
    • 消除网络依赖
    • 加快测试速度
    • 降低成本
  3. 不使用睡眠/超时滥用:对基于时间的测试使用虚拟定时器
    • jest.useFakeTimers()
    • 防止测试变慢

安全规则

  • 无硬编码密钥:切勿在测试代码中硬编码 API 密钥或密码
  • 单独的环境变量:使用 .env.test 文件

示例

示例 1:Python FastAPI 测试(Pytest)

场景:测试 FastAPI REST API

用户请求

使用 pytest 测试基于 FastAPI 构建的用户 API。

最终结果

# tests/conftest.py
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker

from app.main import app
from app.database import Base, get_db

# 用于测试的内存 SQLite
SQLALCHEMY_DATABASE_URL = "sqlite:///./test.db"
engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False})
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)

@pytest.fixture(scope="function")
def db_session():
    Base.metadata.create_all(bind=engine)
    db = TestingSessionLocal()
    try:
        yield db
    finally:
        db.close()
        Base.metadata.drop_all(bind=engine)

@pytest.fixture(scope="function")
def client(db_session):
    def override_get_db():
        try:
            yield db_session
        finally:
            db_session.close()

    app.dependency_overrides[get_db] = override_get_db
    yield TestClient(app)
    app.dependency_overrides.clear()

# tests/test_auth.py
def test_register_user_success(client):
    response = client.post("/auth/register", json={
        "email": "test@example.com",
        "username": "testuser",
        "password": "Password123!"
    })

    assert response.status_code == 201
    assert "access_token" in response.json()
    assert response.json()["user"]["email"] == "test@example.com"

def test_register_duplicate_email(client):
    # 第一个用户
    client.post("/auth/register", json={
        "email": "test@example.com",
        "username": "user1",
        "password": "Password123!"
    })

    # 重复邮箱
    response = client.post("/auth/register", json={
        "email": "test@example.com",
        "username": "user2",
        "password": "Password123!"
    })

    assert response.status_code == 409
    assert "已存在" in response.json()["detail"]

def test_login_success(client):
    # 注册
    client.post("/auth/register", json={
        "email": "test@example.com",
        "username": "testuser",
        "password": "Password123!"
    })

    # 登录
    response = client.post("/auth/login", json={
        "email": "test@example.com",
        "password": "Password123!"
    })

    assert response.status_code == 200
    assert "access_token" in response.json()

def test_protected_route_without_token(client):
    response = client.get("/auth/me")
    assert response.status_code == 401

def test_protected_route_with_token(client):
    # 注册并获取令牌
    register_response = client.post("/auth/register", json={
        "email": "test@example.com",
        "username": "testuser",
        "password": "Password123!"
    })
    token = register_response.json()["access_token"]

    # 访问受保护路由
    response = client.get("/auth/me", headers={
        "Authorization": f"Bearer {token}"
    })

    assert response.status_code == 200
    assert response.json()["email"] == "test@example.com"

最佳实践

质量提升

  1. 测试驱动开发:先写测试再写代码
    • 明确需求
    • 改进设计
    • 自然实现高覆盖率
  2. 给定-当-那么模式:以行为驱动开发风格编写测试
    it('当用户不存在时应返回 404', async () => {
      // 给定:一个不存在的用户 ID
      const nonExistentId = '不存在的-uuid';
    
      // 当:尝试查找该用户
      const response = await request(app).get(`/users/${nonExistentId}`);
    
      // 那么:返回 404 响应
      expect(response.status).toBe(404);
    });
    
  3. 测试夹具:可重用的测试数据
    const validUser = {
      email: 'test@example.com',
      username: 'testuser',
      password: 'Password123!'
    };
    

效率提升

  • 并行执行:使用 Jest 的 --maxWorkers 选项加速测试
  • 快照测试:保存 UI 组件或 JSON 响应的快照
  • 覆盖率阈值:在 jest.config.js 中强制执行最低覆盖率

常见问题

问题 1:由于测试间共享状态导致的测试失败

症状:单独运行通过,一起运行失败

原因:缺少 beforeEach/afterEach 导致数据库状态共享

修复

beforeEach(async () => {
  await db.migrate.rollback();
  await db.migrate.latest();
});

问题 2:“Jest did not exit one second after the test run”

症状:测试完成后进程未退出

原因:数据库连接、服务器等未清理

修复

afterAll(async () => {
  await db.destroy();
  await server.close();
});

问题 3:异步测试超时

症状:“Timeout – Async callback was not invoked”

原因:缺少 async/await 或未处理的 Promise

修复

// 错误
it('应该工作', () => {
  request(app).get('/users');  // Promise 未被处理
});

// 正确
it('应该工作', async () => {
  await request(app).get('/users');
});

参考资料

官方文档

学习资源

工具

元数据

版本

  • 当前版本:1.0.0
  • 最后更新:2025-01-01
  • 兼容平台:Claude, ChatGPT, Gemini

相关技能

标签

#测试 #后端 #Jest #Pytest #单元测试 #集成测试 #TDD #API测试

📄 原始文档

完整文档(英文):

https://skills.sh/supercent-io/skills-template/backend-testing

💡 提示:点击上方链接查看 skills.sh 原始英文文档,方便对照翻译。

声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理。