J9游戏|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 测试。

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

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