速查向。条目按"症状 → 复现 → 根因 → 修复 → 进阶"组织。 AI 协作时,把对应条目贴给 AI 比口头描述快 10 倍。
目录
- Python (pytest)
- TypeScript (Vitest / Jest)
- Node.js (集成测试 / E2E)
- Rust (cargo test)
- 通用工程性问题
- AI prompt 速查模板
Python (pytest)
1. ModuleNotFoundError 在测试中找不到源码
症状
ModuleNotFoundError: No module named 'mypkg'
根因:pytest 默认把项目根加进 sys.path,但 src layout 下源码在 src/ 子目录,根目录看不到 mypkg。
复现
proj/
src/mypkg/__init__.py
tests/test_x.py # from mypkg import foo
修复(任一)
# pyproject.toml — 推荐,显式
[tool.pytest.ini_options]
pythonpath = ["src"]
testpaths = ["tests"]
# 老项目 setup.cfg
[tool:pytest]
pythonpath = src
或:把项目本身装上,pip install -e .,通过 entry_points 注册。这是最干净的方式,生产、CI、IDE 行为完全一致。
进阶:pytest --collect-only 看 pytest 实际发现了什么;pytest --rootdir 排查它认的根。
2. conftest.py 行为陷阱
conftest.py 是隐式 fixture / hook 注入,但层级很重要:
- 项目根
conftest.py→ 所有测试可见 tests/conftest.py→ 仅 tests 树可见tests/integration/conftest.py→ 仅该子树可见
常见坑:把 conftest.py 放进 tests/__init__.py 同目录 → pytest 警告 rootdir 推断异常。测试目录不要加 __init__.py,除非你明知后果(改变包发现规则)。
3. 异步测试静默跳过
async def test_fetch():
assert await fetch() == 200
根因:没装 pytest-asyncio,这个 coroutine 被 pytest 当成普通函数,返回 coroutine 对象就当 PASS。
修复
pip install pytest-asyncio
[tool.pytest.ini_options]
asyncio_mode = "auto" # 不用每个 test 加 marker
# 老版本用 asyncio_mode = "strict" + @pytest.mark.asyncio
自检:加 assert False,如果显示 PASS,说明 asyncio 没装好。
4. fixture scope 导致测试间污染
症状:单跑通,合跑挂;顺序换一下又通了。
@pytest.fixture(scope="session")
def db():
return {} # ⚠️ 共享 dict,测试间互相污染
@pytest.fixture # function scope,每个测试新建
def db():
return {}
根因:session/module/class/function 四级,越高越快但越易污染。
修复:能用 function scope 就用,真有性能问题再升级。session scope 的 fixture 必须只读 或 每次 yield 前 reset:
@pytest.fixture(scope="session")
def db():
conn = make_conn()
yield conn
conn.close()
@pytest.fixture
def clean_db(db):
db.execute("TRUNCATE ...") # 每个测试前清表
yield db
5. mock 路径(patch 使用方,不是定义方)
# app/service.py
from app.db import query
def get_user(): return query(...)
# tests/test_service.py
mocker.patch("app.db.query") # ❌ 改了定义处,service 里的引用没动
mocker.patch("app.service.query") # ✅ patch 使用方
原理:from X import Y 在 service 模块的命名空间里创建了 Y 的本地名字,patch X.Y 不影响 service.Y。
避坑:改成 import app.db; app.db.query(...) 调用,这样 patch app.db.query 就生效。一般库代码这样写,测试更友好。
6. mocker.patch.object vs mocker.patch
mocker.patch("app.service.query") # 字符串路径
mocker.patch.object(app.service, "query") # 直接传对象
patch.object 重构友好——重命名时 IDE 能找到引用,字符串 patch 不能。新代码用 patch.object。
7. parametrize + ids 让失败可读
@pytest.mark.parametrize(
"input,expected",
[(1, 1), (2, 4), (3, 9)],
ids=["one", "two", "three"],
)
def test_square(input, expected):
assert input**2 == expected
不加 ids 时,失败显示 test_square[1-1],加了显示 test_square[one]。CI 输出可读性差异巨大。
进阶:pytest.param(..., marks=pytest.mark.xfail) 给单个 case 加 marker。
8. tmp_path vs tmp_path_factory vs tmpdir
tmpdir:旧 API,返回py.path.local,别用。tmp_path:function scope,返回pathlib.Path,默认用这个。tmp_path_factory:session scope,跨测试共享,适合"准备一次贵的数据"。
def test_write(tmp_path):
f = tmp_path / "a.txt"
f.write_text("hi")
assert f.read_text() == "hi"
pytest 自动清理(保留最近 3 次,在 /tmp/pytest-of-<user>/)。
9. capsys / capfd / caplog 三选一
capsys:抓 Python 层print()和sys.stdout/stderr。capfd:抓 fd 层(C 扩展、子进程 stdout)。caplog:抓 logging 模块输出。
def test_logs(caplog):
with caplog.at_level("WARNING"):
do_thing()
assert "deprecated" in caplog.text
caplog.records 拿 LogRecord 列表,能断言 level、logger name、exc_info。
10. freezegun / time-machine 冻结时间
from freezegun import freeze_time
@freeze_time("2025-01-01")
def test_new_year():
assert datetime.now().year == 2025
time-machine 性能更好(C 扩展),用法几乎相同。所有时间相关测试都该冻结,不然 UTC 跨日、闰年都会偶发挂。
11. monkeypatch 改环境变量
def test_with_env(monkeypatch):
monkeypatch.setenv("API_KEY", "test")
monkeypatch.delenv("PROD_URL", raising=False)
# 测试结束自动恢复
不要直接 os.environ["X"] = ...,会污染后续测试。
12. pytest-xdist 并行测试的隐式假设破坏
pytest -n auto # 多进程并行
会挂的情况:
- 共享文件路径(多 worker 写同一文件)
- 共享数据库(没按 worker 分 schema)
- 端口固定
- 全局 mutable 单例
修复:用 worker_id fixture(pytest-xdist 提供)给每个 worker 独立资源:
@pytest.fixture
def db(worker_id):
return create_db(f"test_{worker_id}") # gw0, gw1, ...
13. pytest --lf / --ff 加速调试
--lf(last-failed):只跑上次失败的--ff(failed-first):失败的先跑,然后跑其他
调试 flaky 时:pytest --lf -x -vv 一键失败重跑+早停+详细输出。
14. hypothesis 属性测试(覆盖手写 case 想不到的)
from hypothesis import given, strategies as st
@given(st.lists(st.integers()))
def test_sort_idempotent(lst):
assert sorted(sorted(lst)) == sorted(lst)
发现反例后自动 shrink 到最小复现,存到 .hypothesis/examples/,下次 run 优先跑。核心算法、解析器、状态机必备。
15. 集成测试用真实依赖:testcontainers
from testcontainers.postgres import PostgresContainer
@pytest.fixture(scope="session")
def pg():
with PostgresContainer("postgres:16") as c:
yield c.get_connection_url()
比 mock SQLAlchemy 真实得多。任何"DB 行为差异导致 bug"的项目都该用。代价:CI 需要 Docker。
16. coverage 覆盖率配置
# pyproject.toml
[tool.coverage.run]
branch = true # 分支覆盖,不只是行覆盖
source = ["src"]
omit = ["*/tests/*", "*/__main__.py"]
[tool.coverage.report]
exclude_lines = [
"pragma: no cover",
"raise NotImplementedError",
"if TYPE_CHECKING:",
"if __name__ == .__main__.:",
]
fail_under = 80
pytest --cov=src --cov-report=term-missing --cov-report=html
term-missing 直接显示哪些行没覆盖,比看 HTML 快。
17. 测试用 dataclass 工厂,不要散列字典
# ❌ 散落各处,字段变了到处改
def test_x():
user = {"id": 1, "name": "x", "email": "x@x"}
# ✅ 工厂 + dataclass
@dataclass
class User: id: int; name: str; email: str
def make_user(**overrides) -> User:
return User(id=1, name="x", email="x@x", **overrides)
字段加了,工厂签名变,所有测试编译期(或 mypy 期)爆——这是好事。
TypeScript (Vitest / Jest)
1. ESM / CJS 之争:Cannot use import statement outside a module
根因:Node 按 package.json 的 "type" 决定模块系统,Jest 默认走 CJS,TS ESM 输出和它对不齐。
推荐:新项目用 Vitest,原生 ESM,几乎零配置。
留 Jest 的话:
// jest.config.mjs
export default {
preset: "ts-jest/presets/default-esm",
extensionsToTreatAsEsm: [".ts"],
moduleNameMapper: { "^(\\.{1,2}/.*)\\.js$": "$1" }, // ts-jest 的坑
};
自检:报错里出现 SyntaxError: Cannot use import 通常是 transform 没走;Cannot find module './x.js' 通常是 ESM 必须带 .js 后缀,但 TS 源是 .ts,需要 moduleNameMapper。
2. fake timers 和真异步混用导致测试卡死
vi.useFakeTimers();
const p = fetch("/api"); // 真网络
vi.advanceTimersByTime(1000); // 没用,fetch 不靠定时器
await p; // 真等
根因:fake timers 只接管 setTimeout/setInterval/Date,不接管 IO。如果测试里有真 IO,要么 mock 掉 IO,要么别 fake timer。
修复:Vitest 0.34+ 可以选择性接管:
vi.useFakeTimers({ shouldAdvanceTime: true, toFake: ["setTimeout"] });
3. await 异步断言(否则失败被吞)
expect(getUser()).resolves.toEqual({ id: 1 }); // ❌ 没 await,Promise reject 不会让测试挂
await expect(getUser()).resolves.toEqual({ id: 1 }); // ✅
强制:ESLint jest/valid-expect-in-promise + jest/no-conditional-expect,Vitest 用 eslint-plugin-vitest。
4. testing-library 三套查询语义
| API | 找不到时 | 用途 |
|---|---|---|
getBy* | throw | "我确定存在" |
queryBy* | 返回 null | "我断言不存在" |
findBy* | Promise reject(默认 1s 超时) | "异步出现" |
*AllBy* | 返回数组 | 多个匹配 |
expect(screen.queryByText("Error")).toBeNull(); // ✅
expect(screen.getByText("Error")).toBeNull(); // ❌ getBy 先 throw 了
await screen.findByText("Loaded"); // ✅ 等异步渲染
优先级:ByRole > ByLabelText > ByText > ByTestId。ByTestId 是最后逃生舱,用它说明组件可访问性差。
5. userEvent vs fireEvent
fireEvent.click(btn)→ 单一 DOM 事件,不模拟真实交互链。userEvent.click(btn)→ 模拟 hover/focus/click 全链 + 同步 React state。
默认用 userEvent,只有需要直接模拟某个底层事件(如 wheel)才 fireEvent。
import userEvent from "@testing-library/user-event";
const user = userEvent.setup();
await user.click(button);
await user.type(input, "hello");
6. any 滑进测试 → 类型护栏失效
const user = {} as User; // ⚠️ 缺字段运行时炸
const user = { id: 1 } as User; // ⚠️ 同上
修复:Partial 工厂
const makeUser = (over: Partial<User> = {}): User => ({
id: 1, name: "x", email: "x@x.com", createdAt: new Date(),
...over,
});
User 加字段 → 工厂签名编译错 → 所有测试同步暴露。
7. 类型测试:expectTypeOf / tsd
import { expectTypeOf } from "vitest";
expectTypeOf(parse("1")).toEqualTypeOf<number>();
expectTypeOf<MyFn>().parameter(0).toBeString();
expectTypeOf<User>().toHaveProperty("id").toBeNumber();
或独立工具 tsd:
// types.test-d.ts
import { expectType } from "tsd";
expectType<number>(parse("1"));
只测类型不测运行时,库代码必备——你的类型签名是公共 API。
8. snapshot 失控
expect(component).toMatchSnapshot(); // ⚠️ 第一次随便过,之后改一处全炸
经验:
- snapshot 小 —— 大对象 snapshot 没人审,改了直接
-u。 - 用 inline snapshot(
toMatchInlineSnapshot()),变化直接在 test 文件里看 diff。 - 不要 snapshot DOM 大树,改用具体断言。
9. mock 模块的两种方式
// 方式 1:vi.mock(声明提升,模块级,所有用例共享)
vi.mock("./db", () => ({ query: vi.fn() }));
// 方式 2:vi.doMock(不提升,要在 import 之前)
vi.doMock("./db", () => ({ query: vi.fn() }));
const { fetchUser } = await import("./service");
坑:vi.mock 会被提升到文件顶部,引用外部变量会 Cannot access X before initialization。需要外部变量时用 vi.hoisted:
const mocks = vi.hoisted(() => ({ query: vi.fn() }));
vi.mock("./db", () => ({ query: mocks.query }));
10. mock React 子组件以隔离 unit
vi.mock("./HeavyChart", () => ({
default: ({ data }: { data: number[] }) => <div data-testid="chart">{data.length}</div>,
}));
单测父组件时,子组件用替身,避免拉起整个图表库。E2E 用真的,unit 用假的。
11. coverage 配置
// vitest.config.ts
export default defineConfig({
test: {
coverage: {
provider: "v8", // 比 istanbul 快很多,但分支不如 istanbul 精确
reporter: ["text", "html", "lcov"],
include: ["src/**"],
exclude: ["**/*.test.*", "**/*.d.ts"],
thresholds: { lines: 80, branches: 75, functions: 80, statements: 80 },
},
},
});
v8 不需要 transform,速度优势明显;istanbul 分支覆盖更准,差别在 ternary、?.、??。
12. React Server Component / Next.js App Router 测试
Vitest 默认跑不了 RSC(用了 server-only API)。两种思路:
- 单测:把组件拆成 "server 部分(纯函数)+ client 组件",分别测。
- 集成测试:用 Playwright 跑真实的 dev server。
不要硬上 jsdom 测 RSC,死路一条。
13. 浏览器测试:Playwright > Cypress(现在)
- Playwright:多浏览器、并行好、auto-wait、原生 TS、
page.locator()语义清晰、trace viewer 神器。 - Cypress:DX 好但单 tab、强制 iframe、并行要付费。
import { test, expect } from "@playwright/test";
test("login", async ({ page }) => {
await page.goto("/login");
await page.getByLabel("Email").fill("a@b.com");
await page.getByRole("button", { name: "Sign in" }).click();
await expect(page).toHaveURL("/dashboard");
});
核心规则:用 getBy* 语义查询,不要 CSS 选择器(.btn-primary 改个 class 全挂)。
14. vitest --ui / Playwright trace viewer 调试
vitest --ui浏览器里看测试树、重跑、diff snapshot。playwright show-trace trace.zip时间线 + DOM 快照 + 网络 + console,失败 case 直接定位。
CI 出错时,永远把 trace 上传成 artifact:
- if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-trace
path: test-results/
15. msw mock 网络层
import { setupServer } from "msw/node";
import { http, HttpResponse } from "msw";
const server = setupServer(
http.get("/api/user", () => HttpResponse.json({ id: 1, name: "x" })),
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
mock 网络层而不是 mock fetch 本身——这样无论 axios/fetch/whatever 都覆盖,且测试和真实代码用同一份契约。
Node.js (集成测试 / E2E)
1. 测试结束进程不退出
通常是 timer、DB 连接、HTTP server、子进程没关。
jest --detectOpenHandles # 找出谁阻止退出
vitest run --reporter=verbose
清理模板:
let server: http.Server;
beforeAll(async () => { server = app.listen(0); });
afterAll(async () => {
await new Promise<void>((r, rej) => server.close(e => e ? rej(e) : r()));
await db.end();
await redis.quit();
});
每行都要 await,只 server.close() 不 await 就是泄漏。
2. 端口冲突 / 用 port 0
并行测试用同一端口必挂。
const srv = app.listen(0); // 0 = OS 分配空闲端口
const { port } = srv.address() as AddressInfo;
const url = `http://127.0.0.1:${port}`;
127.0.0.1 比 localhost 快(localhost 在某些环境会先 IPv6 失败再回退 IPv4)。
3. supertest 不需要监听端口
import request from "supertest";
const res = await request(app).get("/users").expect(200); // app 是 Express 实例,supertest 内部分配端口
expect(res.body).toEqual({ users: [] });
省掉手动 listen/close。
4. DB 隔离:transaction rollback 模式
beforeEach(async () => { await db.query("BEGIN"); });
afterEach (async () => { await db.query("ROLLBACK"); });
每个测试在事务里跑,结束回滚。前提:连接池共享同一连接(否则事务不在同一会话):
beforeEach(async () => {
client = await pool.connect(); // 整个测试用同一 client
await client.query("BEGIN");
});
afterEach(async () => {
await client.query("ROLLBACK");
client.release();
});
不能用嵌套事务的功能(savepoint 可以模拟,但很少需要)。
5. DB 隔离:per-worker schema
const schema = `test_${process.env.VITEST_WORKER_ID ?? 0}`;
await db.query(`CREATE SCHEMA IF NOT EXISTS ${schema}`);
await db.query(`SET search_path TO ${schema}`);
适合需要 DDL(CREATE TABLE 等)的测试。
6. 环境变量隔离
// ❌ 全局污染
process.env.NODE_ENV = "test";
// ✅ Vitest
beforeEach(() => vi.stubEnv("NODE_ENV", "test"));
afterEach (() => vi.unstubAllEnvs());
// 或 dotenv 加载特定文件
import "dotenv/config"; // ⚠️ 不要在测试代码里,在 setup 文件
.env.test 不要 commit,而是 commit .env.test.example 作模板。
7. Stream / pipeline 测试
import { pipeline } from "node:stream/promises";
await pipeline(readable, transform, writable); // ✅ 一次 await 全等完
不要手动监听 end/finish/error,pipeline 会自动处理错误传播和资源释放。
8. 子进程 / CLI 测试
import { execa } from "execa";
test("cli works", async () => {
const { stdout, exitCode } = await execa("node", ["./dist/cli.js", "--help"]);
expect(exitCode).toBe(0);
expect(stdout).toContain("Usage");
});
execa 比 child_process 易用:返回 Promise、自动 reject 非零退出、跨平台 PATH 处理。CLI 工具测试必备。
9. WebSocket / SSE 测试
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
await new Promise<void>(r => ws.once("open", () => r()));
ws.send(JSON.stringify({ type: "ping" }));
const msg = await new Promise<string>(r => ws.once("message", d => r(d.toString())));
expect(JSON.parse(msg).type).toBe("pong");
ws.close();
加超时,不然挂连接会让测试无限等:
test("ws", { timeout: 5000 }, async () => { ... });
10. Worker / 多线程代码测试
import { Worker } from "node:worker_threads";
test("worker computes", async () => {
const w = new Worker(new URL("./worker.js", import.meta.url));
const result = await new Promise(r => w.once("message", r));
await w.terminate();
expect(result).toBe(42);
});
每个测试必须 await w.terminate(),否则 worker 阻止进程退出。
11. 重试 flaky 测试不是解药,但偶尔救急
test("flaky e2e", { retry: 2 }, async () => { ... });
只用于 CI 偶发问题已经查不到根因 时止血。本地不要开 retry(掩盖问题)。 重试次数 > 0 的测试应该有 issue 跟踪,定期清。
12. CI 上 headless 浏览器报字体 / 图片缺失
# GitHub Actions
- uses: actions/setup-node@v4
- run: npx playwright install --with-deps # 装浏览器 + 系统依赖
--with-deps 装 Chromium 需要的 .so(libnss3、libatk 等),不然 Ubuntu 22 容器里 Chrome 起不来。
13. snapshot 测试时间 / 随机
beforeAll(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2025-01-01T00:00:00Z"));
});
任何带时间戳、UUID、随机数的 snapshot 必须先 freeze,否则每次跑都更新。
14. 测 Express/Fastify 错误处理路径
app.get("/boom", () => { throw new Error("nope"); });
const res = await request(app).get("/boom");
expect(res.status).toBe(500);
测正常路径容易,测错误路径才是真正的覆盖。每个 try/catch、每个 next(err)、每个错误中间件都要有专门测试。
15. monorepo 里测试只跑改动的包
# turbo
turbo test --filter=...[origin/main]
# nx
nx affected -t test --base=origin/main
# pnpm 配合 changed
pnpm -r --filter=...[origin/main] test
主分支合并后跑全部,PR 上只跑受影响的——大型 monorepo CI 时间的关键优化。
Rust (cargo test)
1. 单元测试 vs 集成测试位置
src/lib.rs # 内部单元测试:#[cfg(test)] mod tests
src/foo.rs + #[cfg(test)] mod # 可以测 private fn
tests/integration.rs # 独立 crate,只用 pub API
benches/bench.rs # criterion 等,nightly 或 stable+criterion
examples/usage.rs # cargo test 也会编译 example,作为冒烟测
tests/ 下每个 .rs 是独立 crate 不能 use crate::internal_fn。要复用辅助代码,放 tests/common/mod.rs(注意是 mod.rs 不是 common.rs,后者会被当独立测试 crate)。
2. cargo test 默认捕获 stdout
cargo test -- --nocapture # 显示 println / dbg!
cargo test -- --test-threads=1 # 串行,排查并发问题
cargo test foo # 跑名字含 foo 的测试
cargo test --test integration # 只跑 tests/integration.rs
cargo test --doc # 只跑 doc tests
cargo test -- --exact test_x # 精确匹配
-- 之前是 cargo 参数,之后是 test binary 参数。这是新手最大坑。
3. #[should_panic] 必须带 expected
#[should_panic] // ❌ 任何 panic 都过,包括语法错改的 panic
#[should_panic(expected = "divide by zero")] // ✅ 只匹配预期消息
fn test_div() { let _ = 1 / 0; }
更好:用 Result<(), E> 返回错误,别用 panic 表达预期错误。
#[test]
fn test_parse() -> Result<(), ParseError> {
let v = parse("42")?;
assert_eq!(v, 42);
Ok(())
}
4. async 测试需要 runtime
#[tokio::test] // ✅ 单线程 runtime
#[tokio::test(flavor = "multi_thread")] // ✅ 多线程,测竞争
async fn test_x() { ... }
禁用 main thread fixture:tokio::test 每个 test 起独立 runtime,fixture 跨测试共享不直接可行。要共享用 once_cell::sync::Lazy + tokio::sync::Mutex。
5. doc test 同时是文档和测试
/// 把两数相加。
///
/// # Examples
///
/// ```
/// assert_eq!(my_crate::add(1, 2), 3);
/// ```
///
/// 错误示例(不会被运行):
///
/// ```ignore
/// my_crate::add("a", "b");
/// ```
///
/// 编译期测试,不运行:
///
/// ```compile_fail
/// let x: i32 = "no";
/// ```
pub fn add(a: i32, b: i32) -> i32 { a + b }
API 改了文档对不上 → doc test 编译失败 → 强制同步。库 crate 必须开。
6. #[cfg(test)] vs #[test]
#[test]:这是个测试函数。#[cfg(test)]:整段(模块、struct、fn)只在测试编译时存在。
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn it_works() { ... }
}
cfg(test) 包整个 mod 而不是单测函数,这样辅助 fn 在生产构建中完全消失。
7. 测私有函数:#[cfg(test)] 内的 mod 能访问父模块私有
fn internal_helper(x: i32) -> i32 { x + 1 } // private
#[cfg(test)]
mod tests {
use super::*; // 引入 private
#[test]
fn h() { assert_eq!(internal_helper(1), 2); }
}
如果在 tests/ 下,只能访问 pub,这是设计选择:集成测试代表外部用户视角。
8. assert_eq! 失败信息加上下文
assert_eq!(got, want); // ❌ 失败只显示值,不知道哪个 case
assert_eq!(got, want, "case {:?} failed", input); // ✅
pretty_assertions crate 让大 struct 的 diff 可读:
use pretty_assertions::assert_eq; // 颜色 diff,见过就回不去了
9. 属性测试:proptest / quickcheck
use proptest::prelude::*;
proptest! {
#[test]
fn sort_is_idempotent(v in any::<Vec<i32>>()) {
let mut a = v.clone();
a.sort();
let b = a.clone();
a.sort();
prop_assert_eq!(a, b);
}
}
发现反例后自动 shrink,存到 proptest-regressions/,提交进 git 保证回归。算法、parser、状态机必备。
10. fuzz:cargo-fuzz(libFuzzer)/ afl
cargo install cargo-fuzz
cargo fuzz init
cargo fuzz add my_target
cargo fuzz run my_target
// fuzz/fuzz_targets/my_target.rs
#![no_main]
use libfuzzer_sys::fuzz_target;
fuzz_target!(|data: &[u8]| {
let _ = my_crate::parse(data);
});
数据从 stdin 喂,跑出 panic 就保存样本。对外暴露解析函数的 crate 必须做。
11. mock / fake:trait + 注入
Rust 没有 Python 那种 monkey-patch。测试时把依赖抽成 trait:
pub trait Db { fn get(&self, id: u64) -> Option<User>; }
pub struct Service<D: Db> { db: D }
#[cfg(test)]
struct FakeDb;
impl Db for FakeDb { fn get(&self, _: u64) -> Option<User> { Some(User { ... }) } }
或用 mockall:
#[mockall::automock]
pub trait Db { fn get(&self, id: u64) -> Option<User>; }
#[test]
fn t() {
let mut m = MockDb::new();
m.expect_get().with(eq(1)).returning(|_| Some(User { ... }));
}
12. golden file / snapshot:insta
#[test]
fn render() {
insta::assert_snapshot!(render_template(&data));
}
第一次创建 *.snap 文件,人工 review,提交进 git。变化时:cargo insta review 交互式接受/拒绝。
比 Jest snapshot 更严格(单独文件,改动一目了然),大量字符串/JSON 输出的测试必备。
13. benchmark:criterion(stable)
// benches/my_bench.rs
use criterion::{black_box, criterion_group, criterion_main, Criterion};
fn bench(c: &mut Criterion) {
c.bench_function("fib 20", |b| b.iter(|| fib(black_box(20))));
}
criterion_group!(benches, bench);
criterion_main!(benches);
# Cargo.toml
[[bench]]
name = "my_bench"
harness = false
cargo bench 跑,生成 HTML 报告 + 与上次对比。重构性能敏感代码前后必跑。
14. cargo nextest(测试运行器替代品)
cargo install cargo-nextest
cargo nextest run
并行更激进、失败输出更可读、支持 retries、CI 友好。大项目几乎必装,比内置 cargo test 快 2-3 倍。
15. 编译错误也是测试:trybuild
#[test]
fn ui() {
let t = trybuild::TestCases::new();
t.compile_fail("tests/ui/bad_usage.rs"); // 预期编译失败
t.pass("tests/ui/good_usage.rs");
}
测过程宏、复杂泛型 API 时,用户错误使用应该编译失败,且报错可读——trybuild 比对编译器输出 vs *.stderr 文件。
16. miri 检测 UB
rustup +nightly component add miri
cargo +nightly miri test
跑测试时检测越界、未初始化读、违反 stacked borrows 等 UB。unsafe 代码必跑,慢 10-100 倍但是值得。
17. coverage:cargo-llvm-cov
cargo install cargo-llvm-cov
cargo llvm-cov --html
cargo llvm-cov --lcov --output-path lcov.info # CI 上传到 codecov
比 tarpaulin 准,基于 LLVM source-based coverage。CI 模板:
- run: cargo llvm-cov --workspace --lcov --output-path lcov.info
- uses: codecov/codecov-action@v4
with: { files: lcov.info }
通用工程性问题
1. Flaky 测试根因清单(按发生频率)
- 共享状态:DB、文件、全局单例、env vars。
- 时间依赖:
sleep、Date.now()、时区、夏令时。 - 顺序依赖:测试 A 改了全局,B 假设它是初始态。
- 并发:goroutine / async race、未同步的 channel。
- 网络:测试连了真实外部服务。
- 随机种子:未固定。
- 资源限制:CI 内存/CPU 紧,本地宽松,超时阈值刚好压线。
通用治法:让测试纯函数化——给定输入,输出唯一。外部依赖 inject 或 mock。任何 sleep 都该改成事件等待。
2. 覆盖率高不等于测试好
100% 行覆盖可以一个 assert 都没有。看的是:
- 分支覆盖(
if/else、?.、??两侧) - 错误路径覆盖(异常、超时、空输入、reject)
- 边界(空集合、单元素、最大值、Unicode、负数、零)
- 突变测试:
mutmut(Python)、Stryker(JS)、cargo-mutants(Rust)
突变测试的思想:工具随机改你的代码(+ 变 -、> 变 >=),如果测试还过,说明这一行没被有效断言。
3. CI 过本地挂 / 反之
差异源 checklist:
- 环境变量(本地
.env,CI 没设) - 时区(
TZ=UTC本地跑一遍) - locale(
LANG=C) - 文件系统大小写(Mac/Win 不区分,Linux 区分)
- 并行度(
-j1跑一遍,挂的是顺序依赖) - 依赖版本(lockfile 没提交;CI 用的
npm ci严格按 lockfile) - 资源紧张(CI 容器 2C4G,本地 16C32G)
- 网络(CI 没法访问公司内网)
- shell(本地 zsh/fish,CI bash;
set -eo pipefail行为不同)
4. AI 协作下测试的角色
让 AI 改代码前,先让它补测试:
请给 [函数名] 写测试,覆盖:
1. 正常输入
2. 边界值(空、单元素、极大值、Unicode、负数)
3. 异常输入(类型错、None/undefined/Err)
4. 一个真实回归 case(我贴 bug 复现)
框架:pytest / vitest / cargo test。
不要修改源代码,只写测试文件。
先列大纲我审,审完再写代码。
测试当安全网,AI 改实现时:测试挂 → 信号清晰;测试过 → 大概率没回归。测试质量决定 AI 能跑多快。
5. 不要 mock 自家代码
Mock 边界 = 进程外:网络、DB、文件、时间、随机、子进程。 Mock 自家纯函数 → 测试在测 mock,不在测逻辑,变成"我希望它怎样,就 mock 它怎样"。
例外:慢的纯函数(如 1s 的加密运算)、不确定的纯函数(取时间戳)——这些其实就是隐式的"外部依赖",显式抽出来就清楚了。
6. 测试命名:行为而非函数名
❌ test_calculate
❌ test_user_service
✅ returns_zero_when_list_empty
✅ throws_invalid_email_when_missing_at_sign
✅ retries_three_times_then_gives_up
读测试列表 = 读规格说明。函数改名时测试名不用动。
AAA 结构:Arrange / Act / Assert,空行分三段:
test("...", () => {
// arrange
const user = makeUser({ age: 17 });
// act
const result = canVote(user);
// assert
expect(result).toBe(false);
});
7. 测试金字塔 → 现实里更像奖杯
经典金字塔:大量单元测试 + 中量集成 + 少量 E2E。
现实(Kent C. Dodds 推崇):测试奖杯——少量静态(TS/类型)、大量集成、少量单元 + E2E。
理由:单元测试容易测出"我希望它怎么实现",改一下实现就挂;集成测试测"对外行为",refactor 友好。
权衡看项目:数据密集型(算法、parser)单元测试更值;Web 应用集成测试更值。
8. 测试代码也是代码
- 不能 DRY 到看不懂(每个测试该能独立读懂)。
- 工厂函数和辅助 fn 集中放
testutils.ts/conftest.py/tests/common/。 - 测试本身不该有 if/for 等分支逻辑(那是给被测代码用的)。
- 测试代码 review 强度等同生产代码。
AI prompt 速查模板
修一个失败的测试
这个测试挂了,先不要改测试也不要改实现。
报错:
<完整输出,包括 stack trace>
测试代码:
<贴>
被测代码:
<贴>
请:
1. 用一句话解释错误的根本含义(中文)。
2. 列出 3 种可能原因,按概率排序。
3. 每种原因告诉我看哪个文件/命令验证。
4. 等我反馈后再动代码。
补测试
为 [文件:函数] 补测试,要求:
- 框架:[pytest / vitest / cargo test]
- 覆盖:正常 / 边界(空/单元素/极大值/Unicode) / 异常 / 一个 bug 回归 case
- mock 边界:只允许 mock [外部依赖,如 DB/network]
- 测试命名:行为描述,不出现函数名
- 用 AAA 结构,arrange/act/assert 空行分段
先列大纲我审,审完再写代码。
减少 flaky
这个测试 CI 上 1/10 挂。只做分析,不动代码:
1. 列出测试中所有非确定性来源(时间/随机/并发/IO/外部/顺序/资源)。
2. 对每一项,说明如何在测试里消除或固定。
3. 给出最小改动方案。
把 mock 测试改成集成测试
这个测试 mock 了 [X, Y, Z],我想改成用 testcontainers (Python/Node) / 真实 sqlite (Rust)。
请:
1. 列出需要替换的 mock 列表。
2. 列出新依赖。
3. 说明 setup/teardown 改动。
4. 不要直接写代码,先给方案。
review 测试质量
review 这个测试文件,从以下维度评分(1-5)并指出具体行号:
1. 命名(行为 vs 函数名)
2. AAA 结构清晰度
3. mock 边界合理性
4. 边界 case 覆盖
5. 是否有顺序依赖 / 共享状态
6. 失败信息可读性
最后给出 top 3 最值得改的问题。