标题|Home

国家高新技术企业
服务热线:400-6688-605
为什么国内程序员不喜欢写单元测试?
发布来源:J9国际站备用
发布时间:2026-08-2215: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 测试。

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

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