← 中文资源

简体中文版本

测试替身(Test Doubles)

测试替身用哑元、桩、间谍、模拟对象或伪造对象替代真实协作者,让被测单元在没有缓慢或不可预测依赖的情况下运行。

阅读英文原文

五种角色

哑元(dummy)只占位而从不使用;桩(stub)返回预设答案;间谍(spy)记录调用;模拟对象(mock)预置期望并在被违反时失败;伪造对象(fake)是可工作的简化实现,例如内存仓库。名称不如意图重要:每个替身都替换真实协作者,让测试一次只控制一个变量。

状态断言与交互断言

优先断言可观察结果,因为它们能经受重构。只有当协作本身就是需求时才检查交互,例如“提交后只发布一条事件”。滥用模拟会让测试耦合内部结构:改一个私有方法名,几十个测试失败,而行为并未改变。

何时使用

时钟、随机数、网络客户端、支付网关和慢速存储适合使用替身;当风险位于集成本身时,例如 SQL 方言、序列化、事务边界和重试语义,应使用真实依赖。与全模拟的单元测试相比,连接真实数据库的集成测试更慢,但能发现另一类缺陷。

漂移问题与准则

每个伪造对象都编码了对真实系统的假设。真实依赖变化后,伪造对象仍然通过,信心就是假的。契约测试或少量真实集成测试能让替身保持诚实。应在架构边界而非小函数之间使用模拟,让替身保持最小,按模拟的行为命名,并优先断言结果。如果一个测试需要六个模拟对象,设计本身就在发出信号。

代码示例

package main

import (
	"errors"
	"testing"
)

var errNotFound = errors.New("order not found")

func ShippingCost(weightKg int, express bool, tier string) (int, error) {
	if weightKg <= 0 {
		return 0, errors.New("weight must be positive")
	}
	if weightKg > 30 {
		return 0, errors.New("weight exceeds courier limit")
	}

	base := 8
	if weightKg > 10 {
		base += (weightKg - 10) * 2
	}
	if express {
		base *= 2
	}
	if tier == "gold" {
		base -= 4
	}
	return base, nil
}

func TestShippingCost(t *testing.T) {
	t.Parallel()

	tests := []struct {
		name    string
		weight  int
		express bool
		tier    string
		want    int
		wantErr error
	}{
		{name: "light standard", weight: 2, express: false, tier: "", want: 8},
		{name: "light express", weight: 2, express: true, tier: "", want: 16},
		{name: "heavy adds per kg", weight: 15, express: false, tier: "", want: 18},
		{name: "heavy express gold", weight: 15, express: true, tier: "gold", want: 32},
		{name: "gold floor at zero", weight: 1, express: false, tier: "gold", want: 4},
		{name: "zero weight rejected", weight: 0, express: false, tier: "", wantErr: errors.New("weight must be positive")},
		{name: "over courier limit", weight: 31, express: false, tier: "", wantErr: errors.New("weight exceeds courier limit")},
	}

	for _, tt := range tests {
		tt := tt
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()

			got, err := ShippingCost(tt.weight, tt.express, tt.tier)
			if tt.wantErr != nil {
				if err == nil || err.Error() != tt.wantErr.Error() {
					t.Fatalf("ShippingCost(%d, %v, %q) error = %v, want %v",
						tt.weight, tt.express, tt.tier, err, tt.wantErr)
				}
				return
			}
			if err != nil {
				t.Fatalf("unexpected error: %v", err)
			}
			if got != tt.want {
				t.Errorf("ShippingCost(%d, %v, %q) = %d, want %d",
					tt.weight, tt.express, tt.tier, got, tt.want)
			}
		})
	}
}
import httpx
import pytest


def fetch_invoice(base_url: str, invoice_id: str) -> dict:
    with httpx.Client(base_url=base_url) as client:
        resp = client.get(f'/invoices/{invoice_id}')
        resp.raise_for_status()
        return resp.json()


def test_fetch_invoice_parses_payload():
    captured: dict = {}

    def handler(request: httpx.Request) -> httpx.Response:
        captured['url'] = str(request.url)
        captured['auth'] = request.headers.get('Authorization')
        return httpx.Response(200, json={'id': 'inv-1', 'total': 4200})

    client = httpx.Client(
        transport=httpx.MockTransport(handler),
        base_url='https://api.example.test',
        headers={'Authorization': 'Bearer test-token'},
    )

    with client:
        resp = client.get('/invoices/inv-1')
        data = resp.json()

    assert data['total'] == 4200
    assert captured['url'].endswith('/invoices/inv-1')
    assert captured['auth'] == 'Bearer test-token'


@pytest.mark.parametrize('status', [500, 502, 503])
def test_fetch_invoice_raises_on_server_error(status):
    def handler(request: httpx.Request) -> httpx.Response:
        return httpx.Response(status)

    client = httpx.Client(transport=httpx.MockTransport(handler))
    with pytest.raises(httpx.HTTPStatusError):
        client.get('https://api.example.test/invoices/inv-2')