Spring项目Mock测试太难?我靠这套Hexagonal架构优雅通关复杂业务

别再拿 Hexagonal Architecture 玩 DEMO 了:从真实项目 hexagonal-architecture-java 学会 Mock 的正确姿势

作者:Killian(重庆后端开发者)|架构落地实践篇|更新日期:2025-06-12 | 字数:5000字


一、前言:当 Hexagonal Architecture 遇上复杂业务系统

“六边形架构真好,一切皆接口、Mock无压力!”

这种说法我们听得太多,但很多后端工程师——包括我自己早期在用 Clean/Hexagonal 时都会碰到几个问题:

  • 用接口把 service 层包了一圈,测试写不下去
  • port 也写了、adapter 也封了,类却多了一倍
  • 最后测试还是没法做,mock 写了全是模拟表演

为什么会这样?是架构错了吗?不是。

问题出在:我们没有一个真正面向落地系统的参考项目。

直到我发现了 hexagonal-architecture-java 这个开源项目。

这篇文章将结合我过去在支付系统中踩过的坑,以及这个项目的结构,完整写出一篇超 5000 字的实战架构落地博客,告诉你:

Hexagonal Architecture 在真实工程中如何助力 Mock 测试、实现高可维护性与扩展性,而不是一个玩具结构。


二、项目背景:架构不是封装,而是行为边界

我们先来回顾真实项目中你一定遇到的场景:

  • 你在 Service 层里既写了业务流程,又调用 SDK、发消息、写日志、连 Redis
  • 你想写个测试,只想验证“支付成功时是否触发通知”
  • 结果测试前需要连接 MySQL、Mock MQ、预置数据、还担心 Redis key 过期

这是因为你的代码结构没有隔离行为边界

在 hexagonal-architecture-java 项目中,他们做得非常明确:

所有业务规则决策 + 外部服务依赖点,都抽象为 Port 接口,由 UseCase 使用,外部通过 Adapter 实现。

这就是 hexagonal 的核心。


三、项目结构说明(基于 hexagonal-architecture-java)

先看一下项目目录结构:

hexagonal-architecture-java/
├── model                   # 领域模型(不可依赖任何其他层)
├── application            # UseCase + Port(业务逻辑核心)
├── adapters/              # 适配器(实现 port,对接外部世界)
│   ├── in/                # 输入端(controller、cli、api)
│   └── out/               # 输出端(db、mq、http、redis)
└── bootstrap              # 启动器,装配 Adapter

关键抽象:

  • Port in(UseCase)CreateCartUseCase / AddItemToCartUseCase
  • Port outCartRepository / ProductInfoClient
  • AdapterCartRepositoryJpaAdapterProductInfoHttpClientAdapter

在启动时,我们根据环境(测试、生产)注入不同 Adapter,实现灵活 Mock 与真实连接的切换。


四、UseCase 设计:纯净、测试友好、业务聚焦

我们以“添加商品到购物车”为例。

public class AddItemToCartUseCaseImpl implements AddItemToCartUseCase {
    private final CartRepository cartRepository;
    private final ProductInfoClient productClient;

    public void addItem(AddItemCommand cmd) {
        Cart cart = cartRepository.findById(cmd.getCartId())
            .orElseThrow(() -> new IllegalArgumentException("cart not found"));

        ProductInfo info = productClient.getInfo(cmd.getProductId());
        cart.addItem(cmd.getProductId(), info.getPrice());

        cartRepository.save(cart);
    }
}

你会发现:

  • 所有对外依赖(查库、请求其他服务)都是接口
  • 所有行为路径都可被 Mock、可断言
  • 所有逻辑行为都在 UseCase 内,测试只关心它是否行为正确

五、Port & Adapter 模式:Mock 变得自然

1️⃣ Port 接口定义

public interface ProductInfoClient {
    ProductInfo getInfo(String productId);
}

public interface CartRepository {
    Optional<Cart> findById(CartId id);
    void save(Cart cart);
}

2️⃣ Adapter 示例:JPA 实现

@Repository
public class CartRepositoryJpaAdapter implements CartRepository {
    private final JpaCartRepository jpaRepo;

    public void save(Cart cart) {
        jpaRepo.save(mapper.toEntity(cart));
    }
    ...
}

在测试环境中,只需注入 InMemory 实现或者 Mock。


六、Mock 测试用例编写示范

我们来看完整测试类:

@ExtendWith(MockitoExtension.class)
class AddItemToCartUseCaseTest {

    @Mock CartRepository cartRepository;
    @Mock ProductInfoClient productClient;
    @InjectMocks AddItemToCartUseCaseImpl useCase;

    @Test
    void should_add_item_and_save() {
        Cart cart = new Cart(new CartId(UUID.randomUUID()));
        when(cartRepository.findById(any())).thenReturn(Optional.of(cart));
        when(productClient.getInfo(any())).thenReturn(new ProductInfo("p1", BigDecimal.TEN));

        useCase.addItem(new AddItemCommand(cart.getId(), "p1", 1));

        ArgumentCaptor<Cart> captor = ArgumentCaptor.forClass(Cart.class);
        verify(cartRepository).save(captor.capture());

        Cart saved = captor.getValue();
        assertEquals(1, saved.getItems().size());
        assertEquals(BigDecimal.TEN, saved.getItems().get(0).getPrice());
    }
}

这个测试的意义是:我们不在意 Redis、MQ、HTTP、数据库连接,只关心:

  • 有没有正确获取商品信息
  • 有没有正确添加商品
  • 有没有正确调用保存方法

真实项目中可以轻松验证逻辑边界。


七、我们项目里的实际应用(支付 + 风控)

参考 hexagonal-architecture-java,我重构了我们项目中的支付逻辑,提取出:

interface RiskChecker {
    boolean pass(Order order);
}

interface PaymentGateway {
    String prepay(Order order);
}

interface PaymentRepository {
    void save(Order, String payUrl);
}

interface PaymentNotifier {
    void notifySuccess(Order);
}

最终生成的 UseCase 如下:

public void pay(PayRequest request) {
    if (!riskChecker.pass(request)) throw new RiskRejectException();
    String url = gateway.prepay(request);
    repository.save(request, url);
    notifier.notifySuccess(request);
}

测试环境中可以:

  • Mock 风控通过/失败
  • Mock 支付网关异常/返回失败码
  • 验证是否调用 MQ/消息通知

八、深入思考:为什么这种结构在 Mock 上天然优势

结构特征 好处
UseCase 不依赖 Spring Bean 测试启动快、mock 容易
所有行为都经 Port 可模拟、可替代、可监控
Adapter 单一职责 便于 Mock/fake/集成替换
Bootstrap 分环境装配 测试、灰度、生产灵活切换

真正让 Mock 高效的不是“mock 框架”,而是代码结构为 Mock 做好准备


九、实战建议总结

✅ UseCase 不调用任何外部技术细节

✅ 所有跨服务/模块/系统交互都封在 Port

✅ 保留固定不变的技术调用为工具类(如 Redis)

✅ Adapter 实现逻辑简单,单测或集成测

✅ 所有 Mock 行为应是“判断条件”、“响应生成”、“行为触发”


项目地址与进阶阅读

hexagonal-architecture-java:https://github.com/saw303/hexagonal-architecture-java

推荐同步阅读:

  • Sven Woltmann 的系列教程《Hexagonal Architecture in Java》
  • 阿里 P8 架构师对 Clean Architecture 的落地讲解(对比阅读)

十一、总结一句话

你不是不会写测试,你的架构不让你写。

Hexagonal Architecture 的意义,从来不是“听起来解耦”,而是让测试回归理性、让逻辑清晰独立、让协作高效。

Mock 不应该是框架的战斗,而应该是结构的胜利。


作者:Killian,重庆后端开发者,热衷探索真实系统中的可维护性、可测试性架构设计。欢迎技术Leader和猎头朋友联系交流。

本文由 @killian 原创,转载请注明出处。
☕ 请作者喝杯咖啡,持续更新更深入的干货

彩蛋时间:如果你看到了这里,说明你是那种喜欢动手实战的人。那我悄悄分享一个开发圈流传的工具试用入口,貌似跟高效调试很有关系,地址也挺特别的:

入口

据说注册还能解锁一些隐藏功能,懂的都懂(别外传 )

你可能感兴趣的:(程序员的思维乐园,spring,架构,java)