概念定义
两个测试碰同一批行就产生耦合:先跑者改变后跑者所见。隔离切断耦合——每个测试都看到崭新且已知的世界。
工作机制
事务包裹每个测试并在结束时回滚——原子、与顺序无关、快速。替代方案:按测试建 schema 或从模板快照建库。文件系统与时钟用临时目录和假定时器隔离。框架的夹具图决定作用域——函数、模块、会话——会话级只允许只读数据。
使用场景
并行化套件之前、引入重试之前、以及 flaky 与打乱顺序相关之时。CI 应定期随机排序运行以探测新生的耦合。
常见误区
“同一套件本来就共享数据库”正是重排即变结果的原因。truncate-每测试慢于回滚且受外键顺序牵制;回滚是原子的。会话级可变夹具披着性能外衣悄悄重新引入耦合;回滚管不到事务外的副作用——消息队列发布、缓存写、文件;断言彼此数据也会在断言层建立耦合。隔离是并行的前提:没有它,执行器只能串行。
代码示例
import tempfile
from contextlib import contextmanager
from dataclasses import dataclass
from pathlib import Path
from typing import Iterator, Optional
class SandboxEscapeError(ValueError):
"""Raised when a requested path would leave the sandbox root."""
@dataclass(frozen=True)
class Sandbox:
root: Path
def resolve(self, *parts: str) -> Path:
target = self.root.joinpath(*parts).resolve()
if target != self.root and self.root not in target.parents:
raise SandboxEscapeError("path escapes the sandbox: " + str(target))
return target
def write(self, relative_path: str, content: str) -> Path:
target = self.resolve(relative_path)
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(content, encoding="utf-8")
return target
def read(self, relative_path: str) -> str:
return self.resolve(relative_path).read_text(encoding="utf-8")
def exists(self, relative_path: str) -> bool:
return self.resolve(relative_path).exists()
@contextmanager
def sandbox(prefix: Optional[str] = None) -> Iterator[Sandbox]:
"""Yield a private temporary sandbox and always remove it afterwards."""
with tempfile.TemporaryDirectory(prefix=prefix or "devfactory-") as raw:
yield Sandbox(root=Path(raw).resolve())
export function createBuilder(defaults) {
if (defaults === null || typeof defaults !== "object" || Array.isArray(defaults)) {
throw new TypeError("defaults must be a plain object");
}
const layers = [];
const build = (overrides = {}) => Object.assign({}, defaults, ...layers, overrides);
const assertOverrides = (overrides) => {
if (overrides === null || typeof overrides !== "object" || Array.isArray(overrides)) {
throw new TypeError("overrides must be a plain object");
}
};
const api = {
with(overrides) {
assertOverrides(overrides);
layers.push(overrides);
return api;
},
reset() {
layers.length = 0;
return api;
},
build,
buildList(count, overrides = () => ({})) {
if (!Number.isInteger(count) || count < 0) {
throw new RangeError("count must be a non-negative integer");
}
if (typeof overrides !== "function") throw new TypeError("overrides must be a function");
return Array.from({ length: count }, (_, index) => build(overrides(index)));
},
};
return api;
}
const user = createBuilder({ role: "member", active: true });
console.log(user.with({ role: "admin" }).build({ name: "Ada" }));
console.log(user.reset().buildList(2, (index) => ({ id: index + 1 })));