如何正确测试依赖其他方法调用的业务方法

本文详解如何通过依赖注入(di)解耦服务层与数据访问逻辑,结合 mockito 实现对 `create()` 等核心业务方法的可测性设计,覆盖异常路径与正常流程,并避免滥用 spy 或对被测类自身打桩。

在单元测试实践中,一个常见但棘手的问题是:如何干净、可靠地测试那些依赖内部方法调用(如校验、查询、转换)的业务方法? 尤其当这些依赖逻辑又与外部资源(如内存数据库)强耦合时,测试往往陷入“无法控制输入”或“不得不打桩被测类自身”的困境——这不仅违背测试隔离原则,也暴露了代码设计上的可测性缺陷。

根本解法不在测试技巧,而在重构设计:将隐式依赖(如 MemoryDatabase.getInstance())显式化为可注入的抽象依赖。观察原始代码:

@Service
public class EntityService implements FilteringInterface {
    private MemoryDatabase db = MemoryDatabase.getInstance(); // ❌ 静态单例 → 不可替换、不可模拟
    ...
}

该写法导致

db 成为硬编码实现,测试时既无法预置特定数据集,也无法验证 db.add() 是否被调用。正确做法是引入接口抽象并依赖注入:

// 1. 定义持久化接口(替代具体 MemoryDatabase)
public interface Persistence {
    Set getEntities();
    void add(Entity entity);
}

// 2. 修改服务类:通过构造器注入,而非静态获取
@Service
public class EntityService implements FilteringInterface {
    private final Persistence db; // ✅ final + 接口类型

    public EntityService(Persistence db) { // ✅ 构造器注入
        this.db = db;
    }

    public EntityDTO create(EntityDTO dto) throws Exception {
        validateUniqueFields(dto);
        Entity entity = Entity.toEntity(dto, "id1");
        db.add(entity); // 可验证行为
        return new EntityDTO.Builder(entity);
    }
    // ... 其余方法保持不变
}

如此重构后,测试即可完全掌控 Persistence 行为:

@ExtendWith(MockitoExtension.class)
class EntityServiceTest {

    @Mock
    private Persistence persistence;

    @InjectMocks
    private EntityService entityService;

    @Test
    void shouldThrowWhenNameAlreadyExists() {
        // 给定:DB 中已存在同名 Entity
        Entity existing = new Entity(1L, 1L, "conflict-name", "other-code");
        when(persistence.getEntities()).thenReturn(Set.of(existing));

        // 当:尝试创建同名 DTO
        EntityDTO dto = new EntityDTO(null, "conflict-name", "new-code");

        // 则:抛出异常
        assertThrows(Exception.class, () -> entityService.create(dto));
    }

    @Test
    void shouldPersistNewEntityOnSuccess() {
        // 给定:空 DB
        when(persistence.getEntities()).thenReturn(Set.of());

        // 当:创建唯一 DTO
        EntityDTO dto = new EntityDTO(null, "unique-name", "unique-code");
        entityService.create(dto);

        // 则:verify db.add() 被调用一次
        verify(persistence).add(any(Entity.class));
    }
}
? 关键设计原则:依赖抽象,而非实现:Persistence 接口隔离了数据访问细节,使 EntityService 仅关注业务规则。构造器注入优先:确保依赖不可变且显式,便于测试时精准替换。FilteringInterface 无需 Mock:其默认方法是纯函数式逻辑(无状态、无副作用),可直接调用;测试重点应放在 validateUniqueFields() 的结果(是否抛异常),而非内部如何过滤——这由 filterEntityByNameOrCode() 的单元测试单独覆盖。避免 @Spy 和 @Mock 被测类:它们破坏测试边界,易导致“测试自己而非行为”。

此外,针对 FilteringInterface 中的默认方法,建议为其单独编写工具类测试(不依赖 Spring 上下文),例如:

@Test
void filterByNameOrCode_returnsMatchingEntities() {
    Set list = Set.of(
        new Entity(1L, 1L, "a", "x"),
        new Entity(2L, 1L, "b", "y")
    );
    Set result = FilteringInterface.super.filterEntityByNameOrCode("a", "y", list);
    assertEquals(2, result.size()); // 匹配 name="a" 或 code="y"
}

总结:可测性是良好设计的副产品。当你发现某个方法难以测试时,优先审视其依赖是否可替换、职责是否单一、边界是否清晰。通过 DI 解耦持久层、聚焦接口契约、分离纯逻辑与副作用,不仅能写出高覆盖率的测试,更能构建出更健壮、可维护的系统架构。