测试速查:Python / TypeScript / Node.js / Rust

8 阅读19分钟

速查向。条目按"症状 → 复现 → 根因 → 修复 → 进阶"组织。 AI 协作时,把对应条目贴给 AI 比口头描述快 10 倍。


目录


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 > ByTestIdByTestId 是最后逃生舱,用它说明组件可访问性差。


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.1localhost 快(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");
});

execachild_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(libnss3libatk 等),不然 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 测试根因清单(按发生频率)

  1. 共享状态:DB、文件、全局单例、env vars。
  2. 时间依赖:sleepDate.now()、时区、夏令时。
  3. 顺序依赖:测试 A 改了全局,B 假设它是初始态。
  4. 并发:goroutine / async race、未同步的 channel。
  5. 网络:测试连了真实外部服务。
  6. 随机种子:未固定。
  7. 资源限制: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 最值得改的问题。