← 中文资源

简体中文版本

测试夹具

测试夹具是测试运行所针对的固定环境与数据,在执行前建立、执行后拆除。

阅读英文原文

概念定义

夹具是测试运行前必须存在、运行后必须移除的一切:内存对象、数据库记录、临时文件、环境变量、已配置的客户端。建立阶段构造基线,拆除阶段恢复世界。当每个测试拥有或被交付一份全新夹具时,测试之间互不可见,可以按任意顺序甚至并行运行而不相互干扰。

为什么重要

顺序依赖是最常见的不稳定与困惑来源之一:一个单独运行通过的测试,在套件里却失败,因为更早的测试留下了状态。夹具从根本上解决这一点,它让起始条件显式且隔离。它也记录了某段代码对其所处世界的假设,把隐式搭建变成可读的意图。

工程实践

按测试或按作用域创建夹具,并重置共享资源,使状态不在测试间渗出。优先使用快速的内存或容器化夹具,而非生产数据,这既为隐私也为确定性。用小而专的工厂辅助函数构建夹具,让相关字段显式、其余取默认,使每个测试只声明它关心的部分。度量并尽量压缩搭建成本,因为夹具耗时常常主导整个套件。

代码示例

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())
import sqlite3
from typing import Iterator

import pytest


@pytest.fixture
def db() -> Iterator[sqlite3.Connection]:
    connection = sqlite3.connect(":memory:")
    connection.execute(
        "CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT NOT NULL)"
    )
    try:
        yield connection
    finally:
        connection.close()


def test_insert_item(db: sqlite3.Connection) -> None:
    db.execute("INSERT INTO items (name) VALUES (?)", ("widget",))
    count = db.execute("SELECT COUNT(*) FROM items").fetchone()[0]
    assert count == 1