「漫说测试」· 基本功
这两年大家都在聊 prompt、聊 Agent、聊评测。 但真到了项目里,把你和"只会点点点"区分开的,往往还是那几行 Python。
【贴图1】
一、路径与文件:pathlib / shutil / tempfile
为什么测试需要:测试脚本一半的时间在处理文件——读用例、存报告、准备上传文件、清理临时目录。
pathlib:别再拼字符串了
from pathlib import Path
cases = Path("tests/cases")
for f in cases.rglob("*.json"): # 递归找
data = f.read_text(encoding="utf-8")
out = Path("reports") / "run.json" # 用 / 拼路径
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text("{}", encoding="utf-8")
坑:Path("a") / "b" 在 Windows 上也正确;但把路径传给外部命令时,记得 str(p)。
shutil:复制、删除、找可执行文件
import shutil
shutil.copytree("fixtures", "tmp/fixtures", dirs_exist_ok=True)
shutil.rmtree("tmp", ignore_errors=True)
print(shutil.disk_usage("/").free) # 磁盘够不够
print(shutil.which("chromedriver")) # 环境到底装没装
坑:rmtree 是真删。测试里删目录前,先断言路径在 tmp 下。
tempfile:让每个用例有自己的沙箱
import tempfile
from pathlib import Path
def test_upload():
with tempfile.TemporaryDirectory() as d:
p = Path(d) / "a.txt"
p.write_text("hello", encoding="utf-8")
assert p.read_text() == "hello"
# 退出 with 自动清理
坑:NamedTemporaryFile 在 Windows 上默认不能再次打开,测试文件建议用 TemporaryDirectory + 普通文件。
【贴图2】
二、数据格式:json / csv / yaml / configparser
为什么测试需要:用例、配置、报告、Mock 数据,全是格式转换。
json
import json
data = json.loads(resp.text) # 解析
print(json.dumps(data, ensure_ascii=False, indent=2)) # 中文不转义
坑:json.loads 遇到空字符串会抛 JSONDecodeError。接口返回 204 时先判空。
csv
import csv
with open("cases.csv", newline="", encoding="utf-8-sig") as f:
for row in csv.DictReader(f):
print(row["case_id"], row["expected"])
坑:不加 newline="",Windows 上会多出空行;utf-8-sig 是为了让 Excel 打开不乱码。
yaml
import yaml
with open("config.yaml", encoding="utf-8") as f:
cfg = yaml.safe_load(f) # 只用 safe_load
坑:yaml.load 可以执行任意对象构造,永远别用于不可信输入。
configparser
import configparser
cfg = configparser.ConfigParser()
cfg.read("pytest.ini", encoding="utf-8")
print(cfg.get("pytest", "addopts", fallback=""))
坑:默认会把 % 当插值符。密码里有 % 时,用 ConfigParser(interpolation=None)。
【贴图3】
三、接口与网络:requests / httpx / urllib
为什么测试需要:接口测试的主战场。
requests:够用,但别裸用
import requests
s = requests.Session() # 复用连接、共享 cookie
r = s.post(
f"{BASE}/api/login",
json={"username": "alice", "password": "Passw0rd!"},
timeout=(3, 10), # 连接 3s,读取 10s
)
r.raise_for_status()
token = r.json()["token"]
三个必设:
-
timeout:不设会挂死;
-
Session:不然每次重新握手;
-
raise_for_status():不然 500 也会被当成"成功返回"。
httpx:需要异步或 HTTP/2 时用它
import httpx
with httpx.Client(http2=True, timeout=10) as c:
r = c.get(f"{BASE}/api/orders")
异步场景:
async with httpx.AsyncClient() as c:
rs = await asyncio.gather(*[c.get(url) for url in urls])
urllib:不装第三方库时的兜底
from urllib.parse import urlencode, quote
q = urlencode({"q": "测试 用例", "page": 1}) # q=%E6%B5%8B%E8%AF%95...
坑:urlencode 默认用 quote_plus,空格变 +;签名场景要用 quote,别搞混。
【贴图4】
四、并发:concurrent.futures / threading / asyncio
为什么测试需要:并发下单、批量造数、多环境并行验证。
ThreadPoolExecutor:IO 密集首选
from concurrent.futures import ThreadPoolExecutor, as_completed
def check(env):
return env, requests.get(f"{env}/health", timeout=5).status_code
with ThreadPoolExecutor(max_workers=8) as pool:
futures = [pool.submit(check, e) for e in ENVS]
for f in as_completed(futures):
print(f.result())
坑:max_workers 不是越大越好;打自己的测试环境,8–16 足够,太大反而触发限流。
threading:需要共享状态时
import threading
lock = threading.Lock()
counter = 0
def inc():
global counter
with lock:
counter += 1
坑:Python 有 GIL,但 += 仍不是原子操作。别用"没加锁也没出错"来证明线程安全。
asyncio:高并发接口压测/巡检
import asyncio, httpx
async def one(c, url):
r = await c.get(url)
return r.status_code
async def main(urls):
async with httpx.AsyncClient(timeout=10) as c:
sem = asyncio.Semaphore(50) # 控制并发
async def guarded(u):
async with sem:
return await one(c, u)
return await asyncio.gather(*[guarded(u) for u in urls])
坑:asyncio.gather 默认一个异常会向上抛,其余任务继续跑但不返回结果。需要 return_exceptions=True。
【贴图5】
五、测试框架与替身:pytest / mock / faker / hypothesis
为什么测试需要:这是"测试工程师"和"会写 Python 的人"的分水岭。
pytest:fixture 是核心
import pytest
@pytest.fixture(scope="session")
def api():
s = requests.Session()
s.headers["Authorization"] = f"Bearer {login()}"
yield s
s.close()
@pytest.mark.parametrize("status,ok", [(200, True), (401, False)])
def test_auth(api, status, ok):
r = api.get("/api/me")
assert (r.status_code == 200) is ok
坑:fixture 的 scope 只能"小依赖大",反过来报 ScopeMismatch。
unittest.mock:把外部依赖换掉
from unittest.mock import patch, MagicMock
@patch("app.pay.client")
def test_pay_fail(mock_client):
mock_client.charge.side_effect = TimeoutError
with pytest.raises(TimeoutError):
pay(100)
mock_client.charge.assert_called_once_with(amount=100)
坑:patch 的路径要写"被使用的地方",不是"定义的地方"。
faker:造人话数据
from faker import Faker
fake = Faker("zh_CN")
fake.name(), fake.phone_number(), fake.company()
fake.unique.email() # 保证唯一
坑:unique 在大量造数时会耗尽,记得捕获 UniquenessException。
hypothesis:让机器帮你找边界
from hypothesis import given, strategies as st
@given(st.integers(min_value=0, max_value=10**6))
def test_amount(amount):
assert calc(amount) >= 0
坑:它会保存失败样本到 .hypothesis/,别把目录提交进仓库(加进 .gitignore)。
【贴图6】
六、数据库:sqlite3 / pymysql / redis
为什么测试需要:校验落库、准备数据、清理现场。
sqlite3:本地测试最省事
import sqlite3
conn = sqlite3.connect(":memory:")
conn.row_factory = sqlite3.Row
cur = conn.execute("select * from orders where id = ?", (1,))
row = cur.fetchone()
print(row["status"])
坑:必须用 ? 占位符。字符串拼接就是 SQL 注入,测试代码也一样。
pymysql:MySQL 校验
import pymysql
conn = pymysql.connect(host="127.0.0.1", user="qa", password="***",
database="shop", cursorclass=pymysql.cursors.DictCursor)
with conn.cursor() as cur:
cur.execute("select status from orders where id=%s", (1,))
print(cur.fetchone())
坑:pymysql 默认不开自动提交,写操作后要 conn.commit(),否则"查不到刚写的数据"。
redis:缓存校验与清理
import redis
r = redis.Redis(decode_responses=True)
r.set("k", "v", ex=60)
print(r.ttl("k"))
坑:测试环境务必用独立 db(db=1),别把开发缓存清了。
【贴图7】
七、系统与进程:subprocess / psutil / signal
为什么测试需要:起服务、杀进程、查内存、做超时。
subprocess:起服务与执行命令
import subprocess
p = subprocess.run(
["python", "app.py"],
capture_output=True, text=True, timeout=30, check=False,
)
print(p.returncode, p.stdout, p.stderr)
坑:shell=True 有注入风险;参数用列表传,别拼字符串。
psutil:进程与资源
import psutil
proc = psutil.Process(pid)
print(proc.cpu_percent(interval=1), proc.memory_info().rss / 1024 / 1024, "MB")
print(len(proc.net_connections())) # 连接泄漏排查
坑:cpu_percent() 第一次调用返回 0,需要传 interval 或调两次。
signal:给用例加超时(仅 Unix)
import signal
def handler(signum, frame):
raise TimeoutError("case timeout")
signal.signal(signal.SIGALRM, handler)
signal.alarm(10)
try:
run_case()
finally:
signal.alarm(0)
坑:Windows 没有 SIGALRM。跨平台用 func_timeout 或框架自带超时。
【贴图8】
八、日志与排错:logging / traceback / pdb / faulthandler
为什么测试需要:失败时能不能自证,决定了别人信不信你的测试。
logging:别用 print
import logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s %(message)s",
)
log = logging.getLogger("api")
log.info("request", extra={"url": url, "status": r.status_code})
坑:basicConfig 只在第一次调用生效;子进程/插件里重复配置会"没反应"。
traceback:把异常变成可读文本
import traceback
try:
run()
except Exception:
log.error("case failed:\n%s", traceback.format_exc())
坑:format_exc() 只能取"当前"异常;存日志时别只存 str(e),会丢掉堆栈。
pdb:本地调试
breakpoint() # 3.7+,等价于 pdb.set_trace()
常用:n 下一行、s 进入函数、p x 打印、l 看上下文、c 继续、q 退出。
坑:CI 里别留 breakpoint(),会直接卡住。
faulthandler:卡死时打出堆栈
import faulthandler
faulthandler.enable()
faulthandler.dump_traceback_later(60, exit=True) # 60s 后打印并退出
坑:它是"兜底诊断",不是精确超时控制,CI 作业级超时仍要保留。
【贴图9】
九、时间与编码:datetime / hashlib / base64 / secrets
为什么测试需要:时间断言、签名、加密参数,是接口测试的高频坑。
datetime + zoneinfo:别再用本地时间
from datetime import datetime, timedelta, timezone
from zoneinfo import ZoneInfo
now = datetime.now(timezone.utc)
sh = now.astimezone(ZoneInfo("Asia/Shanghai"))
print((sh + timedelta(days=1)).isoformat())
坑:datetime.now() 不带时区,跨时区比较必错。统一用 UTC 存储,展示再转。
hashlib + hmac:签名校验
import hashlib, hmac
sig = hmac.new(b"secret", body, hashlib.sha256).hexdigest()
print(hmac.compare_digest(sig, expected)) # 防时序攻击
坑:比较签名用 compare_digest,不要用 ==。
base64:传输编码,不是加密
import base64
b64 = base64.b64encode(b"hello").decode()
raw = base64.b64decode(b64)
坑:URL 安全场景用 urlsafe_b64encode,否则 +``/ 会被转义。
secrets:生成测试凭证
import secrets
token = secrets.token_hex(16)
坑:别用 random 生成 token——它可预测,测试数据也别用。
【贴图10】
十、性能与质量:timeit / cProfile / coverage
为什么测试需要:你得能回答"慢在哪"和"测了多少"。
timeit:比较两种写法
import timeit
t1 = timeit.timeit(lambda: json.dumps(data), number=1000)
t2 = timeit.timeit(lambda: orjson.dumps(data), number=1000)
坑:单次跑噪声大,用 repeat 取最小值。
cProfile:找热点函数
import cProfile, pstats
cProfile.runctx("run_suite()", globals(), locals(), "prof.out")
pstats.Stats("prof.out").sort_stats("cumtime").print_stats(20)
坑:cProfile 会放大函数调用开销,适合找"谁占了大头",不适合精确计时。
coverage:覆盖率
coverage run -m pytest
coverage report -m
coverage xml -o coverage.xml
坑:覆盖率只看"执行过",不看"断言有效"。100% 覆盖也可能一个 bug 都没抓到。
十一、如果只学五组,学这五组
时间有限的话,按这个顺序:
-
pathlib + json
:所有脚本的地基;
-
requests.Session + timeout
:接口测试的底线;
-
pytest fixture + parametrize
:从"写脚本"到"搭框架";
-
unittest.mock
:能把外部依赖切开;
-
logging + traceback
:失败时能自证。
这五组吃透,剩下的都是查文档的事。
【贴图11】
十二、面试大礼包
光看不够,面试是要说出来的。
我把这 10 组模块整理成了一份**《测试工程师 Python 必会模块 + 面试 50 问》**:
-
模块速查卡
:每组 3–5 个核心 API + 一个测试场景;
-
50 道面试题
:按模块分组,每题带参考答案和追问;
-
20 个高频坑
:都是"看起来会、一写就错"的那种;
-
一份学习顺序表
:四周怎么排。
**公众号后台回复「pypack」,领取《测试工程师 Python 必会模块 + 面试 50 问》。**👉
【贴图12】
最后
AI 能帮你写代码,但它不知道你的环境、你的约束、你的历史包袱。
这些东西,最后还是落在你对语言和系统的理解上。
工具决定你跑多快,基本功决定你能跑多远。