国家高新技术企业
服务热线:400-6688-605
为什么国内程序员不喜欢写单元测试?
发布来源:大鹏网络
发布时间:2026-08-31 15:09

很多人说国内程序员不重视质量、不专业、急功近利。这是扯淡。真实原因很简单,大部分场景下,单元测试的投入产出比太低。

单元测试的真实成本

一个简单的业务方法,写单元测试要花多少时间?

public OrderVO createOrder(Long userId, Long productId, Integer quantity) {
    // 1. 查用户信息
    User user = userService.getById(userId);
    if (user == null) {
        throw new BizException("用户不存在");
    }
    
    // 2. 查商品信息
    Product product = productService.getById(productId);
    if (product == null || product.getStock() < quantity) {
        throw new BizException("商品库存不足");
    }
    
    // 3. 扣减库存
    productService.decreaseStock(productId, quantity);
    
    // 4. 创建订单
    Order order = new Order();
    order.setUserId(userId);
    order.setProductId(productId);
    order.setQuantity(quantity);
    order.setAmount(product.getPrice().multiply(new BigDecimal(quantity)));
    orderMapper.insert(order);
    
    // 5. 发送消息
    orderMessageProducer.send(order.getId());
    
    return OrderConverter.toVO(order);
}

这个方法的业务逻辑:查用户、查商品、扣库存、写数据库、发消息。很典型的增删改查代码。

写单元测试需要 Mock 5 个依赖:userServiceproductServiceorderMapperorderMessageProducerOrderConverter

@Test
public void testCreateOrder() {
    // Mock userService
    User user = new User();
    user.setId(1L);
    when(userService.getById(1L)).thenReturn(user);
    
    // Mock productService
    Product product = new Product();
    product.setId(100L);
    product.setPrice(new BigDecimal("99.00"));
    product.setStock(10);
    when(productService.getById(100L)).thenReturn(product);
    
    // Mock productService.decreaseStock
    doNothing().when(productService).decreaseStock(100L, 2);
    
    // Mock orderMapper.insert
    doAnswer(invocation -> {
        Order order = invocation.getArgument(0);
        order.setId(1000L);
        return 1;
    }).when(orderMapper).insert(any(Order.class));
    
    // Mock orderMessageProducer
    doNothing().when(orderMessageProducer).send(anyLong());
    
    // 执行
    OrderVO result = orderService.createOrder(1L, 100L, 2);
    
    // 验证
    assertNotNull(result);
    assertEquals(1000L, result.getId());
    verify(productService).decreaseStock(100L, 2);
    verify(orderMessageProducer).send(1000L);
}

业务代码20行,测试代码35行。业务逻辑改一个字段,测试代码要改5处。

这个测试覆盖了什么?

覆盖了正常流程。但这个流程有什么技术难点吗?没有。唯一的复杂度在于:各个服务之间的交互。而这恰恰是单元测试最不擅长的。

这个测试能发现什么 bug?

几乎发现不了。因为所有依赖都是 Mock 的,不是真实调用。真实环境里,userService 可能返回 null,productService.decreaseStock 可能抛异常,orderMapper.insert 可能失败,消息可能发不出去。但单元测试里这些都是假的。

你花了半小时写测试,结果这个测试只能验证:”当所有依赖都按预期工作时,这个方法能正常执行。” 这不是废话吗?

你 Mock 掉的,恰好是最容易出 bug 的

单元测试的理想状态是:只测试当前方法的逻辑,把外部依赖全部 Mock 掉。

但问题是:大部分业务代码的”逻辑”就是”调用外部依赖”。

看这段代码:

public void refundOrder(Long orderId) {
    Order order = orderMapper.selectById(orderId);
    if (order.getStatus() != OrderStatus.PAID) {
        throw new BizException("订单状态不允许退款");
    }
    
    RefundRequest request = new RefundRequest();
    request.setOrderId(orderId);
    request.setAmount(order.getAmount());
    
    RefundResponse response = paymentService.refund(request);
    if (!response.isSuccess()) {
        throw new BizException("退款失败:" + response.getMessage());
    }
    
    order.setStatus(OrderStatus.REFUNDED);
    orderMapper.updateById(order);
    
    orderMessageProducer.send(orderId);
}

这段代码的”逻辑”是什么?就是调用 paymentService.refund,然后更新订单状态。

如果你把 paymentService Mock 掉,让它永远返回成功,那你测试的是什么?测试的是”当支付接口返回成功时,订单状态能正确更新”。

但真实场景里,paymentService.refund 可能网络超时、可能返回失败、可能直接抛异常,甚至可能出现钱退了但接口返回失败的情况。

这些情况,单元测试都测不出来。因为你 Mock 了。

所以单元测试能测的,只是”胶水代码的胶水逻辑”。而真正容易出 bug 的地方——外部依赖的异常处理、事务的边界、并发的竞态条件——单元测试统统测不到。

什么代码值得写单元测试

不是所有代码都值得写单元测试。判断标准只有一个:逻辑复杂度高不高?

逻辑复杂度高的代码,单元测试的投入产出比最高。

工具类、算法类

public class PriceCalculator {
    public BigDecimal calculate(Order order, List coupons) {
        BigDecimal total = order.getAmount();
        
        // 按优先级排序优惠券
        coupons.sort(Comparator.comparing(Coupon::getPriority));
        
        for (Coupon coupon : coupons) {
            if (coupon.getType() == CouponType.PERCENT) {
                // 百分比折扣
                BigDecimal discount = total.multiply(coupon.getPercent())
                    .divide(new BigDecimal("100"), 2, RoundingMode.HALF_UP);
                total = total.subtract(discount);
            } else if (coupon.getType() == CouponType.FIXED) {
                // 固定金额减免
                total = total.subtract(coupon.getAmount());
            } else if (coupon.getType() == CouponType.THRESHOLD) {
                // 满减
                if (total.compareTo(coupon.getThreshold()) >= 0) {
                    total = total.subtract(coupon.getAmount());
                }
            }
            
            // 最低不能低于0.01
            if (total.compareTo(new BigDecimal("0.01")) < 0) {
                total = new BigDecimal("0.01");
            }
        }
        
        return total;
    }
}

这段代码没有外部依赖,纯计算逻辑。各种优惠券组合、边界条件(价格低于0.01)、精度处理。这种代码非常适合单元测试。

一个测试能覆盖一种优惠券组合,10个测试能覆盖各种边界情况。每次改代码,跑一遍测试,立刻知道有没有改坏。

核心领域模型

public class Order {
    private OrderStatus status;
    
    public void pay() {
        if (status != OrderStatus.PENDING) {
            throw new IllegalStateException("只有待支付订单可以支付");
        }
        this.status = OrderStatus.PAID;
    }
    
    public void cancel() {
        if (status == OrderStatus.PAID) {
            throw new IllegalStateException("已支付订单不能取消");
        }
        if (status == OrderStatus.SHIPPED) {
            throw new IllegalStateException("已发货订单不能取消");
        }
        this.status = OrderStatus.CANCELLED;
    }
    
    public void ship() {
        if (status != OrderStatus.PAID) {
            throw new IllegalStateException("只有已支付订单可以发货");
        }
        this.status = OrderStatus.SHIPPED;
    }
}

这是领域模型的状态机。状态流转的规则是业务核心逻辑,必须保证正确。写单元测试,覆盖所有状态流转的路径和异常情况。

但 CRUD 代码就完全不值得费这个劲。

public UserVO getUserById(Long userId) {
    User user = userMapper.selectById(userId);
    if (user == null) {
        throw new BizException("用户不存在");
    }
    return UserConverter.toVO(user);
}

查数据库,转VO,抛异常。没有任何逻辑。写单元测试要 Mock userMapper,Mock UserConverter,验证调用了一次 selectById。写完了能验证什么?验证你确实调用了一次 selectById。这有什么意义?

胶水代码也一样。

public void syncUserToES(Long userId) {
    User user = userService.getById(userId);
    UserDocument doc = UserDocumentConverter.convert(user);
    esTemplate.save(doc);
}

这段代码就是把数据从 MySQL 同步到 Elasticsearch。没有任何业务逻辑。写单元测试要 Mock 3个依赖,验证调用了 save。完全没必要。

这种代码,集成测试比单元测试有用得多。真实调用 MySQL 和 ES,验证数据能不能正确同步。

别再拿 Google 的测试文化说事了

很多人搬出 Google、微软的测试实践来反驳:人家大厂都写单元测试,你凭什么说不值得?

但 Google 的核心代码是什么?搜索引擎、编译器、分布式存储、TensorFlow。这些代码的特点是:逻辑极度复杂、算法密集、没有外部 IO 依赖、一个 bug 可能影响全球用户。这种代码天然适合单元测试,投入产出比极高。

国内大部分程序员写的是什么?电商系统、管理后台、业务中台。80% 的代码是查数据库、调接口、组装 DTO、返回 JSON。拿基础设施的测试实践硬套在 CRUD 系统上,就是刻舟求剑。

遗留代码的测试困境

单元测试的教科书都是这么写的:依赖注入、接口抽象、可测试性设计。

但真实项目里,大部分代码是遗留代码。

public class OrderService {
    public void createOrder(Long userId, Long productId) {
        // 直接 new 对象
        UserService userService = new UserService();
        ProductService productService = new ProductService();
        
        User user = userService.getById(userId);
        Product product = productService.getById(productId);
        
        // 直接调用静态方法
        BigDecimal price = PriceUtils.calculate(product.getPrice(), user.getLevel());
        
        // 直接操作数据库
        Connection conn = DataSource.getConnection();
        PreparedStatement ps = conn.prepareStatement("INSERT INTO orders ...");
        ps.executeUpdate();
        
        // 直接发消息
        RabbitMQClient.send("order.created", orderId);
    }
}

这段代码没有依赖注入,全是 new 对象和静态方法调用。怎么写单元测试?

用 PowerMock 强行 Mock 静态方法?用 Javaagent 拦截 new 操作?这些工具确实存在,但引入成本太高。配置复杂、运行慢、维护困难。

重构代码,改成依赖注入?那得动几百个文件,改完还得全量回归测试。改代码的风险,比不写单元测试的风险还大。

所以遗留代码的现实是:写单元测试的成本 > 不写单元测试的风险。团队只能选择不写。

测试覆盖率是个伪指标

很多公司要求:”单元测试覆盖率必须达到 80%。”

然后开发就开始刷覆盖率。

@Test
public void testCreateOrder() {
    orderService.createOrder(1L, 100L, 2);
}

一行代码,调用一次方法,覆盖率就上去了。验证什么了吗?什么也没验证。

或者更极端的:

@Test
public void testGetter() {
    User user = new User();
    user.setName("张三");
    assertEquals("张三", user.getName());
}

给每个 getter/setter 都写测试,覆盖率能刷到 90%。但这些测试有用吗?完全没用。

真正有价值的是:测试的质量,不是数量。

一个测试,能发现 bug,能保证重构不出错,能在代码改动时提供快速反馈,这才是有价值的测试。

100个垃圾测试,不如1个好测试。

TDD 为什么在国内推不动

Kent Beck、Martin Fowler 这些大牛都推崇 TDD(测试驱动开发):先写一个失败的测试,再写最少的代码让测试通过,然后重构,循环往复。

听起来很美,但 TDD 有一个隐含的前提:需求是稳定的。

想想 TDD 诞生的背景——欧美的 SaaS 产品,一个功能可以打磨半年,需求变更有完整的评审流程。在这种节奏下,先写测试再写代码确实能提高代码质量。

但国内的产品经理是怎么干的?今天说做 A 功能,明天改成 B 功能,后天又说 A 和 B 都要。你按 A 写了测试、写了代码,产品一句话测试全废了,得全部重写。重写测试花的时间比写业务代码还长,这谁受得了?

更现实的问题是,TDD 要求代码有良好的可测试性——依赖注入、接口隔离、关注点分离。但大部分程序员每天面对的是 5 年前的遗留代码,全是 new 对象和静态方法,连依赖注入都没有,TDD 的第一步就卡住了。

再加上交付压力。产品说”下周一必须上线”,你说”我想先写测试再写代码”,产品说”你是不是不想干了?”。TDD 前期确实会拖慢速度,但国内的节奏根本不给你这个时间窗口。

集成测试比单元测试更实用

单元测试测的是:当所有依赖都正常时,这个方法能正常工作。

集成测试测的是:当真实调用所有依赖时,整个流程能正常工作。

对于业务系统,集成测试的投入产出比远高于单元测试。

@SpringBootTest
@AutoConfigureMockMvc
public class OrderIntegrationTest {
    @Autowired
    private MockMvc mockMvc;
    
    @Test
    public void testCreateOrder() throws Exception {
        mockMvc.perform(post("/api/orders")
                .contentType(MediaType.APPLICATION_JSON)
                .content("{\"userId\":1,\"productId\":100,\"quantity\":2}"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.id").exists())
                .andExpect(jsonPath("$.amount").value("198.00"));
        
        // 验证数据库
        Order order = orderMapper.selectByUserId(1L).get(0);
        assertEquals(OrderStatus.PENDING, order.getStatus());
        assertEquals(new BigDecimal("198.00"), order.getAmount());
        
        // 验证库存
        Product product = productMapper.selectById(100L);
        assertEquals(8, product.getStock());  // 原来10个,扣了2个
    }
}

这个测试真实调用了 HTTP 接口、真实操作了数据库、验证了整个业务流程的完整性。一个集成测试就能覆盖多个单元测试的场景。

而且集成测试能发现单元测试永远发现不了的问题——数据库事务有没有正确提交?JSON 序列化有没有字段丢失?参数校验有没有生效?这些都是线上真实会炸的东西,单元测试一个都测不到。

测试金字塔 vs 测试菱形

经典的测试金字塔:底层大量单元测试,中层少量集成测试,顶层极少量 E2E 测试。

但国内的现实是:测试菱形。中层大量集成测试,底层少量单元测试(只测工具类和核心算法),顶层少量 E2E 测试。

这不是不专业,而是更务实。单元测试的成本高、收益低,集成测试的成本适中、收益高。

写不写单元测试,从来不是态度问题,是投入产出比的问题。把时间花在刀刃上,比什么都管用。