概念定义
每个测试回答两个问题:执行多少、替换多少。单元边界就是这两者的分界线。画紧了,测试快而准但耦合结构;画宽了,测试扛得住重构但失败离根因更远。
工作机制
孤立式用替身替换协作者:数据库、队列、时钟;断言只打交互与单元自身状态。协作式保留进程内协作者为真,只替换真边界——时间、随机性、I/O。混合套件把算法放孤立式、编排放协作式。
使用场景
重构频繁的领域适合活得比结构久的协作式;契约密集领域适合钉住接口两侧的孤立式。看反馈回路:慢测试与噪声失败说明边界放错了层。
常见误区
“单元等于单个类隔离”只是学派之一:底特律学派带真实协作者跑集群,同样是单元测试,只是边界不同。全项目统一边界不如按模块选择——算法要窄、编排要宽。重 mock 的测试验证的是调用不是结果:真实协作者出错时它照样绿;协作式则放大爆炸半径——一个构造函数 bug 染红数百条。边界决策会老化:模块从算法形态漂向编排形态,出生时定的边界成年后就不再合身。
代码示例
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')
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)
}
})
}
}