如何编写易于但愿测试的代码

简介

在开发过程中应用单元测试保证质量已经有几年时间,期间体会到单元测试的收益还是很愉悦的,近期大团队开始对单测进行强行要求,借此机会也想分享一些我对单测的一些经验。

本篇文章作为开篇,并不讲述单测本身,而是从代码开发的角度阐述什么样的代码易于单元测试,如今AIGC火热的背景下,如果编写结构清晰,模块划分合理,可读性良好的代码,也能使得自动生成的单元测试拥有较高的质量,从而达到低投入高收益。

当然,据说真男人从来不搞什么单元测试。

从易于单元测试角度划分代码类型

通常可以从两个维度来衡量代码的重要程度:

  • 代码的复杂度与领域相关性
  • 代码的合作方数量

代码复杂度与领域相关性维度

代码复杂度

代码复杂度通常以圈复杂度(cyclomatic complexity)计算,意味着一个函数或程序中唯一路径的数量,过大的函数或过多的判断分支通常意味着代码有较高的圈复杂度,比如我们看一段下面的例子:

public static List<String> getServiceChecklistItems(String vehicleType) throws AutoServiceException {
    if (VehicleSupport.isSedan(vehicleType)) {
        return new ArrayList<>(SERVICE_CHECKLIST_SEDAN);
    } else if (VehicleSupport.isSUV(vehicleType)) {
        return new ArrayList<>(SERVICE_CHECKLIST_SUV);
    } else if (VehicleSupport.isTruck(vehicleType)) {
        return new ArrayList<>(SERVICE_CHECKLIST_TRUCK);
    } else if (VehicleSupport.isHybrid(vehicleType)) {
        return new ArrayList<>(SERVICE_CHECKLIST_HYBRID);
    } else if (VehicleSupport.isElectric(vehicleType)) {
        return new ArrayList<>(SERVICE_CHECKLIST_ELECTRIC);
    } else if (VehicleSupport.isSportsCar(vehicleType)) {
        return new ArrayList<>(SERVICE_CHECKLIST_SPORTS);
    }
    throw new AutoServiceException(ErrorCode.UNSUPPORTED_VEHICLE_TYPE);
}

这段代码if-else有6中路径,加上最后的Exception,有7段路径,因此圈复杂度为7。

领域相关性

领域相关性通常与所需解决问题域的重要程度有关,这部分代码与系统服务用户的目标相关性越近,则领域相关性越高。

高领域相关性并不一定代表存在较高的复杂度。比如下面的例子,一个用于根据ECS存储类型计算IOPS的函数:

private int getIOPSBaseOnStorageInfo(Integer dbInstanceStorageInGB, String dataDiskCategory) {
    final int IOPS_BASE = 1800;
    
    final String CLOUD_ESSD_PL0 = "cloud_essd_pl0";
    final String CLOUD_ESSD = "cloud_essd_pl1";
    final String CLOUD_ESSD_PL2 = "cloud_essd_pl2";
    final String CLOUD_ESSD_PL3 = "cloud_essd_pl3";
    final String CLOUD_EFFICIENCY = "cloud_efficiency";
    
    final int[] CLOUD_ESSD_PL0_CONFIG = {12, 10000};
    final int[] CLOUD_ESSD_CONFIG = {50, 50000};
    final int[] CLOUD_ESSD_PL2_CONFIG = {50, 100000};
    final int[] CLOUD_ESSD_PL3_CONFIG = {50, 1000000};
    final int[] CLOUD_EFFICIENCY_CONFIG = {8, 5000};
    final int[] DEFAULT_IOPS_CONFIG = {30, 25000};
    
    // 创建IOPS配置映射
    Map<String, int[]> iopsConfig = new HashMap<>();
    iopsConfig.put(CLOUD_ESSD_PL0, CLOUD_ESSD_PL0_CONFIG);
    iopsConfig.put(CLOUD_ESSD, CLOUD_ESSD_CONFIG);
    iopsConfig.put(CLOUD_ESSD_PL2, CLOUD_ESSD_PL2_CONFIG);
    iopsConfig.put(CLOUD_ESSD_PL3, CLOUD_ESSD_PL3_CONFIG);
    iopsConfig.put(CLOUD_EFFICIENCY, CLOUD_EFFICIENCY_CONFIG);
    
    // 根据数据盘类别获取配置
    int[] config = iopsConfig.getOrDefault(dataDiskCategory.toLowerCase(), DEFAULT_IOPS_CONFIG);
    int scaleFactor = config[0];
    int maxLimit = config[1];

    int calculatedIOPS = Math.min(IOPS_BASE + scaleFactor * dbInstanceStorageInGB, maxLimit);

    return calculatedIOPS;

}

可以看到该函数没有任何代码分支路径,所以圈复杂度为1,但该代码会根据存储类型与大小计算IOPS,直接呈现给最终用户,因此该函数拥有极高的领域相关性,但复杂度仅为1。

合作方数量维度

该维度取决于类或方法的中涉及的合作方数量。这些合作方可以是:

  • 其他的类,或外部类库
  • 对于外部API的访问(例如阿里云公开的OpenAPI,其他内部微服务的API等)
  • 对于数据库的访问
  • 文件系统
  • 等等...

代码中包含大量的合作方通常难以测试,例如下面一个例子:

public void review(String applicationId) {
    // 1. 从外部API获取用户信息
    UserInfo userInfo = externalAPIService.getUserInfo(applicationId);

    // 2. 从数据库获取订单信息
    OrderInfo orderInfo = databaseService.getOrderInfo(applicationId);

    // 3. 使用规则引擎进行风控审核
    ReviewResult reviewResult = ruleEngineService.review(userInfo, orderInfo);

    // 4. 根据审核结果发送通知
    if (reviewResult.isApproved()) {
        notificationService.sendApprovalNotification(applicationId);
    } else {
        notificationService.sendRejectionNotification(applicationId, reviewResult.getRejectionReason());
    }
}

Review函数用于进行风控业务的review,可以看到每个步骤都有外部依赖,分别为:

  • 获取用户信息来自于外部API
  • 获取订单信息依赖内部数据库
  • 获取风控审核依赖于内部服务
  • 发送审核结果依赖通知服务

因此对于该函数的测试,需要详细的Mock这四类服务对应函数的行为特征,最简单的行为特征如下:

    @Mock
    private ExternalAPIService externalAPIService;
    @Mock
    private DatabaseService databaseService;
    @Mock
    private RuleEngineService ruleEngineService;
    @Mock
    private NotificationService notificationService;

    @Test
    void testReview_Approved() {
        // Arrange
        String applicationId = "APP123";
        UserInfo userInfo = new UserInfo("Yunji", "yunji@xxx.com");
        OrderInfo orderInfo = new OrderInfo(1000.0, "Product A");
        ReviewResult reviewResult = new ReviewResult(true, null);

        when(externalAPIService.getUserInfo(applicationId)).thenReturn(userInfo);
        when(databaseService.getOrderInfo(applicationId)).thenReturn(orderInfo);
        when(ruleEngineService.review(userInfo, orderInfo)).thenReturn(reviewResult);

      //省略....
    }

如果我们想对review进行单元测试,则需要Mock这几方依赖,这意味着即使只是想测试一个简单的用户操作,也需要mock订单相关的依赖,无疑给测试带来很多负担,同时会导致单元测试更加没有意义。

应该对上述哪些代码进行单元测试

并不是所有代码都值得平等对待。边缘代码出错可能影响有限,而核心代码出错可能导致故障或资损。只有对最核心的代码进行单元测试,才能事半功倍。

通过下图可以看出,按照上述两个维度,可以将代码分为4个象限。

域模型、算法

拥有较高领域相关性和复杂度的代码,却有最小的合作方数量的代码是应用单元测试的最佳代码,由于低合作方数量,编写单元测试对该类代码进行测试通常成本低廉(不需要或只需要较少的Mock),该类代码的正确性又极为重要,因此拥有最佳的单元测试投资回报率。

例如上述"领域相关性小结"提到的示例代码。

简单代码

这就是所谓的边缘代码,既没有复杂度,又没有领域相关度,同时合作方数量很小,这类代码通常没有单元测试价值。一些例子例如构造函数,做类的初始化赋值,或一些简单的set方法。

Controller(流程控制代码)

这类代码通常本身不做工作,只是简单的请求转发或协调,没有复杂的业务逻辑,业务逻辑基本上是调用不同组件来处理请求。通常来讲,该类单元测试重点需要测试输入值的有效性和规则,避免进行无效转发。

过度复杂的代码

该类代码是真正的问题代码,该类代码有较多的外部依赖,同时又有较高的复杂度与领域相关性,例如"合作方数量维度"中提到的示例代码,该类代码如果不进行单元测试较为危险,无异于裸奔,但对该类代码进行单元测试成本过于高昂,因此写代码时,要极力避免编写该类代码。

因此,一个总体原则应该是,避免过度复杂的代码,如果看到一个函数和类同时具备高复杂度或领域相关度的,又同时拥有大量合作方,就应该引起警觉,将其拆分为"域模型或算法"+"Controller",从而更容易实现单元测试。

如何避免过度复杂的代码

该问题如果深入探讨,涉及的范围会非常广泛,从SOLID原则到领域抽象、领域驱动设计(DDD)等都可能会涉及。考虑到篇幅限制,本小节仅聚焦于一个核心问题:如何将"过度复杂的代码"这一象限中的代码进行拆分,使其更易于测试和维护。我们将通过一个具体的示例来阐述这一过程。

拆分原则通常如下图所示

而具体的拆分原则如下:

  1. 识别核心业务逻辑:将具有高领域相关性和复杂度的部分提取出来,形成独立的域模型或服务。
  2. 剥离外部依赖:将外部依赖抽象为接口
  3. 分离流程控制:将协调各个步骤的逻辑抽取为Controller,负责调用各个服务和处理结果。

下面,我们将通过一个具体的示例来展示如何应用这些原则,将一个过于复杂的代码重构为更易于测试和维护的结构。这个示例将展示如何将复杂代码进行拆分,核心部分拆分为Controller和域模型,并剥离外部调用,从而更易于测试与维护。

###示例代码:订单处理函数###

public class OrderProcessor {
    public void processOrder(String orderId, String customerEmail) {
        // 从数据库获取订单信息
        Object[] orderData = Database.getOrderById(orderId);
        double orderAmount = (double) orderData[0];
        String orderStatus = (String) orderData[1];

        // 检查订单状态
        if (!"PENDING".equals(orderStatus)) {
            return;
        }

        // 获取客户信息
        Object[] customerData = Database.getCustomerByEmail(customerEmail);
        String customerName = (String) customerData[0];
        String customerType = (String) customerData[1];

        // 计算折扣
        double discount = 0;
        if ("VIP".equals(customerType)) {
            discount = 0.1;
        } else if ("REGULAR".equals(customerType)) {
            discount = 0.05;
        }

        // 应用折扣
        double finalAmount = orderAmount * (1 - discount);

        // 检查库存
        boolean isInStock = InventorySystem.checkStock(orderId);
        if (!isInStock) {
            EmailService.sendOutOfStockNotification(customerEmail);
            return;
        }

        // 处理支付
        boolean paymentSuccess = PaymentGateway.processPayment(orderId, finalAmount);
        if (!paymentSuccess) {
            EmailService.sendPaymentFailureNotification(customerEmail);
            return;
        }

        // 更新订单状态
        Database.updateOrderStatus(orderId, "PROCESSED");

        // 发送确认邮件
        String emailContent = "Dear " + customerName + ", your order " + orderId + " has been processed. Total amount: $" + finalAmount;
        EmailService.sendEmail(customerEmail, "Order Processed", emailContent);

        // 更新客户积分
        int points = (int) finalAmount;
        Database.updateCustomerPoints(customerEmail, points);

        // 触发配送流程
        ShippingService.initiateShipping(orderId);
    }
}

该代码乍一看没有问题,命名规范,分段清晰,但实际违反了SOLID原则的每一条,同时如果希望对该代码进行单元测试则是灾难,例如,我仅仅想测试订单状态是"pending"时不进行处理,就需要mock所有的信息才能让单元测试正常执行,这不仅导致单元测试困难,同时会导致单元测试的"脆弱性"(所谓脆弱性简单解释就是测试容易因为被测试代码的微小改变而失效,例如上述代码中,如果增加一个折扣策略,会导致针对订单状态的测试失败,而折扣策略与订单状态不应该与此相关)。

    @Mock
    private Database database;
    
    @Mock
    private InventorySystem inventorySystem;
    
    @Mock
    private PaymentGateway paymentGateway;
    
    @Mock
    private EmailService emailService;
    
    @Mock
    private ShippingService shippingService;

    @InjectMocks
    private OrderProcessor orderProcessor;

拆分步骤1:识别实体类(或领域模型)

还记得前面提到,哪部分代码具有单元测试的最佳投资回报率吗?领域模型通常是首选目标。。

从processOrder函数的入参与该函数的流程来看,整个过程会涉及到两部分主体,分别为Customer与Order,因此,我们首相提取这两个实体类,如代码所示:

class Order {
        private String id;
        private double amount;
        private String status;
        private OrderRepository repository;

        // 省略与processOrder 方法无关的内容

        public double getAmount(){
            return amount;
        }

        public String getId(){
            return id;
        }

        public boolean isPending() {
            return "PENDING".equals(status);
        }

        public void markAsProcessed() {
            this.status = "PROCESSED";
            repository.save(this);
        }
    }

    class Customer {
        private String email;
        private String name;
        private String type;
        private int points;
        private CustomerRepository repository;

        // 省略与processOrder 方法无关的内容

        public String getCustomerType(){
            return type;
        }

        public void addPoints(int points) {
            this.points += points;
            repository.save(this);
        }
    }

    interface OrderRepository {
        void save(Order order);
    }

    interface CustomerRepository {
        void save(Customer customer);
    }

我们不仅定义了Order与Customer的属性,同时将与实体对象相关的行为内聚到实例类中,这也是所谓的"充血模型"(Rich Domain Model)。同时,使用数据访问的Repository模式将数据的访问逻辑从业务逻辑中分离出来,从而能够更加容易单元测试。比如Order实体通过对orderRepository的Mock,很容易剥离数据访问逻辑,仅测试业务逻辑,单元测试代码如下:

public class OrderTest {
    private OrderRepository orderRepository;
    private Order order;

    @BeforeEach
    public void setUp() {
        orderRepository = mock(OrderRepository.class);
        order = new Order("123", 100.0, "PENDING", orderRepository);
    }

    @Test
    public void testIsPending_shouldReturnTrue_whenOrderStatusIsPending() {
        assertTrue(order.isPending());
    }

    @Test
    public void testMarkAsProcessed_shouldUpdateStatusAndSave_whenCalled() {
        order.markAsProcessed();
        assertEquals("PROCESSED", order.getStatus());
        verify(orderRepository, times(1)).save(order);
    }
}

Domain Model的类图如下:

拆分步骤2:剥离外部依赖为接口

部分调用涉及外部接口,我们可以将其抽象为接口,将外部服务抽象为类的接口可以极大简化单元测试,示例代码中涉及的接口主要有4个

    public interface PaymentGateway {
        boolean processPayment(String orderId, double amount);
    }

    public interface InventorySystem {
        boolean checkStock(String orderId);
    }

    public interface ShippingService {
        void initiateShipping(String orderId);
    }

    interface NotificationService {
            void sendOutOfStockNotification(Customer customer);
            void sendPaymentFailureNotification(Customer customer);
            void sendOrderConfirmation(Customer customer, Order order, double finalAmount);
        }

    class EmailNotificationService implements NotificationService {
        public void sendOutOfStockNotification(Customer customer) {
            // 省略实现细节
        }

        public void sendPaymentFailureNotification(Customer customer) {
            // 省略实现细节
        }

        public void sendOrderConfirmation(Customer customer, Order order, double finalAmount) {
            // 省略实现细节
        }
    }

这些接口由于实现在其他内部或外部服务,因此当前组件中我们并没有办法对其进行"接口实现类"的单元测试,而只能对其进行"接口使用者"的单元测试,而这部分单元测试不应该在该层实现,而应该在使用者一层进行测试,也就是下一小节提到的服务层。

通过接口可以非常容易在"接口使用者"层面进行Mock。

因此这部分代码可以归结到简单代码部分,无需进行"接口实现类"的单元测试。

拆分步骤3:实现服务层

订单服务需要涉及多个领域对象的协作。包括通知、支付网关、库存等。服务层提供了一个合适的地方来协调这些操作,而不是将这种复杂的逻辑放在订单领域自身中。

因此将订单服务单独抽离出来,作为服务层。

订单服务层

public class OrderService {
        private final InventorySystem inventorySystem;
        private final PaymentGateway paymentGateway;
        private final NotificationService notificationService;

        public OrderService(InventorySystem inventorySystem,
                                    PaymentGateway paymentGateway,
                                    NotificationService notificationService) {
            this.inventorySystem = inventorySystem;
            this.paymentGateway = paymentGateway;
            this.notificationService = notificationService;
        }

        public boolean finishOrder(Order order, Customer customer, double amount) {
            order.markAsProcessed();
            notificationService.sendOrderConfirmation(customer, order, amount);
        }

        public boolean checkInventoryAndNotify(Order order, Customer customer) {
            if (!inventorySystem.checkStock(order.getId())) {
                notificationService.sendOutOfStockNotification(customer);
                return false;
            }
            return true;
        }

        public boolean processPaymentAndNotify(Order order, Customer customer, double amount) {
            if (!paymentGateway.processPayment(order.getId(), amount)) {
                notificationService.sendPaymentFailureNotification(customer);
                return false;
            }
            return true;
        }
    }

这样我们可以重点测试服务层某个功能,其他无关功能通过Mock接口方式实现

public class OrderServiceTest {

    @Mock
    private InventorySystem inventorySystem;

    @Mock
    private PaymentGateway paymentGateway;

    @Mock
    private NotificationService notificationService;

    @Mock
    private Order order;

    @Mock
    private Customer customer;

    private OrderService orderService;

    @BeforeEach
    void setUp() {
        MockitoAnnotations.openMocks(this);
        orderService = new OrderService(inventorySystem, paymentGateway, notificationService);
    }

    @Test
    void testFinishOrder_shouldMarkAsProcessedAndSendConfirmation_whenCalled() {
        double amount = 100.0;

        orderService.finishOrder(order, customer, amount);

        verify(order).markAsProcessed();
        verify(notificationService).sendOrderConfirmation(customer, order, amount);
    }

    @Test
    void testCheckInventoryAndNotify_shouldReturnTrue_whenStockAvailable() {
        when(inventorySystem.checkStock(anyString())).thenReturn(true);
        when(order.getId()).thenReturn("123");

        boolean result = orderService.checkInventoryAndNotify(order, customer);

        assertTrue(result);
        verify(inventorySystem).checkStock("123");
        verify(notificationService, never()).sendOutOfStockNotification(any());
    }

    @Test
    void testCheckInventoryAndNotify_shouldReturnFalseAndNotify_whenStockUnavailable() {
        when(inventorySystem.checkStock(anyString())).thenReturn(false);
        when(order.getId()).thenReturn("123");

        boolean result = orderService.checkInventoryAndNotify(order, customer);

        assertFalse(result);
        verify(inventorySystem).checkStock("123");
        verify(notificationService).sendOutOfStockNotification(customer);
    }
}

折扣计算服务层

该层并没有下层依赖,将该类放到服务层的原因是该类可能会被多个Domain对象共享,而折扣是非常核心的业务逻辑,针对该部分逻辑的所有分支进行全面覆盖非常重要。

class DiscountPolicy {
        public double calculateFinalAmount(Order order, Customer customer) {
            return order.getAmount() * (1 - getDiscountRate(customer.getCustomerType()));
        }
        private double getDiscountRate(String customerType) {
            switch (customerType) {
                case "VIP":
                    return 0.1;
                case "REGULAR":
                    return 0.05;
                default:
                    return 0;
            }
        }
    }

该类对应的单元测试:

class DiscountPolicyTest {

    @Mock
    private Order order;

    @Mock
    private Customer customer;

    private DiscountPolicy discountPolicy;

    @BeforeEach
    void setUp() {
        MockitoAnnotations.openMocks(this);
        discountPolicy = new DiscountPolicy();
    }

    @Test
    void testCalculateFinalAmount_shouldApplyVipDiscount_whenCustomerIsVip() {
        when(order.getAmount()).thenReturn(100.0);
        when(customer.getCustomerType()).thenReturn("VIP");

        double finalAmount = discountPolicy.calculateFinalAmount(order, customer);

        assertEquals(90.0, finalAmount, 0.001);
    }

    @Test
    void testCalculateFinalAmount_shouldApplyRegularDiscount_whenCustomerIsRegular() {
        when(order.getAmount()).thenReturn(100.0);
        when(customer.getCustomerType()).thenReturn("REGULAR");

        double finalAmount = discountPolicy.calculateFinalAmount(order, customer);

        assertEquals(95.0, finalAmount, 0.001);
    }

    @Test
    void testCalculateFinalAmount_shouldApplyNoDiscount_whenCustomerTypeIsUnknown() {
        when(order.getAmount()).thenReturn(100.0);
        when(customer.getCustomerType()).thenReturn("UNKNOWN");

        double finalAmount = discountPolicy.calculateFinalAmount(order, customer);

        assertEquals(100.0, finalAmount, 0.001);
    }
}

拆分步骤4: 使用拆分后的类重构OrderProcessor

public class OrderProcessor {
        private final ShippingService shippingService;
        private final DiscountPolicy discountPolicy;
        private final OrderService orderService;

        public OrderProcessor(OrderService orderService,
                              ShippingService shippingService,
                              DiscountPolicy discountPolicy) {
            this.orderService = orderService;
            this.shippingService = shippingService;
            this.discountPolicy = discountPolicy;
        }

        public void processOrder(Order order, Customer customer) {
            if (!order.isPending()) {
                return;
            }

            double finalAmount = discountPolicy.calculateFinalAmount(order, customer);

            if (!orderService.checkInventoryAndNotify(order, customer)) {
                return;
            }

            if (!orderService.processPaymentAndNotify(order, customer, finalAmount)) {
                return;
            }

            orderService.finishOrder(order, customer, finalAmount);

            customer.addPoints((int) finalAmount);
            shippingService.initiateShipping(order.getId());
        }
    }

拆分结果

至此,我们就完成了全部的拆分:

  1. Order Customer 充血模型实体类,OrderService DiscountPlicy服务层属于代码复杂度与领域相关度较高,合作方较低的类,拥有最好的单元测试投资回报率。
  2. OrderProcessor变为流程控代码
  3. 外部接口类InventorySystem、PaymentGateway等属于简单代码,不对接口实现进行测试,仅对服务层的接口使用进行单元测试。

拆分示意图如下:

将类做简单的分层,示意如下:

通过这个示例,我们可以看到如何将一个复杂的、难以测试的函数转变为一个结构清晰、职责分明、易于测试的代码。这种重构不仅提高了代码的可测试性,还增强了系统的可维护性和可扩展性。

小结

编写易于单元测试的代码需要一定设计,需要从代码的复杂度、领域相关性以及合作方数量等多个维度进行考量。高领域相关性和复杂度的代码具有较高的单元测试投资回报率,而复杂且有大量外部依赖的代码在开发过程中要有意识的避免。