公司动态

测试替身实战指南:Mock、Stub、Spy、Fake 的正确选择与避坑

📅 2026/7/26 6:45:13
测试替身实战指南:Mock、Stub、Spy、Fake 的正确选择与避坑
1. 项目概述测试替身的迷雾与选择在软件开发的日常里尤其是后端服务或前端组件化开发中我们几乎每天都在和测试打交道。单元测试、集成测试、端到端测试这些名词早已耳熟能详。但你是否遇到过这样的场景你写了一个调用外部API的服务每次跑测试都要等网络响应慢得让人抓狂或者你的代码依赖一个尚未开发完成的模块测试根本跑不起来。这时候一个经验丰富的开发者会告诉你“用个Mock吧。” 于是你兴冲冲地引入了某个测试框架的Mock功能把外部依赖“糊弄”过去测试终于绿了。但久而久之你发现测试代码越来越脆弱业务逻辑稍有变动一堆Mock相关的测试就挂了维护成本高得吓人。更糟糕的是这些测试给了你一种虚假的安全感你以为代码没问题一上线却漏洞百出。这正是“测试替身”这个强大工具被滥用后的典型症状。测试替身或者说Test Double是一个统称它就像电影拍摄中的替身演员在测试这个“片场”里临时顶替那些难以配合的“明星演员”真实依赖。Mock、Stub、Spy、Fake这些都是不同类型的替身各有各的戏路和适用场景。然而很多团队包括我早期所在的团队常常把它们混为一谈或者手里拿着Mock这把“锤子”看所有依赖都像“钉子”一通乱敲最终把测试架构敲得千疮百孔。这篇文章我就结合自己踩过的无数坑来系统性地拆解这四种测试替身它们到底是什么应该在什么场景下用以及最关键的如何避免那些常见的滥用陷阱让你的测试真正成为可靠的安全网而不是一碰就碎的“瓷器”。2. 核心概念辨析四种替身的本质区别在深入如何使用之前我们必须先厘清概念。很多资料对它们的定义模糊不清甚至互相矛盾。这里我采用在实践中被广泛认可也最有助于我们做出正确选择的定义。2.1 Stub提供预设答案的“提词器”你可以把Stub想象成话剧舞台上给演员提示台词的“提词器”。它的核心职责非常简单当被测代码调用某个方法时Stub会返回一个我们预先设定好的答案返回值或者抛出一个预设的异常。Stub不关心这个方法被调用了多少次、以什么顺序调用它只负责“给答案”。典型场景你的支付服务需要调用一个汇率转换接口来换算金额。在测试时你显然不希望真的去调用一个不稳定的外部汇率服务。这时你就可以为这个汇率客户端创建一个Stub让它无论输入什么货币对都固定返回一个预设的汇率比如1:7。这样你的支付逻辑测试就可以在一个确定的环境下运行专注于计算逻辑本身是否正确而不受外部服务波动的影响。关键特征被动它只是数据的提供者。无状态验证它不会记录或验证自己是如何被调用的。实现简单通常几行代码就能手写一个Stub。// 一个手写的简单Stub示例Java风格伪代码 public class FixedExchangeRateStub implements ExchangeRateService { Override public double getRate(String fromCurrency, String toCurrency) { // 无论什么货币固定返回7.0 return 7.0; } }2.2 Mock专注行为验证的“监考官”Mock则完全不同它更像一个严格的“监考官”。Mock的核心目的是验证交互行为。我们使用Mock时会预先设定一个期望比如“userRepository.save()这个方法应该被调用一次且传入的参数对象其email字段为testexample.com”。测试执行后Mock对象会自行检查这些期望是否全部满足。如果有任何一条期望未达成比如方法没被调用、调用次数不对、参数不符它就会让测试失败。典型场景测试用户注册服务。当用户提交注册信息后服务层应该调用一次NotificationService.sendWelcomeEmail()方法。至于这个方法内部具体怎么发邮件我们根本不关心也不应该在单元测试中去关心。我们只关心“发邮件”这个行为是否发生了。这时Mock就是最佳选择。我们会Mock掉NotificationService并设置期望“sendWelcomeEmail方法必须被调用恰好一次”。如果服务层代码漏写了这行调用测试立刻会失败。关键特征主动验证它主动断言交互行为。关注“如何调用”关心方法是否被调用、调用次数、调用参数。通常由框架生成像Mockito、Sinon.JS、unittest.mock等框架能方便地创建和配置Mock。注意这是滥用重灾区很多人用Mock来替代Stub的功能即让Mock返回一个值然后顺便做验证。这虽然可行但会让测试意图变得模糊。最佳实践是如果你只需要一个返回值用Stub如果你需要验证一个行为发生了用Mock。2.3 Spy忠实记录行为的“记录仪”Spy可以理解为真实对象的一个“间谍”或“包装器”。它包装一个真实的对象或者部分真实的对象让这个对象的大部分功能正常执行但同时秘密记录下它的调用信息以便在测试结束后进行查询和断言。你可以问Spy“刚才calculate方法被调用了多少次”“最后一次调用update的参数是什么”。典型场景你有一个复杂的、已经过充分测试的工具类DataProcessor你的新服务会调用它的多个方法。你想测试新服务的逻辑但又想确认它是否正确调用了DataProcessor的特定方法序列。如果完全用Mock你需要模拟DataProcessor的所有行为这很繁琐。如果直接用真实对象你又无法验证调用行为。这时用一个Spy包装真实的DataProcessor就是完美选择。新服务通过Spy调用所有真实逻辑都正常执行但调用记录被留存供你验证。关键特征部分真实默认执行真实对象的行为。事后查询行为记录在测试后供查询而非预先设定期望。用于验证“副作用”调用非常适合验证那些你不便Mock但又需要知道是否被调用的方法。2.4 Fake功能完备的“轻量级替补”Fake是一个“山寨版”的实现。它拥有和真实依赖相似的功能和业务逻辑但采用了一种极其简单、快速、适用于测试的实现方式。它不是为了验证交互而是为了提供一个可工作的、轻量级的运行环境。典型场景最经典的例子就是“内存数据库”。你的业务代码使用一个UserRepository来持久化用户数据真实实现是连接MySQL。在测试中你可以使用一个InMemoryUserRepository作为Fake它用HashMap或List在内存中存储用户数据。这个Fake实现了save、findById、delete等所有接口方法业务逻辑可以像操作真实数据库一样操作它但速度极快且不依赖外部服务。另一个常见例子是用一个简单的InMemoryEmailService代替真实的邮件发送服务它只是把邮件内容记录到一个列表中供测试后检查。关键特征可工作的实现有真实的、可运行的业务逻辑。简化实现牺牲了真实实现的某些特性如持久化、性能、分布式能力换取速度和可控性。用于集成测试或简化外部依赖常用于构建一个可控的测试上下文。为了更直观地区分我们可以看下面这个表格替身类型核心目的是否验证行为是否有真实逻辑典型使用场景Stub提供预设响应否否替换外部服务提供确定输入Mock验证交互行为是否验证方法是否被正确调用Spy记录并查询行为事后查询是(包装真实对象)验证对已有、复杂对象的调用Fake提供可工作的轻量实现否是(简化版)替换数据库、文件系统等重型依赖3. 选择策略如何为你的测试场景匹配合适的替身理解了区别下一步就是如何选择。这没有银弹但有一些清晰的决策路径。我的经验是遵循“从简到繁”的原则优先选择侵入性最小、最能表达测试意图的替身。3.1 决策流程图一张图帮你做选择面对一个依赖你可以遵循以下思考流程需要测试对象A它依赖B。 问B是“过程”还是“状态”如果关注“状态”即A调用B后基于B的返回结果进行后续计算你需要一个确定的返回值- 优先选择Stub。例如计算税费需要依赖税率服务返回一个税率值。如果关注“过程”即A是否对B发出了正确的“指令”你需要验证A是否调用了B - 优先选择Mock。例如用户注册后需要验证是否调用了发送邮件的服务。如果B本身很复杂但你已经信任它只想确认A是否“通知”了它你想让B执行真实逻辑同时记录调用 - 选择Spy(包装真实的B)。例如A调用了已有的、稳定的日志工具类你想确认错误被记录了。如果B是一个重型外部依赖如数据库、第三方API你需要一个完整但轻量的环境你需要一个能完全模拟B功能但不涉及网络/IO的替代品 - 选择或构建一个Fake。例如测试涉及数据库CRUD的业务流使用内存数据库Fake。3.2 不同测试层级的替身使用倾向单元测试Unit Test这里是Mock和Stub的主战场。单元测试要求隔离我们大量使用Mock来验证对象间的协作协议Protocol使用Stub来提供输入。要警惕Mock过度导致测试与实现细节过度耦合。集成测试Integration TestFake大放异彩。我们用Fake如内存数据库、假支付网关来替换掉一部分外部依赖让测试能在“半真实”环境下运行验证模块间的集成是否正确。Spy也常用于此监控模块间的调用。端到端测试E2E Test原则上尽量少用替身使用真实依赖。但在某些特定环节如为了触发一个错误场景可能会使用Stub来让某个服务返回错误码。3.3 实操心得我如何为服务层选择替身假设我正在测试一个OrderService的placeOrder方法它依赖InventoryClient库存服务、PaymentGateway支付网关和NotificationService通知服务。对InventoryClient.checkStock(itemId)这是一个查询操作服务需要根据库存是否充足决定下一步。我Stub它让它针对不同的测试用例返回true或false。我不关心它被调了多少次虽然通常是一次我只关心它给我的答案。对PaymentGateway.charge(order)这是一个命令操作是业务流程的关键一环。我必须确保订单创建成功后支付行为一定发生了。我Mock它设置期望在订单成功创建后charge方法必须被调用一次且参数中的订单金额正确。如果未来有人重构代码时误删了这行支付调用测试会立即失败。对NotificationService.sendReceipt(userEmail, order)这也是一个命令但可能不是核心业务关键路径也许日志记录就够了。为了测试的完备性我依然会验证它。如果NotificationService是一个简单的接口我可以用Mock。但如果它内部有复杂的模板生成逻辑且这个逻辑本身是稳定的我可能会用Spy包装一个真实的NotificationService实例这样既能测试邮件发送行为是否触发又能确保收据内容生成正确通过查询Spy记录到的参数。整体依赖如果InventoryClient和PaymentGateway背后是真实的微服务为了做集成测试我可能会为它们编写Fake——一个本地的、模拟了基本业务逻辑的轻量级HTTP服务器或客户端实现这样我可以在不启动整套微服务架构的情况下测试OrderService与这些“外部服务”的集成逻辑。4. 滥用警告与避坑指南知道怎么用很重要但知道不该怎么用可能更重要。以下是我总结的几种典型的测试替身滥用模式每一种都足以让你的测试套件价值大打折扣。4.1 过度Mock测试与实现细节的致命耦合这是最常见的反模式我称之为“Mock癌”。其症状是测试里充满了对被测对象内部私有方法、或间接依赖的Mock设置。反面案例// 过度Mock测试知道了太多“秘密” Test public void testProcessOrder_OverMocked() { // 过度Mock连内部工具方法都Mock了 OrderProcessor processor mock(OrderProcessor.class); when(processor.calculateTax(any())).thenReturn(10.0); // 计算税费本应是内部实现细节 when(processor.generateInternalId()).thenReturn(fake-id); // 生成ID也是内部细节 // 还Mock了不是直接依赖的深层对象 DatabaseConnection conn mock(DatabaseConnection.class); when(conn.isValid()).thenReturn(true); // ... 一堆对conn的when操作 service.processOrder(...); // 断言... }问题这个测试不再测试OrderService的业务逻辑而是在测试它的实现细节。一旦你重构OrderService把calculateTax的逻辑挪到另一个辅助类或者换了种ID生成算法这个测试就毫无理由地失败了。测试变得极其脆弱重构成本陡增。正确做法只Mock或Stub被测对象的直接依赖通常通过构造函数或方法参数注入。对于内部逻辑我们应该通过公开的API即被测方法输入不同的数据来验证其输出和行为而不是去Mock它的内部步骤。测试应该关注“做了什么”What而不是“怎么做”How。4.2 用Mock代替Stub模糊的测试意图很多人因为Mockito这类框架太方便就只用when(...).thenReturn(...)把它当Stub用然后因为顺手再加几个verify(...)。这会让测试意图不清晰。反面案例Test public void testGetUserDiscount_MockAsStub() { UserService userService mock(UserService.class); // 主要目的是让userService返回一个用户这是一个Stub行为 when(userService.findUser(1L)).thenReturn(new User(VIP)); // 但又顺便验证了一个可能不重要的调用 verify(userService, times(1)).findUser(1L); // 这个验证必要吗 double discount calculator.getDiscount(1L); assertThat(discount).isEqualTo(0.9); }问题这个测试的主要目的是验证折扣计算逻辑。findUser被调用一次是计算折扣的必然前提吗也许getDiscount方法内部会缓存用户信息第一次调用后第二次就不调了。这个对findUser调用次数的验证就把测试和具体的缓存实现耦合了。如果未来加了缓存这个测试就会失败尽管折扣计算逻辑完全正确。正确做法意图单一化。如果这个测试只是为了验证根据用户类型计算折扣那么userService就应该是一个纯粹的Stub。只有当“调用findUser”这个行为本身就是业务规则的一部分例如协议规定必须查询一次最新用户状态时才使用Mock进行验证。在大多数情况下对于查询类依赖使用Stub就足够了。4.3 忽略Fake的价值过度依赖Mock框架导致测试笨重有些团队对所有外部依赖都条件反射般地使用Mock框架来创建替身。对于像数据库访问层Repository这样的依赖这会导致测试代码异常臃肿。反面案例测试一个涉及多个数据库实体查询和关联的业务服务。你需要MockUserRepository.findById、OrderRepository.findByUserId、ProductRepository.findByIdIn……每个方法都要精心设置返回值测试代码长达数百行且极其脆弱实体关系稍变Mock配置就要大改。正确做法为持久层引入Fake。花点时间实现一个InMemoryUserRepository和InMemoryOrderRepository。它们使用ConcurrentHashMap或List存储实体并实现完整的增删改查逻辑。这样你的测试就可以这样写Test public void testGetUserDashboard() { // 1. 准备Fake数据 InMemoryUserRepository userRepo new InMemoryUserRepository(); InMemoryOrderRepository orderRepo new InMemoryOrderRepository(); User user new User(Alice); userRepo.save(user); orderRepo.save(new Order(user.getId(), ...)); orderRepo.save(new Order(user.getId(), ...)); // 2. 注入Fake DashboardService service new DashboardService(userRepo, orderRepo); // 3. 执行与断言 Dashboard dashboard service.getDashboard(user.getId()); assertThat(dashboard.getOrderCount()).isEqualTo(2); // ... 其他断言 }优势测试更贴近真实业务逻辑在“类数据库”环境下运行。代码更简洁无需大量when...thenReturn。更健壮不依赖具体的方法调用顺序和次数只依赖最终数据状态。可用于集成测试这些Fake可以轻松共享给其他需要测试数据库交互的组件。4.4 Spy的误用破坏封装与测试无关细节Spy的强大在于它能窥探真实对象但这也意味着它很容易被滥用去测试一些本应是私有的、无关紧要的细节。反面案例Test public void testGenerateReport_MisuseSpy() { ReportGenerator realGenerator new ReportGenerator(); ReportGenerator spyGenerator spy(realGenerator); // 错误用Spy来验证一个内部私有方法是否被调用 doNothing().when(spyGenerator).sanitizeData(any()); // 试图监控一个私有方法 // 或者验证一个非关键的内部工具方法调用次数 verify(spyGenerator, times(2)).formatSectionHeader(anyString()); // 格式细节 spyGenerator.generateReport(data); }问题sanitizeData和formatSectionHeader很可能是ReportGenerator的内部实现细节。测试它们等于把测试和具体的代码结构牢牢绑死。一旦重构内部方法名或调用结构测试就崩了尽管generateReport的对外功能完全正常。正确做法使用Spy来验证对象对外部依赖的调用或者验证那些具有明确业务意义的、公开的协作行为。例如用Spy包装一个真实的邮件发送器Fake验证generateReport方法最后是否调用了发送器的send方法并检查发送的邮件内容参数是否符合预期。至于报告生成器内部调用了多少次formatSectionHeader那不是测试应该关心的。5. 实战构建一个健壮的测试套件理论说再多不如看一个综合性的小例子。假设我们有一个PaymentProcessor支付处理器它依赖BankGateway银行网关外部HTTP服务和TransactionLogger交易日志记录器。我们的目标测试PaymentProcessor.executePayment(PaymentRequest request)方法。业务规则调用BankGateway.charge执行扣款成功后调用TransactionLogger.logSuccess记录成功日志失败则调用TransactionLogger.logFailure记录失败日志。BankGateway.charge可能成功也可能因各种原因余额不足、网络超时失败。5.1 测试用例设计与替身选择用例1支付成功应记录成功日志对BankGateway我们需要它返回成功响应。这是一个预设答案用Stub。when(bankGateway.charge(...)).thenReturn(new SuccessResponse(...))对TransactionLogger我们需要验证logSuccess被调用了一次。这是一个行为验证用Mock。verify(transactionLogger, times(1)).logSuccess(...)为什么不MockBankGateway因为此用例不关心charge是否被调用它是成功的必然前提我们只关心它的返回值。用例2支付失败余额不足应记录失败日志对BankGateway需要它返回一个特定的失败响应。依然是Stub。when(...).thenReturn(new FailureResponse(INSUFFICIENT_FUNDS))对TransactionLogger需要验证logFailure被调用且参数中包含错误码。用Mock。verify(transactionLogger).logFailure(argThat(f - f.contains(INSUFFICIENT_FUNDS)))用例3支付网关超时应记录失败日志并抛出业务异常对BankGateway需要它模拟超时异常。还是Stub。when(...).thenThrow(new TimeoutException())对TransactionLogger验证logFailure被调用。用Mock。对PaymentProcessor本身断言它抛出了特定的PaymentTimeoutException。进阶用例4集成测试 - 验证整个流程与日志内容我们可能想验证日志记录的具体内容比如是否包含了交易ID和金额。对BankGateway使用一个Fake比如一个模拟了网络延迟和不同响应状态的轻量级HTTP服务器桩。对TransactionLogger不使用Mock而是使用一个Spy来包装一个真实的、但输出到内存的Logger实现这个Logger实现本身就是一个Fake。这样测试可以执行真实流程通过Fake网关然后通过Spy查询真实记录下来的日志条目并断言其内容。这比Mock更强大因为它测试了日志器实际产生的输出而不仅仅是一个方法调用。5.2 代码结构示意// 测试类核心片段 public class PaymentProcessorTest { Test public void executePayment_Success_LogsSuccess() { // 1. 准备替身 BankGateway bankGateway mock(BankGateway.class); TransactionLogger logger mock(TransactionLogger.class); PaymentProcessor processor new PaymentProcessor(bankGateway, logger); // 2. 配置Stub让网关返回成功 PaymentRequest request new PaymentRequest(...); when(bankGateway.charge(request)).thenReturn(PaymentResult.success(txn_123)); // 3. 执行 processor.executePayment(request); // 4. 验证Mock行为成功日志被记录 verify(logger).logSuccess(txn_123, request.getAmount()); // 注意我们没有verify(bankGateway)因为此测试不关心调用行为只关心其返回结果。 } Test public void executePayment_InsufficientFunds_LogsFailure() { BankGateway bankGateway mock(BankGateway.class); TransactionLogger logger mock(TransactionLogger.class); PaymentProcessor processor new PaymentProcessor(bankGateway, logger); PaymentRequest request new PaymentRequest(...); // Stub配置返回失败 when(bankGateway.charge(request)).thenReturn(PaymentResult.failure(INSUFFICIENT_FUNDS)); processor.executePayment(request); // 验证Mock行为失败日志被记录且包含特定错误码 verify(logger).logFailure(argThat(errorMsg - errorMsg.contains(INSUFFICIENT_FUNDS))); } // 使用Spy验证日志内容的示例 Test public void executePayment_Success_LogsCorrectDetails_UsingSpy() { BankGateway bankGateway mock(BankGateway.class); // 使用一个真实的内存日志器Fake并用Spy包装 InMemoryTransactionLogger realLogger new InMemoryTransactionLogger(); TransactionLogger loggerSpy spy(realLogger); // Spy包装真实对象 PaymentProcessor processor new PaymentProcessor(bankGateway, loggerSpy); when(bankGateway.charge(any())).thenReturn(PaymentResult.success(txn_456)); processor.executePayment(new PaymentRequest(...)); // 查询Spy背后真实对象记录的内容 ListLogEntry logs realLogger.getLogs(); assertThat(logs).hasSize(1); assertThat(logs.get(0).getType()).isEqualTo(SUCCESS); assertThat(logs.get(0).getTransactionId()).isEqualTo(txn_456); // 这样我们既验证了行为记录了日志又验证了日志的具体内容。 } }5.3 避坑技巧实录每个测试只Mock/Stub必要的东西在Before方法中初始化所有Mock是一种常见坏味道。这会导致每个测试用例都有一些用不到的替身降低了测试的清晰度。更好的做法是在每个测试方法内部按需创建和配置替身或者使用JUnit 5的Nested类来分组共享配置。使用“严格”的Mock大多数Mock框架如Mockito的strictness设置支持严格模式。它会对不必要的交互即你没有明确Stub或期望的方法调用发出警告或报错。这能有效帮你发现测试对实现细节的隐含依赖促使你编写更专注、更松耦合的测试。警惕“过度指定”在使用Mock的verify时避免过度指定参数细节。例如使用any()、eq()等参数匹配器或者使用ArgumentCaptor来捕获参数再进行灵活断言而不是写死一个具体的对象。这能让测试在内部重构如增加字段时保持稳定。Fake的共享与维护如果你决定为数据库层编写Fake如InMemoryRepository请把它当作一个正式的项目组件来维护。为其编写测试确保它的行为与真实Repository的契约接口一致。可以考虑将其放在testFixtures源码集Gradle或一个独立的测试工具模块中供整个项目使用。当测试变得复杂时反思设计如果一个单元测试需要Mock 5个以上的依赖或者配置极其复杂这很可能是一个设计信号——你的被测类可能承担了太多职责违反了单一职责原则。考虑是否可以通过拆分类、引入领域事件、或使用策略模式来简化依赖关系从而让测试也变得简单。测试替身是提升测试质量、保障开发效率的利器但利器用不好也会伤到自己。核心在于理解每种替身的本质意图Stub管喂数据Mock管查岗Spy管记录Fake管顶班。时刻问自己“我到底想测试什么是状态的变化还是交互的行为” 根据答案选择最合适的工具。记住好的测试应该像一份清晰的文档描述代码在何种输入下应产生何种输出或行为而不是一份冗长的实现说明书把代码的内部结构死死锁住。让你的测试专注于契约接口而非实现这样你才能获得在重构代码时那份真正的自由与信心。