← 中文资源

简体中文版本

夹具隔离

夹具隔离保证每个测试从受控状态出发——setup 创建、teardown 还原——阻断顺序依赖与跨测试污染这两种 flaky 形态。

阅读英文原文

概念定义

两个测试碰同一批行就产生耦合:先跑者改变后跑者所见。隔离切断耦合——每个测试都看到崭新且已知的世界。

工作机制

事务包裹每个测试并在结束时回滚——原子、与顺序无关、快速。替代方案:按测试建 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 })));