在十多年的开发生涯中,我见过太多因为测试困难而被放弃维护的项目。最典型的场景就是当一个Service直接调用DAO层操作数据库时,单元测试就变成了集成测试——你需要启动完整的数据库环境,准备测试数据,清理测试数据...整个过程缓慢而脆弱。
最近在重构一个电商促销系统时,我遇到了一个典型案例:StoreThemeConditionChecker。这个服务需要判断用户储值情况来决定是否展示营销弹窗。最初的实现直接调用了GORM模型查询数据库,导致每次测试都需要:
更糟糕的是,当CI环境中数据库连接出现波动时,明明业务逻辑正确的测试也会失败。这就是典型的"测试阻抗"现象——不是代码有问题,而是测试方式有问题。
让我们解剖原始实现的痛点(以Go为例):
go复制type StoreThemeConditionChecker struct {
// 直接依赖具体模型
orderModel *models.StoreOrders
}
func (c *StoreThemeConditionChecker) CheckCondition(ctx context.Context, req Request) (bool, error) {
// 直接查询数据库
orders, err := c.orderModel.ListByCustomer(req.CustomerID)
if err != nil {
return false, err
}
// 业务逻辑与数据访问混杂
if len(orders) == 0 {
return true, nil // 从未储值
}
balance := calculateBalance(orders)
return balance < req.Threshold, nil
}
这种写法存在三个致命问题:
根据《加速》一书中的研究数据:
首先我们需要识别核心领域行为。对于储值条件检查,本质需要两个能力:
go复制type StoreConditionChecker interface {
// 行为1:判断储值历史
HasNeverStored(ctx context.Context, customerId int64) (bool, error)
// 行为2:判断余额阈值
IsBalanceLessThan(ctx context.Context, customerId int64, threshold int64) (bool, error)
}
这个接口设计体现了几个关键点:
生产环境实现会真实访问数据库:
go复制type DBStoreChecker struct {
db *gorm.DB
}
func (c *DBStoreChecker) HasNeverStored(ctx context.Context, customerId int64) (bool, error) {
var count int64
err := c.db.Model(&models.StoreOrder{}).
Where("customer_id = ? AND status = ?", customerId, PaidStatus).
Count(&count).Error
return count == 0, err
}
注意这里仍然保持了单一职责——只做数据访问,不包含业务逻辑。
测试版本可以自由控制返回结果:
go复制type MockStoreChecker struct {
neverStored bool
balance int64
err error
}
func (m *MockStoreChecker) HasNeverStored(ctx context.Context, customerId int64) (bool, error) {
return m.neverStored, m.err
}
现在业务代码可以完全面向接口编程:
go复制type ThemeConditionService struct {
checker StoreConditionChecker
}
func (s *ThemeConditionService) ShouldShowPopup(ctx context.Context, req Request) (bool, error) {
switch req.ConditionType {
case NeverStored:
return s.checker.HasNeverStored(ctx, req.CustomerID)
case BalanceBelow:
return s.checker.IsBalanceLessThan(ctx, req.CustomerID, req.Threshold)
default:
return false, ErrInvalidCondition
}
}
go复制func TestShouldShowPopup_NeverStored(t *testing.T) {
// 准备
mockChecker := &MockStoreChecker{neverStored: true}
service := &ThemeConditionService{checker: mockChecker}
// 执行
result, err := service.ShouldShowPopup(context.Background(), Request{
CustomerID: 123,
ConditionType: NeverStored,
})
// 断言
assert.NoError(t, err)
assert.True(t, result)
}
| 指标 | 紧耦合方案 | 接口方案 |
|---|---|---|
| 单个测试耗时 | 300-500ms | 0.03-0.05ms |
| 异常场景覆盖率 | <20% | 100% |
| 测试稳定性 | 经常失败 | 稳定 |
| 环境依赖 | 需要DB | 无 |
StoreReader比MySQLStoreReader更好推荐使用构造函数注入:
go复制func NewThemeService(checker StoreConditionChecker) *ThemeConditionService {
return &ThemeConditionService{checker: checker}
}
避免使用全局变量或服务定位器模式。
go复制mockChecker.EXPECT().
HasNeverStored(gomock.Any(), 123).
Return(true, nil)
当领域模型需要调用服务时,可以使用接口拆分:
go复制// 在领域层定义
type NotificationService interface {
SendPromotionNotification(userID int64, message string) error
}
// 在应用层实现
type RealNotifier struct {
client *sms.Client
}
遵循接口隔离原则(ISP):
go复制// 错误:混杂的接口
type StoreService interface {
QueryOrders()
UpdateBalance()
GenerateReport()
}
// 正确:拆分接口
type OrderQuery interface {
QueryOrders()
}
type BalanceManager interface {
UpdateBalance()
}
避免Mock所有东西,对于:
在最近一个支付项目中,我们通过接口抽象实现了:
关键收获:
重要提示:不要为了测试而测试。接口设计的首要目标应该是良好的架构,可测试性只是副产品。如果发现自己在为一个简单类创建大量Mock,可能需要重新思考设计。