← 中文资源

简体中文版本

测试数据构建器

测试数据构建器用合理默认值加逐测试覆盖构造合法对象:夹具可读,不变量随领域演进集中维护。

阅读英文原文

概念定义

测试夹具因复制而腐化:每个测试手工拼装领域对象,下一次领域变更要改遍全场。构建器反转它——构造知识集中,变化局部化。

工作机制

构建器持有合法默认值,并经由领域工厂或实体本身构造,使非法状态无从产生;测试只链式覆盖受测字段。场景助手——按业务语义命名——包装常见配置,让测试读起来是意图而不是字段汤。领域字段变更时只改构建器一处,五十份内联字面量从此不必逐一追改。

使用场景

同一实体出现在超过一小撮测试里、字段相互依赖、合法性本身在演进的领域。纯值结构继续用字面量即可,不必为它引入构建器。

常见误区

绕过构造器或校验的构建器会重新引入不可能状态。每个字段都暴露的上帝构建器产出的测试与序列化数据无异。默认值会静默偏离业务现实——要像生产代码一样评审,因为它编码的就是领域假设;命名只反映字段名的覆盖会丢掉测试读者需要的场景意图。

代码示例

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 })));
import random
import string
from dataclasses import dataclass, replace


@dataclass(frozen=True)
class User:
    identifier: int
    email: str
    display_name: str
    created_at: str


class FixtureFactory:
    def __init__(self, seed: int = 20261021) -> None:
        self._rng = random.Random(seed)
        self._next_id = 1
        self._created_at = "2026-10-21T00:00:00Z"

    def _word(self, length: int = 6) -> str:
        return "".join(self._rng.choice(string.ascii_lowercase) for _ in range(length))

    def user(self, **overrides: object) -> User:
        identifier = self._next_id
        self._next_id += 1
        user = User(
            identifier=identifier,
            email=f"{self._word(5)}.{identifier}@example.test",
            display_name=f"{self._word(4).title()} {self._word(6).title()}",
            created_at=self._created_at,
        )
        return replace(user, **overrides)  # type: ignore[arg-type]

    def users(self, count: int) -> list[User]:
        if count < 0:
            raise ValueError("count must not be negative")
        return [self.user() for _ in range(count)]


def main() -> None:
    first = FixtureFactory(seed=7).users(3)
    again = FixtureFactory(seed=7).users(3)
    other = FixtureFactory(seed=8).users(3)
    print(first)
    print("deterministic:", first == again)
    print("seed changes output:", first != other)
    print("override:", replace(first[0], email="[email protected]"))


if __name__ == "__main__":
    main()