概念定义
测试替身用于替换真实的协作者,以便隔离测试某个单元。各变体区别在于它们断言什么:哑元被传入但从不使用;桩返回预置答案;间谍记录调用以备后查;伪造是轻量但可工作的实现;模拟则预置期望,若未被满足就失败。选择正确的种类以及模拟的程度,决定了测试是能捕获真实缺陷,还是仅仅复述实现。
为什么重要
真实依赖会让测试变慢、变得不稳定,并依赖外部状态;替身恢复了速度与可重复性。但替身的选择决定了测试能发现什么。桩让代码能走到难以触发的错误路径;伪造能演练真实逻辑;模拟断言交互,当交互本身就是契约时很强大,而当它只是实现细节时很脆弱。
工程实践
优先使用假设最少的可用替身:用桩提供数据、用伪造演练逻辑,把模拟保留给“交互确实就是可观察行为”的协作者。绝不要模拟被测单元,并把替身放在设计的边界处,使测试关注行为而非结构。当真正的集成才是重点时就改用真实或内存中的组件而非模拟,并验证替身与真实对象遵守同一契约。
代码示例
export type Outcome<T> = { readonly ok: true; readonly value: T } | { readonly ok: false; readonly error: Error };
export function ok<T>(value: T): Outcome<T> {
return { ok: true, value };
}
export function fail(message: string): Outcome<never> {
return { ok: false, error: new Error(message) };
}
export interface AttemptLog {
readonly index: number;
readonly at: number;
readonly result: "ok" | "error";
}
export interface RetryHarness<T> {
readonly attempts: AttemptLog[];
readonly loader: () => Promise<T>;
readonly exhausted: () => boolean;
}
export function createHarness<T>(
script: readonly Outcome<T>[],
clock: { now: () => number; setTimeout: (fn: () => void, ms: number) => unknown },
): RetryHarness<T> {
const attempts: AttemptLog[] = [];
let index = 0;
const loader = async (): Promise<T> =>
new Promise<T>((resolve, reject) => {
const outcome = script[index];
attempts.push({
index,
at: clock.now(),
result: outcome.ok ? "ok" : "error",
});
index += 1;
if (outcome.ok) resolve(outcome.value);
else reject(outcome.error);
});
return {
attempts,
loader,
exhausted: () => index >= script.length,
};
}
package main
import (
"bytes"
"fmt"
"os"
"path/filepath"
"strings"
)
func WriteGolden(target string, data []byte) error {
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return fmt.Errorf("create golden dir: %w", err)
}
return os.WriteFile(target, data, 0o644)
}
func diffLines(want, got string) string {
wantLines := strings.Split(strings.TrimSpace(want), "\n")
gotLines := strings.Split(strings.TrimSpace(got), "\n")
limit := len(wantLines)
if len(gotLines) < limit {
limit = len(gotLines)
}
var builder strings.Builder
for i := 0; i < limit; i++ {
if wantLines[i] != gotLines[i] {
fmt.Fprintf(&builder, "- %s\n+ %s\n", wantLines[i], gotLines[i])
}
}
if len(wantLines) != len(gotLines) {
fmt.Fprintf(&builder, "line count: want %d got %d\n", len(wantLines), len(gotLines))
}
return builder.String()
}
func CompareGolden(target string, got []byte) error {
want, err := os.ReadFile(target)
if err != nil {
return fmt.Errorf("read golden: %w", err)
}
if !bytes.Equal(bytes.TrimSpace(got), bytes.TrimSpace(want)) {
return fmt.Errorf("golden mismatch for %s\n%s", filepath.Base(target), diffLines(string(want), string(got)))
}
return nil
}
func main() {
dir, err := os.MkdirTemp("", "golden-*")
if err != nil {
panic(err)
}
defer os.RemoveAll(dir)
target := filepath.Join(dir, "report.golden")
report := []byte("users=3\norders=5\n")
if err := WriteGolden(target, report); err != nil {
panic(err)
}
if err := CompareGolden(target, report); err != nil {
fmt.Println("unexpected mismatch:", err)
} else {
fmt.Println("match: ok")
}
if err := CompareGolden(target, []byte("users=4\norders=5\n")); err != nil {
fmt.Println("mismatch detected:")
fmt.Println(err)
}
}