单测运行器与 fixtures:让本机、并行与 CI 看到同一组事实
一次合并请求在开发机上连续通过,进入 CI 后却出现三种互相矛盾的结果:Java 模块显示“0 tests”,Python 模块偶尔报临时文件已经存在,前端模块第一次失败、自动重试后转绿。团队把它归咎于 runner 性能波动,直到把作业改为单线程才发现,真正的问题分属三个边界:构建工具没有把测试交给正确的引擎,两个用例共享了同一个目录,重试掩盖了依赖执行顺序的污染。
单元测试运行器不只是“执行带有某个注解或函数名的代码”。它先根据配置发现测试,把测试组织成执行树,再为每个节点建立上下文、调用生命周期钩子、调度并发任务、采集结果,最后向构建工具返回退出码和报告。fixture 则是这棵执行树中的资源所有权协议:谁创建状态,哪些用例共享,失败或中断后由谁释放。
“本机与 CI 一致”因此不是两边都能输入一个 test 命令,而是两边使用相同的依赖锁、发现根目录、时区与 locale、随机种子、并行策略、fixture 作用域、环境变量契约和报告规则。任何一项由 IDE 隐式补齐,CI 都可能看到另一组事实。
先让运行器证明它发现了哪些测试
测试未执行和测试通过必须是两种不同状态。第一步不是写更多断言,而是在本机和 CI 都保存“发现清单”。
| 运行器 | 发现入口 | 容易漂移的地方 | 应保存的证据 |
|---|---|---|---|
| JUnit | JUnit Platform 通过 TestEngine、selector 和 filter 形成测试计划 | Maven/Gradle 未启用 Platform、缺少 Jupiter engine、Surefire 命名规则排除了类 | 构建工具测试数、XML 报告、必要时 ConsoleLauncher tree |
| pytest | 从 rootdir 和配置文件出发,按 testpaths、文件/类/函数模式收集 node id | 从子目录启动选中了另一份配置、导入路径不同、未注册 marker 拼错 | --collect-only 输出、rootdir、configfile、插件列表 |
| Jest | 从 rootDir、roots、testMatch/testRegex 和 projects 形成测试文件清单 | monorepo 从错误目录启动、两套匹配规则并存、--passWithNoTests 掩盖空发现 | --listTests、--showConfig、project 名与 JSON/JUnit 报告 |
| Vitest | 从 root、include、exclude 和 project 配置发现测试文件 | vitest 的 watch 行为被带进 CI、工作目录不同、过滤条件只过滤用例却仍加载大量文件 | vitest list 清单、reporter 输出、项目名 |
JUnit Platform 把启动平台、Jupiter 编程模型和具体引擎分开;把 junit-jupiter-api 加进 classpath 并不必然意味着构建工具已经能执行它。JUnit 的构建工具说明 还指出 Maven Surefire 默认按类名模式扫描,类名偏离模式时会出现“代码编译了,但一个测试也没跑”的假绿。
pytest 的 rootdir 是配置与缓存的锚点,不会自动替你修正 Python 导入路径。pytest 的发现配置 和 collection 规则 应与仓库布局一起提交。Jest 的 --listTests 输出测试文件而不是文件内 test case,--showConfig 才能证明最终合并后的 project 配置;不要把“列出 N 个文件”误写成“N 个用例已执行”。Vitest 的名称过滤发生在测试文件加载之后,大仓库只传 -t 仍可能加载许多文件;Vitest 过滤说明 建议同时传文件路径来缩小发现范围。
团队可以把“收集数量大于零”做成门禁,但不能写死一个长期不变的总数。更稳妥的判断是:每个受影响模块至少产生一个测试结果文件;报告中的执行数等于通过、失败、跳过和错误之和;CI 合并分片后没有重复 node id,也没有缺失 shard。
把版本和入口锁进项目,不依赖开发机全局安装
JUnit:依赖、引擎和构建插件是一条链
下面以 JUnit 6.1.2 为基线,它要求 Java 17。老项目若仍运行 JUnit 5,应按自己的 JDK 与框架兼容矩阵锁定 5.x,不要只升级一个 Jupiter artifact。新建 Maven 实验可以使用下面的最小 pom.xml;版本应由仓库依赖更新流程维护,而不是由某台机器的 IDE 决定。JUnit 6.1.2 构建工具说明列出了 Maven 与 Gradle 的对应配置。
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<groupId>example.testing</groupId>
<artifactId>runner-lab</artifactId>
<version>1.0-SNAPSHOT</version>
<properties>
<maven.compiler.release>17</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<junit.version>6.1.2</junit.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.junit</groupId>
<artifactId>junit-bom</artifactId>
<version>${junit.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.5.5</version>
<configuration>
<failIfNoTests>true</failIfNoTests>
</configuration>
</plugin>
</plugins>
</build>
</project>验证入口不是 IDE 的绿色三角,而是仓库命令:
java -version
mvn -version
mvn test
Get-ChildItem target/surefire-reports在这个最小项目中,预期至少出现一个 TEST-*.xml,Maven 摘要中的 tests 数量大于零。Surefire 的 failIfNoTests 默认是 false,示例显式改成 true,让“没有任何测试可运行”成为构建失败;命令行用 -Dtest=... 指定测试却没有命中时,则由默认开启的 failIfNoSpecifiedTests 处理。若日志显示 Tests run: 0,先检查类名扫描、测试源目录和 engine;不要用“允许空测试”把构建转绿。Gradle 项目必须在 test task 中启用 useJUnitPlatform(),并让 IDE 使用 Gradle 测试配置或读取同一份 classpath 配置。
pytest:虚拟环境和配置文件共同定义运行器
python -m venv .venv
.\.venv\Scripts\python -m pip install --upgrade pip
.\.venv\Scripts\python -m pip install "pytest==9.1.1" "pytest-xdist==3.8.0"
.\.venv\Scripts\python -m pytest --versionLinux 或 macOS 将解释器路径换成 .venv/bin/python。仓库提交依赖锁或带 hash 的约束文件,不能只提交一行无上界的 pytest。pyproject.toml 中的发现约束应尽量窄:
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py", "*_test.py"]
addopts = ["--strict-config", "--strict-markers", "-ra"]
markers = [
"serial: mutates a process-global or exclusive resource",
"integration: needs an external dependency"
]先运行 python -m pytest --collect-only -q。预期输出稳定的 node id;pytest 核心在一个测试也没有收集到时返回退出码 5,不要用插件把它统一改成成功。错误配置应直接退出,而不是带着 warning 继续。使用 python -m pytest 还能减少“命令行 pytest 来自另一个环境”的歧义。
Vitest:锁文件比全局 npx 缓存更可信
npm install --save-dev vitest@4.1.10 @vitest/coverage-v8@4.1.10
npm exec vitest -- --version
npm exec vitest -- list把 package-lock.json、pnpm-lock.yaml 或 yarn.lock 与 package.json 一起提交,CI 使用 npm ci 或对应的冻结锁命令。不要让 CI 通过 npx vitest@latest 临时选择版本。一个可审查的 vitest.config.ts 可以这样起步:
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
include: ['src/**/*.test.ts'],
exclude: ['**/dist/**', '**/.work/**'],
environment: 'node',
isolate: true,
fileParallelism: true,
maxWorkers: 4,
reporters: ['default', 'junit'],
outputFile: { junit: 'test-results/vitest.xml' },
allowOnly: false,
passWithNoTests: false,
},
})maxWorkers: 4 是教学基线,不是通用容量答案。生产值应从 runner CPU、内存、文件描述符、外部依赖连接数和执行时长趋势推导。isolate: true 主要隔离测试文件的运行环境,不会自动隔离数据库、磁盘路径、端口或外部账号。
Jest:文件沙箱不等于外部资源隔离
Jest 应与转换器、测试环境和类型定义一起锁进项目,避免全局命令选择另一套版本:
npm install --save-dev jest@30.4.2 @types/jest@30.0.0
npm exec jest -- --version
npm exec jest -- --listTests
npm exec jest -- --showConfigJest 默认用 worker pool 并行执行测试文件,而单个文件中的普通测试默认串行执行。maxWorkers 控制 worker 数,不是文件内并发度;--runInBand 让所有文件在当前进程串行,适合定位污染,却不应成为长期掩盖共享资源冲突的方案。Jest CLI 还提供 --detectOpenHandles 定位未关闭句柄,但它隐含 --runInBand 且有明显性能开销,只用于诊断。
beforeEach、afterEach、beforeAll、afterAll 的作用域以测试文件和 describe 为边界。异步钩子必须返回 Promise 或使用受控回调,否则测试可能在初始化完成前开始、在清理完成前退出。Jest setup/teardown 明确 describe 回调先完成收集,再执行测试;不要在 describe 顶层创建连接、写文件或启动计时器。跨文件的一次性 globalSetup/globalTeardown 运行在独立上下文,setup 中定义的全局值不能直接交给测试文件消费,且多 project 配置只会为实际命中的 project 触发对应钩子。
fixture 作用域是在选择共享故障半径
fixture 的作用域越大,初始化次数通常越少,但可变状态被更多测试共享。应先写出资源的所有者与清理时机,再决定 scope。
| 资源 | 推荐起点 | 扩大 scope 前必须证明 |
|---|---|---|
| 内存对象、临时目录、测试用户 | 每个测试调用 | 创建成本确实成为瓶颈,并且状态可完整重置 |
| 只读 schema、不可变大文件 | 每文件或每 worker | 内容不可变,命名不冲突,worker 退出后可回收 |
| 数据库进程、消息代理 | 每 worker 或每 session | 每个测试仍有独立 schema/topic/tenant,异常中断有外部清理器 |
| 进程环境变量、默认时区、系统属性 | 尽量不共享 | 已串行化访问,并在 finally/teardown 恢复原值 |
JUnit Jupiter 默认 PER_METHOD,每个测试方法使用新的测试类实例;改成 PER_CLASS 会让实例字段跨测试共享。JUnit 生命周期说明 建议把默认值写进 src/test/resources/junit-platform.properties,否则 IDE 和构建工具可能采用不同生命周期。@BeforeAll/@AfterAll 只是钩子,不能证明中途终止时外部资源一定释放。
pytest fixture 默认 function scope,还支持 class、module、package 和 session。fixture 使用 yield 时,yield 后的代码承担 teardown;若较早的 fixture 在成功建立前抛错,后续 fixture 与测试都不会运行。pytest fixture 模型 把这种状态报告为 setup error,而不是 assertion failure。复杂 fixture 应拆成多个“创建一个状态变化、登记一个逆操作”的小步骤,避免半初始化资源无人认领。
Jest 没有内建的依赖注入式 fixture 图,资源通常由工厂函数配合钩子持有。beforeAll 共享连接只降低初始化成本,不会自动回滚每个测试写入的数据;若状态不能在 beforeEach 中完整复位,就应降到 test scope,或为每个测试生成独立 schema、目录和账号。clearMocks 只清调用记录,resetMocks 还重置 mock 实现,restoreMocks 才尝试恢复 spy 和被替换属性;三者都不会清 module cache、环境变量、fake timer 或外部数据库。把它们混写成“自动隔离”会留下跨用例污染。
Vitest 的 test.extend 默认是 test scope,也支持 file 与 worker scope。Vitest Test Context 明确 worker/file fixture 运行在具体测试之外,不能访问 test-scoped context;关闭 isolation 后,worker 状态还可能跨文件共享。scope 不是性能开关,而是状态可见性协议。
用临时目录跑通一条可复制的隔离实验
下面的 Vitest 实验只使用 Node 内置文件系统,不需要账号、容器或外部服务。新建 src/fixture-isolation.test.ts:
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { test as base } from 'vitest'
const test = base.extend<{ workspace: string }>({
workspace: async ({}, use) => {
const dir = await mkdtemp(join(tmpdir(), 'runner-fixture-'))
try {
await use(dir)
} finally {
await rm(dir, { recursive: true, force: true })
}
},
})
test.concurrent('alpha owns its directory', async ({ workspace, expect }) => {
const file = join(workspace, 'state.txt')
await writeFile(file, 'alpha', 'utf8')
expect(await readFile(file, 'utf8')).toBe('alpha')
})
test.concurrent('beta owns its directory', async ({ workspace, expect }) => {
const file = join(workspace, 'state.txt')
await writeFile(file, 'beta', 'utf8')
expect(await readFile(file, 'utf8')).toBe('beta')
})执行:
npm exec vitest -- run src/fixture-isolation.test.ts --reporter=verbose在 Vitest 4.1.10 与受支持 Node.js 版本组成的环境中,预期两个用例都通过,并且每个用例拿到不同目录。并发测试使用上下文中的局部 expect,让断言和快照归属当前测试任务。这里要观察的不变量不是“文件 API 可用”,而是并发调用之间没有共享路径,并且 fixture 的 finally 在测试抛错时仍执行删除。
反向实验把 fixture 改成固定目录,并用 barrier 保证两个写入都完成后才读取:
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { test as base } from 'vitest'
const shared = join(tmpdir(), 'runner-fixture-shared')
let arrivals = 0
let release!: () => void
const bothArrived = new Promise<void>((resolve) => { release = resolve })
async function barrier() {
arrivals += 1
if (arrivals === 2) release()
await bothArrived
}
const test = base.extend<{ workspace: string }>({
workspace: async ({}, use) => {
await mkdir(shared, { recursive: true })
await use(shared)
},
})
for (const owner of ['alpha', 'beta']) {
test.concurrent(`${owner} expects its own state`, async ({ workspace, expect }) => {
const file = join(workspace, 'state.txt')
await writeFile(file, owner, 'utf8')
await barrier()
expect(await readFile(file, 'utf8')).toBe(owner)
})
}执行同一条 Vitest 命令时,两个用例最终读取的是同一个文件,因此至少一个断言应失败;这稳定证明了共享路径污染,而不是依赖调度运气制造偶发错误。并发 writeFile 到同一路径本身也不受支持,不应对具体损坏字节做断言。实验后执行:
Remove-Item -Recurse -Force (Join-Path $env:TEMP 'runner-fixture-shared') -ErrorAction SilentlyContinue
Remove-Item -Recurse -Force test-results,coverage -ErrorAction SilentlyContinue不要把仓库根目录、用户 home 或共享缓存目录传给递归删除。pytest 的 --basetemp 也会在运行前直接清空目标路径,pytest 临时目录说明 对此有明确警告;CI 应把它指向当前作业专属目录。
三种 fixture 写法表达同一个资源协议
JUnit 没有与 pytest 同形的 fixture 函数,但可以用生命周期、参数解析器、extension store 和 @TempDir 表达所有权。对普通文件实验,优先使用内置 @TempDir:
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.nio.file.Files;
import java.nio.file.Path;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class FileContractTest {
@Test
void writesOnlyInsideOwnedDirectory(@TempDir Path workspace) throws Exception {
Path state = workspace.resolve("state.txt");
Files.writeString(state, "alpha");
assertEquals("alpha", Files.readString(state));
}
}@TempDir 的默认 cleanup mode 是 ALWAYS,作用域结束时递归删除;改成 ON_SUCCESS 会保留失败现场,也会增加磁盘占用和敏感数据残留。JUnit 内置扩展说明提供全局 cleanup mode、自定义 factory 和删除失败策略,但团队应给保留目录设置容量、脱敏和过期清理责任人。pytest 的 tmp_path 虽然对每个调用给出唯一目录,默认却会保留最近三次测试会话的临时目录;需要立即清除时应显式配置 tmp_path_retention_policy,而不是把“临时”理解成用例结束即删除。
pytest 用 yield 把使用点夹在建立与释放之间:
from pathlib import Path
import pytest
@pytest.fixture
def workspace(tmp_path: Path):
marker = tmp_path / "owner.txt"
marker.write_text("fixture-owned", encoding="utf-8")
yield tmp_path
assert marker.exists(), "test deleted fixture ownership evidence"
def test_reads_owned_workspace(workspace: Path):
assert (workspace / "owner.txt").read_text(encoding="utf-8") == "fixture-owned"teardown 中的断言失败会把原本通过的用例变成 error,这正是资源契约被破坏的证据。外部资源更适合把删除动作写成幂等调用,并在日志中只记录非敏感资源 id;不要因为业务断言失败就跳过回收。
Vitest object syntax 的 use 回调具有相同结构。新版本还支持 onCleanup,但每个 fixture 只能注册一次;多个独立资源应拆成多个 fixture,以免第二个清理注册失败。Jest 若需要同样的所有权结构,应把“创建资源并返回幂等 disposer”封装成工厂,再由创建它的同一层 afterEach/afterAll 释放;不要让共享 setup 模块在 import 时偷偷创建资源。
参数化测试扩展输入空间,不共享可变参数
参数化的目标是让每组输入拥有独立结果标识和失败证据。不要为了少写初始化,把一个可变 list、数据库连接或目录放进所有参数实例。
JUnit @ParameterizedTest 需要 junit-jupiter-params,junit-jupiter 聚合依赖已包含它。每个参数 invocation 会单独报告;参数若实现 AutoCloseable,默认在 invocation 后关闭,JUnit 参数化说明 也提醒复用同一可关闭参数时要明确 autoCloseArguments,否则后续实例会拿到已关闭资源。
@ParameterizedTest(name = "{index} -> zone={0}")
@ValueSource(strings = {"UTC", "Asia/Shanghai"})
void rendersInExplicitZone(String zoneId, @TempDir Path workspace) {
assertTrue(workspace.isAbsolute());
assertNotNull(java.time.ZoneId.of(zoneId));
}pytest 的参数值按原对象传入,不会自动复制。字典或列表被一个实例修改后,后续实例可能看到污染。把测试数据声明成不可变结构,或在 function-scoped fixture 中深拷贝;参数 id 应稳定、可读且不包含真实用户名、邮箱、token 或客户编号。
Vitest 的 test.each 同样应避免共享可变对象。数据规模很大时,不要在 collection 阶段把完整生产快照展开成数万 invocation;按等价类、边界值与风险模型生成合成样本,并把大规模随机验证交给属性测试或专门的数据测试作业。
并行不是把串行缺陷乘以 CPU 数量
并行运行器至少有三层隔离:进程或 worker、测试文件或容器、单个测试调用。内存全局变量可能只在一个 worker 内共享,文件、端口、数据库和远端账号却跨所有 worker 可见。
JUnit 并行默认关闭。开启后,应先让类并行、类内方法串行:
# src/test/resources/junit-platform.properties
junit.jupiter.execution.parallel.enabled = true
junit.jupiter.execution.parallel.mode.default = same_thread
junit.jupiter.execution.parallel.mode.classes.default = concurrent
junit.jupiter.execution.parallel.config.strategy = fixed
junit.jupiter.execution.parallel.config.fixed.parallelism = 4
junit.jupiter.execution.parallel.config.fixed.max-pool-size = 4JUnit 6.1.2 并行执行说明区分“期望 parallelism”和最大线程数:默认 fork_join_pool 在阻塞或同步时可能创建更多线程,所以示例同时把 fixed.max-pool-size 设为 4。若测试或业务代码本身使用 ForkJoinPool,还要评估实验性的 worker_thread_pool,不能把 executor 切换当成无风险优化。测试若修改系统属性,可使用 @ResourceLock 声明共享资源;更好的设计仍是把全局配置改成显式依赖。@ResourceLock 只能协调同一 JUnit 测试计划,不能锁住另一个 Maven fork、另一个 CI job 或远端共享环境。
pytest-xdist 的 -n auto 使用多个 worker 进程,默认 --dist load 不保证顺序;loadscope、loadfile 与 loadgroup 能把共享昂贵 fixture 的测试放到同一 worker。pytest-xdist 分发模型 不会替测试生成唯一数据库名。使用 worker id 与作业 id构造资源前缀,例如 ci_<run>_<worker>_<test-hash>,并限制长度与字符集。
.\.venv\Scripts\python -m pytest -n 4 --dist loadscope
.\.venv\Scripts\python -m pytest -n 0第一条验证并行,第二条提供串行诊断基线。若只有并行失败,优先找共享状态、资源容量与 teardown 竞争;不要永久退回单线程后宣称修复。
Vitest 默认可并行运行测试文件,文件内测试需要 test.concurrent 或 sequence 配置才会并发。Vitest 并行说明 区分这两个层级。maxWorkers 要与 CI 容器实际 CPU 配额匹配;宿主机报告 16 核而容器只有 2 核时,自动并发可能增加调度与内存抖动。
Jest 同样默认按文件分发到 worker;worker 进程可连续执行多个文件,所以 JEST_WORKER_ID 适合构造 worker 级资源名,却不能替代 test id。固定端口、固定数据库名和仓库内固定临时目录仍会跨 worker 冲突。test.concurrent 又会在单文件内并发回调,其 maxConcurrency 与 maxWorkers 是两层不同限制。定位并发故障时先用 --runInBand 建立串行反证,再修资源命名与清理,最后恢复并行;不要把串行化当成根治。
随机种子用来复现顺序,不替资源隔离
随机顺序能暴露“前一个测试替后一个测试初始化”的隐藏依赖。种子必须在每次运行开始时打印到报告,并允许通过环境变量重放。
JUnit 可以使用 @TestMethodOrder(MethodOrderer.Random.class) 并通过 junit.jupiter.execution.order.random.seed 固定顺序种子。该种子只控制 JUnit 的随机 orderer,不会自动控制业务代码中的 Random、第三方生成器或数据库返回顺序。
Vitest 可以执行:
npm exec vitest -- run --sequence.shuffle --sequence.seed=1847Vitest sequence 配置 说明 seed 只有在 shuffle 开启时生效。业务随机数据仍应从测试上下文注入自己的 PRNG,并把同一个 seed 写入失败报告。
Jest 可用 --randomize --seed=1847 --showSeed 打乱文件内测试顺序;seed 也参与 suite 顺序,并可通过 jest.getSeed() 读取。Jest CLI 的 seed 与 randomize 明确 --randomize 只由默认 jest-circus runner 支持。它同样不会替业务 PRNG、faker 或数据库结果播种,失败报告必须分别记录 runner seed 与数据 seed。
pytest 核心不负责统一播种所有随机库。可以在 session fixture 中读取 TEST_SEED,分别初始化 Python random、NumPy 或属性测试库,但必须记录每个生成器实际接收的 seed。若采用 pytest-randomly 等插件,要锁定插件版本并验证它对并行 worker 的派生规则;“装了随机插件”不等于所有库共用一个随机流。
发现一个顺序相关失败后,保留原 seed 作为回归入口,同时修掉共享状态。只固定 seed 会让测试重新稳定,却把未覆盖的其他顺序永久藏起来。
时间、时区和 locale 必须变成输入
在测试中直接读取系统当前时间,会让午夜、夏令时切换和不同时区 runner 成为隐式参数。可靠做法是让业务代码依赖可注入的 clock;测试传固定 instant 和明确 zone。JUnit 自身不提供业务时钟替身,Java 代码可注入 java.time.Clock.fixed(...)。JUnit 6.1 的 @DefaultTimeZone 与 @DefaultLocale 适合验证确实依赖 JVM 默认值的兼容路径:扩展会设置并恢复全局值,并通过 resource lock 避免同类注解测试并行;其他直接读写默认时区或 locale 的测试仍须使用对应的 read/write 注解协调。普通转换逻辑继续传显式 ZoneId,不要扩大进程全局状态的故障半径。
Vitest 可以在每个测试中启用并恢复 fake timer:
import { afterEach, expect, test, vi } from 'vitest'
afterEach(() => vi.useRealTimers())
test('expires relative to injected T0', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date(0))
const expiresAt = new Date(Date.now() + 60_000)
expect(expiresAt.getTime()).toBe(60_000)
})Jest 的现代 fake timer 同样基于 @sinonjs/fake-timers,可替换 Date、performance 与多种 timer/microtask API;它与 Vitest 的 API 名称相似,但配置默认值、被替换 API 和推进异步 timer 的方法不能直接照搬。两者都应在 afterEach 恢复 real timers,并在切换前确认待执行 timer 已被断言或清空,否则恢复真实时钟会把未执行任务静默丢掉。Jest fake timers给出了实际替换清单。
fake timer 只控制当前 JavaScript 测试环境中被替换的 API,不会让数据库、子进程或远端服务跟着变化。pytest 项目也应优先注入 clock;monkeypatch 环境变量后要恢复,但 Windows 没有与 POSIX 完全相同的 tzset 行为。需要验证进程默认时区时,启动独立子进程并显式设置 TZ,不要在并行 worker 中切换整个进程的时区。
CI 统一设置 TZ=UTC、UTF-8 locale 只是减少环境差异,不能替代多时区用例。报告还应记录 OS、运行时版本、timezone 与 locale 名称,禁止记录主机名、用户 home、访问 token 和完整内部路径。
timeout 限制等待时间,不承诺资源已经停止
JUnit 的 @Timeout 默认 INFERRED 到 SAME_THREAD:超时后由另一线程中断执行测试的线程;显式 SEPARATE_THREAD 与 assertTimeoutPreemptively() 会让代码运行在另一个线程,可能绕开依赖 ThreadLocal 的事务上下文。pytest 核心的 faulthandler_timeout 只负责超时后打印所有线程栈,默认不会让用例失败;需要强制终止可配置 faulthandler_exit_on_timeout,需要按用例失败语义通常要锁定 pytest-timeout 等插件。Vitest 的 testTimeout 与 hookTimeout 分别限制测试和钩子,CLI 当前默认值是 5000 ms 与 10000 ms;测试上下文的 signal 会在超时或取消时 abort,但被测代码必须主动把它传给支持取消的 API。
三者的 timeout 都不能证明外部请求、子进程、线程或容器已经退出。超时日志要区分“runner 停止等待”和“资源确认回收”;作业级 deadline 之后仍要运行幂等清理器,并记录未回收资源的非敏感 ID。JUnit timeout 线程模式、pytest faulthandler 配置和 Vitest CLI分别定义了这些边界。
sharding 解决墙钟时间,fixture 解决隔离
分片把测试计划拆给多个 CI job;worker 并行是在一个 job 内调度。两者叠加时,资源名必须包含 shard 与 worker 两级身份,报告也必须在合并后检查完整性。
Vitest 原生支持:
npm exec vitest -- run --shard=1/3 --reporter=blob
npm exec vitest -- run --shard=2/3 --reporter=blob
npm exec vitest -- run --shard=3/3 --reporter=blob
npm exec vitest -- --merge-reports --reporter=junit --outputFile=test-results/vitest.xmlVitest shard 参数 使用 <index>/<count>,不能与 watch 同用。Vitest 拆分的是测试文件而不是文件内的单个 test case,因此一个超大文件仍会形成长尾;三个 shard 必须来自同一 commit、依赖锁和配置,任一缺失都应让合并作业失败。blob reporter 的默认文件名会包含 shard 信息以避免互相覆盖,但 CI 仍要分别上传后汇入同一个 merge 目录。
Jest 也原生接受 --shard=1/3 这类 1-based 参数,并要求自定义 testSequencer 实现 shard 方法。分片对象同样是测试 suite/文件而不是文件内 case;自定义 sequencer 若按历史耗时分配,必须保存输入清单、算法版本和每个 shard 的文件清单。Jest shard 说明只负责选择集合,不负责合并 JUnit、coverage 或 snapshot 证据。
JUnit Platform 与 pytest 核心没有一条跨 CI 的通用 --shard 契约。Java 可以按 Gradle task、Maven module、tag 或由 CI 生成的类清单拆分;pytest 可以按稳定 node id 清单拆分,或由 CI 插件完成。不要用“当前测试序号取模”处理会变化的 collection 清单,否则增删一个文件会让大量测试换 shard,历史时长与失败归属都失去可比性。
按历史耗时平衡 shard 能缩短长尾,但调度清单必须版本化或作为制品保存。任何动态分片器都要证明:收集到的每个测试 id 恰好出现一次;被 quarantine 的用例单独计数;报告合并没有覆盖同名文件。
retry 是故障分类器,不是稳定性配置
Vitest 4.1+ 的 retry 对象提供 count、delay 和按错误消息匹配的 condition,旧版常见的数字形式只表达次数;Jest 的 jest.retryTimes() 是文件或 describe 级重试设置;pytest 常通过 pytest-rerunfailures 插件实现;JUnit 的 @RepeatedTest 是重复执行语义,不是失败重试。四种能力来源和报告语义不同,不要混成一个团队默认开关。
建议基线是无重试。确需隔离外部暂态故障时,报告必须同时保留首次失败与后续尝试,并按以下结果分类:
首次失败、同一进程重试通过:高度怀疑共享状态、顺序、时间或异步等待。worker 重启后通过:关注进程级缓存、内存泄漏、端口和运行时崩溃。新 job 才通过:关注网络、镜像、远端限流、凭据与 runner 状态。
使用同 seed 和相同 fixture 快照仍稳定失败:进入真实缺陷处理,不应继续重试。
retry rate 应作为质量指标,不能只看最终失败率。一个用例连续多个观察窗口都需要重试,就应进入 quarantine,保留明确 owner、问题单和退出条件;quarantine 作业仍运行并报告,但不得把失败悄悄转成主门禁成功。
coverage 只能证明执行到,不能证明断言有效
JUnit 通常由 JaCoCo 等构建插件采集覆盖率,pytest 常用 pytest-cov,Jest 通过 V8 或 Babel provider 采集,Vitest 可选择 V8 或 Istanbul provider。四个生态的行、分支、函数定义和 source map 处理并不完全相同,阈值不能脱离工具直接横向比较。
Vitest 示例:
coverage: {
enabled: true,
provider: 'v8',
include: ['src/**/*.ts'],
exclude: ['src/**/*.test.ts', 'src/generated/**'],
reporter: ['text', 'json', 'lcov'],
thresholds: {
lines: 80,
branches: 75,
functions: 80,
statements: 80,
},
}这些数字只是示例门槛,项目应以当前基线和风险模块设定,并对新代码防回退。Vitest coverage 配置 默认只统计被测试触达的文件,若不显式设置 include,完全没被加载的源文件可能不进入分母。
分片覆盖率应先产出原始数据,再由单一作业合并;不要把各 shard 百分比求平均。retry 也可能重复计数执行路径,不能把“重试后覆盖率上升”当成测试增强。覆盖率门禁还要配合 mutation test、关键分支断言审查和故障注入,才能回答测试是否真的能抓住错误。
CI 一致性靠显式运行契约,而不是同名脚本
每个语言模块应提供单一仓库入口,例如 ./mvnw test、.venv/bin/python -m pytest、npm test -- --run,并把 CI 需要的输入做成非敏感环境变量:
TEST_SEED=<reported integer>
TEST_SHARD_INDEX=<one-based index>
TEST_SHARD_COUNT=<positive count>
TEST_WORKER_LIMIT=<capacity-derived integer>
TEST_TIMEZONE=UTC
TEST_ARTIFACT_DIR=<job-owned directory>作业开始记录 commit、依赖锁 hash、运行时与 runner 版本、发现数量、seed、shard 和 worker 配置;作业结束上传 JUnit XML、首次失败日志、覆盖率原始数据和失败时受控保留的临时证据。日志不能回显环境变量全集。
本机和 CI 的差异应被有意缩小:
本机也从仓库根目录运行同一 wrapper,不依赖 IDE 默认配置。CI 使用冻结依赖,缓存 key 包含 lockfile、运行时、OS 和架构。并行度是配置输入,本机可较低,但隔离语义不变。
CI 缺少测试或报告文件时失败,不能把空目录当成功 artifact。失败证据有保留期限与访问控制,成功运行不长期保存含测试数据的临时目录。
Docker socket 只在 fixture 真正需要容器化依赖时开放。挂载 /var/run/docker.sock 等价于给测试作业很高的宿主控制能力;共享 runner 上应使用隔离 executor、受限远端 daemon 或专用 runner,并限制可拉取 registry。镜像代理、私有仓库 CA 与凭据由 CI secret 注入,不能写入测试代码、fixture 名称、命令行参数或 XML 属性。单元测试若无外部依赖,不应为了“环境一致”额外引入 Docker。
常见失败先按证据层分型
发现数为零但退出码成功
检查构建工具、engine、rootdir、include/testpaths 和过滤器。保存 collect/list 输出,并增加非空门禁。不要先改测试命名迎合未知规则,要确认哪份配置实际生效。
单独运行通过,整套运行失败
用固定 seed 随机顺序,比较失败前执行过的用例;检查实例字段、module cache、环境变量、默认时区、数据库记录和固定路径。将可疑资源降到 test scope,再逐项扩大 scope 找到污染边界。
串行通过,并行失败
记录 shard、worker、进程 id 和非敏感资源 id。检查端口、目录、schema、topic、账号、系统属性与 mock server 是否唯一。先证明资源命名独立,再判断是否是 CPU、内存或连接池容量不足。
测试通过但进程不退出
检查未关闭的线程、timer、socket、数据库连接和 watcher。JUnit 可借助线程 dump,Node 可检查 open handle,pytest 可检查 fixture teardown 是否执行。强制超时只能中断等待,不能替代资源 close;进程被 kill 后还要有作业级清理器回收外部对象。
本机和 CI 输出不同
对比运行时、lockfile hash、工作目录、timezone、locale、文件系统大小写、路径长度、代理和证书。禁止把 CI 的真实凭据下载到开发机复现;使用权限等价的短期测试身份和合成数据。
覆盖率突然下降或合并后超过合理范围
确认 include/exclude、source map、生成代码、分片原始文件与合并工具版本。检查是否有 shard 缺失、报告互相覆盖或只统计已加载文件。百分比异常先视为采集问题,不要直接降低阈值。
测试数据要能生成、隔离、追责和销毁
fixture 中的数据分四类处理:
纯合成数据:由固定 seed 和 schema 生成,允许进仓库,字段要显式标记为测试值。脱敏样本:必须有不可逆规则、重识别风险评审、访问范围和过期时间,不能把“删了姓名”当作完成脱敏。大型基准集:作为版本化制品保存 hash、schema version、owner 和保留策略,不随意塞进 Git LFS 或 runner 缓存。
外部账号与租户:每次作业创建唯一命名空间,使用最小权限短期身份,结束后按标签回收,定期扫描孤儿资源。
测试报告、snapshot、临时目录和 coverage source map 都可能包含输入数据。artifact 权限不能默认等同源码读取权限;失败截图、HTTP dump 和数据库快照尤其要做字段脱敏与短期保留。不要用生产快照验证普通单测,也不要把真实 token 伪装成 fixture 常量。
容量预算同时覆盖 CPU、内存、磁盘 inode、临时目录保留、测试报告、外部 API 配额和容器镜像流量。扩大并行度前观察“总 CPU 时间、墙钟时间、峰值内存、失败率、retry rate、孤儿资源数”是否一起改善;只缩短墙钟时间却让重试和泄漏上升,是把成本转移给排障人员。
架构选型从反馈速度和故障半径出发
JUnit 适合 JVM 构建链与多引擎平台,Platform selector、tag、extension 和构建工具集成成熟;代价是 engine、构建插件、fork 与 IDE 四层配置容易漂移。pytest 的 fixture 依赖图、参数化与插件生态灵活,适合 Python 服务和数据项目;代价是插件组合、导入模式和 session fixture 容易形成隐式平台。Jest 适合需要成熟 mock、snapshot、projects 和 Node/jsdom 生态的仓库;代价是转换器、测试环境、module cache 与 worker 配置容易叠成隐式状态。Vitest 与 Vite/TypeScript 工具链贴合,文件隔离、fixture、watch、shard 和浏览器相关能力统一;代价是 worker pool、ESM/module cache 与 fake timer 边界需要前端团队真正理解。
不要因为一个仓库是多语言就强行统一 runner。应该统一的是运行契约:非空发现、稳定唯一标识、fixture 所有权、seed 重放、worker/shard 命名、首次失败保留、报告 schema、数据分级和清理证明。语言侧保留最适合生态的工具,平台侧负责合并证据而不是抹平语义差异。
小型模块先用默认串行和 test-scoped fixture,建立可靠基线后再开启文件级并行。中型仓库按模块或 project 拆作业,并限制每个 job 的 worker。大型仓库再引入历史耗时分片、远程缓存与 quarantine 服务,但必须维护测试清单完整性、缓存可信边界和平台 owner。共享 staging 不能成为单测默认依赖;需要真实协议的测试应进入集成或契约测试层,并使用独立租户与容量预算。
长期治理用可证明的不变量收尾
团队成熟度不看“有多少测试”,而看下面这些事实能否持续被证明:
每个受影响模块都发现并执行了非零测试,测试 id 在分片间不重不漏。默认无重试;发生重试时保留首次失败,并能按用例、原因和 owner 追踪。fixture 创建的文件、进程、容器、schema、topic 和远端租户都有唯一 owner 与幂等清理。
并行运行多轮后,孤儿资源数和临时磁盘占用回到稳定基线,不随轮次单调增长。随机顺序失败能用 seed 重放;修复后继续随机,而不是永久固定顺序。时间、时区、locale 与外部输入显式化,不依赖 runner 所在地区和系统默认值。
报告、覆盖率和失败 artifact 完整、可访问、受控保留,且不包含凭据与真实敏感数据。runner、插件与运行时升级经过串行/并行、全量/分片、成功/失败清理四组兼容验证,能回退到已锁定版本。
当一次失败能够从报告定位到确定的测试 id、seed、shard、worker 和 fixture 资源,当重放不依赖原 runner,当异常中断后外部状态仍能被扫描和回收,单测运行器才真正成为工程反馈系统。否则再快的并行、再高的覆盖率,也只是把不确定性更快地送进 CI。
