
前阵子帮一个电商团队复盘线上秒杀故障测试环境全绿压测一上就超卖。查到最后发现问题根本不在代码逻辑而在测试压根没覆盖到真正的并发时序。那段时间我正好同时在两个项目里折腾测试驱动开发一个用Jest一个用JUnit都是秒杀场景。两套框架、两种语言、两种心智模型踩了不少坑也沉淀了一些实打实的经验。这篇就把整个对比过程写透从秒杀系统的测试难点讲起到TDD怎么落地再到Jest和JUnit各自怎么写、差异在哪最后是维护过程中的血泪教训。适合正在做高并发业务、想在团队里推行TDD、或者在纠结前后端测试框架选型的同学参考。1. 秒杀系统的测试难点为什么常规写法在这里经常失效1.1 难点不是“写测试”而是“模拟真实并发”很多人第一次接触秒杀系统的测试第一反应是“多写几个并发请求跑一下不就行了”。真正做进去才发现本地写个for循环起100个线程去调接口结果大概率是绿但线上还是会出问题。原因在于秒杀系统的核心风险是竞态条件两个请求同时读到库存为1都判定可以扣减最后库存变成-1。这个时序问题在单线程测试里根本不会暴露。我在JUnit侧最早踩过这个坑。写了一个库存扣减的单测单线程调用10次每次校验返回值测试全过。但用JMeter并发一压超卖立刻出现。后来才明白单测默认是顺序执行的它验证的是“功能正确性”不是“并发正确性”。所以要测秒杀必须主动去构造并发调用而且要用确定性手段让线程尽量同一时刻冲到临界区而不是靠运气。Jest那边的情况类似。前端BFF层的秒杀预扣接口通常用Redis的原子操作来保证不超卖。你在单测里mock掉Redis连续调用10次DECR结果肯定是对的。但DECR的原子性依赖Redis服务端测试却把Redis换成了内存对象这时候测的是业务逻辑还是Mock框架的行为就很难说了。1.2 三个典型测试盲区时序、缓存一致性、降级路径秒杀系统里测试最容易“假绿”的区域我总结下来有三个。时序盲区是最常见的。库存预扣、订单创建、支付回调这三步之间存在时间窗口任何一步失败都需要补偿机制。很多团队的测试只覆盖了“正常流程”也就是预扣成功、订单创建成功、支付成功这条幸福路径。但秒杀真正让人头疼的是“预扣成功但订单创建失败”“支付超时但库存已扣”“用户重复点击导致同一订单被创建两次”这类跨步骤的时序问题。这类问题必须用TDD的方式先把测试场景列出来再写实现否则很容易漏。缓存一致性是第二个盲区。秒杀系统为了扛住峰值流量商品详情、库存数量通常会先读缓存再异步回写数据库。测试里如果只验证了缓存层的读写没验证缓存与数据库的最终一致就会出现“接口测试全过、实际库存数据错乱”的情况。正确做法是在测试用例里同时写缓存和数据库的断言模拟缓存失效、回写失败、补偿任务重试等场景。降级路径是第三个盲区。秒杀系统在流量超过阈值时会触发限流、熔断、降级比如直接返回“活动太火爆”。很多团队的测试根本不覆盖降级开关打开时的行为。结果就是平时接口正常一到大促流量上来降级策略误伤了正常用户或者降级逻辑里的Bug导致部分请求拿到错误状态。TDD的好处在于业务规则一确定降级分支也会被写进测试用例逼着开发把这条路径实现完整。1.3 TDD的真正价值逼出可测试的架构而不是刷覆盖率很多团队对TDD的理解停留在“先写测试再写代码”这个流程上但真正做过之后我最大的感受是TDD最大的收益是逼你设计出可测试的架构。举个具体例子。如果你先用TDD写一个库存扣减服务你会先思考怎么让这个服务不依赖真实的Redis和数据库连接那就得引入接口抽象、依赖注入、仓储模式。如果你先写实现大概率会把RedisTemplate、DataSource这些东西直接new在Service里后面想测试就只能上PowerMock改私有字段痛苦不堪。Jest和JUnit在这方面的哲学是一致的但表现形式不同。Jest的mock机制很轻量jest.fn()随手就能造一个替身所以JavaScript项目天然容易测试但也容易因为太容易mock而导致测试失去意义。JUnit这边Mockito用注解声明mock对象代码更正式但想要mock私有方法或者静态方法就比较麻烦这反而倒逼你把代码写得依赖干净、边界清晰。我一直觉得TDD的“红-绿-重构”不是目的目的是让设计在写第一行业务代码之前就被测试需求驱动着变得合理。测试写不出来的地方往往是设计有问题的地方而不是测试能力有问题。2. TDD在秒杀场景的落地思路先列规则再谈框架2.1 从业务规则反推测试用例秒杀系统的业务规则通常非常明确比如“活动期间每人限购一件”“库存扣减不能为负”“同一用户同一活动只能创建一个订单”。这些规则几乎是天然的测试用例来源。我做TDD的习惯是先把业务规则一条条列出来然后每条规则转成一个或多个测试用例再开始写测试代码。拿一个典型的秒杀库存服务来说规则可以拆成这样业务规则对应测试用例库存充足时允许扣减扣减10件库存断言剩余库存减少10库存不足时拒绝扣减扣减数量大于库存断言扣减失败且库存不变同一用户同一活动只能下一单同一userId下两单第二单被拒绝并发扣减不能超卖10件库存20个并发请求成功数不超过10幂等请求不重复扣减相同请求ID重复提交第二次直接返回成功不扣减活动未开始或已结束拒绝下单时间在活动窗口外断言拒绝这张表列出来之后你会发现测试用例直接决定了后续实现需要哪些入参、哪些返回值、哪些异常分支。比如幂等那条规则意味着接口必须接受一个业务请求ID服务端要有去重表或者Redis SETNX做幂等判断。这些设计决策不是事后补的而是被测试用例推着走的。2.2 红-绿-重构的节奏在库存扣减里怎么走TDD的基础节奏是红-绿-重构。写第一个测试时实现类还不存在测试跑不过去这是“红”。然后写刚好能让测试通过的最小实现跑出“绿”。最后在不改变行为的前提下优化代码结构这是“重构”。在秒杀库存扣减这个具体场景下节奏大概是这样的。第一个测试我会写“库存充足时允许扣减”先定义一个接口StockService.deductStock(skuId, quantity)测试里调用这个接口并断言结果。编译都过不了这就是第一次红。然后创建一个最简单的实现类直接返回成功测试变绿。紧接着加第二个测试“库存不足时拒绝扣减”这次新的测试会红实现类里开始查库存、判断数量让测试变绿。这个步骤看起来很简单但有一个关键点很多人没意识到测试用例的顺序就是实现复杂度的推进路线。每写一个测试实现里就多一个if分支或者多一个依赖。等到写并发用例的时候你自然会意识到原来的实现里库存变量是内存里的一个int并发下会出问题于是你把它替换成Redis的DECR或者数据库的乐观锁。这就是TDD驱动设计的过程。2.3 被TDD逼出来的几个架构决策我做了几次秒杀TDD之后总结出几个被测试用例逼出来的架构决策这些决策在非TDD流程下往往会拖到联调阶段才发现。第一个是依赖注入。为了在单元测试里替换Redis、数据库连接Service必须通过构造函数或Setter接收这些依赖而不是在内部new一个单例。Spring Boot的Autowired和Node.js的构造函数注入在这个需求下不谋而合。第二个是接口隔离。库存扣减、订单创建、幂等校验这些能力被拆成独立接口而不是一个大Service里的方法。因为测试需要分别mock每个依赖接口不拆分mock的粒度就会很粗测试写起来特别累。第三个是异步边界显式化。秒杀场景里有很多异步操作比如库存扣减成功后发送MQ消息、支付回调后异步更新订单状态。如果不做TDD你很容易在业务方法里直接把消息发送逻辑写进去测试时只能靠sleep等异步完成。TDD会逼你把这层异步边界抽象成接口测试里注入一个同步实现的替身用fake timer或者同步执行器来验证这样测试既不慢又不flaky。3. Jest侧实战Node.js秒杀BFF的TDD全过程3.1 项目结构与测试环境搭建先说场景背景。这个项目是Node.js写的BFF层Express框架负责秒杀页面的请求入口。BFF层不直接操作MySQL但会和Redis交互做库存预扣然后调用下游Java交易服务创建订单。所以Jest这边的测试对象是BFF层的两个核心函数preDeductStock预扣库存和createOrder调用下游并处理幂等。项目结构大概长这样src/ services/ stockService.js orderService.js routes/ seckill.js tests/ stockService.test.js orderService.test.jsJest的配置很简单package.json里装好jest之后直接跑就行。我额外配了testEnvironment: node因为BFF层跑在Node环境不需要jsdom。这个细节容易忽略默认的jsdom环境在测Redis、HTTP这类Node API时偶尔会有奇怪的行为。3.2 第一个红牌用例写一个必然失败的预扣测试按照TDD我第一个测试的目标是验证“库存充足时预扣成功”。当时的实现还没写stockService.js文件都不存在所以第一步是创建测试文件引用还不存在的模块。const { preDeductStock } require(../src/services/stockService); describe(preDeductStock, () { test(库存充足时预扣成功返回剩余库存, async () { const result await preDeductStock({ skuId: sku-1001, quantity: 1, requestId: req-001 }); expect(result.success).toBe(true); expect(result.remainingStock).toBe(9); }); });第一次跑测试报错是Cannot find module ../src/services/stockService这就是红。然后创建stockService.js先给一个最朴素的实现async function preDeductStock({ skuId, quantity }) { return { success: true, remainingStock: 10 - quantity }; }测试变绿。注意这个实现根本没有持久化只是用参数算了一个数。但这正是TDD强调的“刚好让当前测试通过”。下一批测试会让它继续演进。3.3 jest.mock与Redis依赖隔离接下来第二个测试“库存不足时预扣失败”。这时候实现需要真正查库存了。项目里用的是ioredis测试里不能连真实Redis所以要用Jest的mock机制替代。const redisClient require(../src/utils/redisClient); const { preDeductStock } require(../src/services/stockService); jest.mock(../src/utils/redisClient, () ({ decrBy: jest.fn(), get: jest.fn() })); describe(preDeductStock, () { beforeEach(() { jest.clearAllMocks(); }); test(库存不足时预扣失败库存不变, async () { redisClient.get.mockResolvedValue(0); redisClient.decrBy.mockResolvedValue(-1); const result await preDeductStock({ skuId: sku-1001, quantity: 1, requestId: req-002 }); expect(result.success).toBe(false); expect(redisClient.decrBy).toHaveBeenCalledWith(stock:sku-1001, 1); }); });这里最关键的设计决策是preDeductStock内部通过require引入redisClient。Jest的jest.mock会用mock版本替换掉这个模块不需要依赖注入框架。这种机制写起来很快但也有隐患一旦真实代码里绕过了模块引入或者用了别的实例mock就会失效。我后面在坑里细说。实现里我用的是Redis的decrBy命令加get判断剩余库存整个逻辑不复杂核心是保证原子性。在真实场景里这里应该用Lua脚本一次完成检查和扣减避免并发下两个请求同时读到库存1。但测试驱动的节奏下可以先写简单的实现让用例变绿再加入Lua脚本。3.4 并发边界测试与fake timers秒杀最核心的用例是并发下不超卖。Jest里模拟并发最简单的方式是Promise.all同时发起多个异步调用。test(20个并发请求库存10成功数不超过10, async () { const initialStock 10; const requestCount 20; // 用mock模拟Redis的原子扣减行为 const mockStock { value: initialStock }; redisClient.decrBy.mockImplementation(() { mockStock.value - 1; return Promise.resolve(mockStock.value); }); const results await Promise.all( Array.from({ length: requestCount }, (_, i) preDeductStock({ skuId: sku-1001, quantity: 1, requestId: req-${i} }) ) ); const successCount results.filter(r r.success).length; expect(successCount).toBeLessThanOrEqual(initialStock); });这里有一个需要注意的点mock实现里mockStock.value - 1这段代码是同步执行的虽然外层包了Promise.resolve但在单个Node进程里所有调用都在同一个事件循环中排队所以其实不会有真正的并发竞争。这个测试本质上验证的是“当底层接口返回负数时业务层能正确判定失败”而不是验证Redis的原子性。真正要验证Redis原子性需要起一个真实的Redis实例写集成测试或者在mock里用异步操作故意构造竞态但那样测试就变成flaky了。我的实践是单元测试验证业务逻辑的并发判定分支集成测试验证Redis命令本身的原子性两者各司其职。Jest还有一个特别实用的能力是fake timers。BFF层经常有用setTimeout做延迟重试的逻辑。正常测试里等真实定时器跑完会浪费大量时间。Jest的jest.useFakeTimers()可以把定时器替换成可控的假时钟然后用jest.advanceTimersByTime(5000)手动快进5秒。我在测试“预扣失败后5秒自动重试”这个场景时用得非常顺手。3.5 Jest异步测试的一个隐蔽坑Jest里测异步函数时最容易踩的坑是断言永远不执行。比如你写了expect(foo).toBe(true)但foo是个Promise这个断言实际上永远为真因为Promise对象永远不等于布尔值true。Jest不会报错测试照样绿。深究下去是因为你没有return或await。我的习惯是所有异步测试要么写return preDeductStock(...).then()要么直接await preDeductStock(...)绝不在回调里写断言。而且我会开启eslint-plugin-jest的规则no-done-callback从工具层面上杜绝这类问题。4. JUnit侧实战Spring Boot核心交易服务的TDD细节4.1 测试金字塔在JUnit生态中的真实形态相比Jest那种怎么方便怎么来的风格JUnit生态会更强调测试金字塔底层是大量快速的单元测试中间是少量的集成测试顶层是更少的端到端测试。秒杀系统的Java服务在JUnit侧通常要覆盖Controller、Service、Repository三层每一层的测试策略不一样。Repository层真正连数据库的话测试会很慢所以通常用DataJpaTest或者Testcontainers。Service层是业务核心用JUnit 5 Mockito做单元测试把Repository和RedisTemplate都mock掉。Controller层用WebMvcTest只加载MVC相关组件不加载整个Spring上下文以此保证测试速度。这套分层策略在Jest生态里没有这么严格。Jest项目往往单元测试和集成测试混在一起很多开发者用一个describe把所有层都测了。不能说谁优谁劣但碰到秒杀这种对并发正确性要求极高的场景JUnit这种严格分层有一个额外的好处它逼着你在每一层都要验证一次并发边界约束而不是只在最外层糊一层测试。4.2 用JUnit 5 Mockito实现库存扣减Service测试TDD的流程一样先写测试再写实现。假设有一个StockService核心方法签名是public DeductResult deductStock(DeductCommand command)测试代码长这样ExtendWith(MockitoExtension.class) class StockServiceTest { Mock private StockRepository stockRepository; Mock private RedisTemplateString, String redisTemplate; InjectMocks private StockService stockService; Test void 库存充足时扣减成功() { Stock stock Stock.builder() .skuId(sku-1001) .availableStock(10) .version(1L) .build(); when(stockRepository.findBySkuId(sku-1001)).thenReturn(Optional.of(stock)); DeductResult result stockService.deductStock( new DeductCommand(sku-1001, 1, req-001) ); assertThat(result.isSuccess()).isTrue(); assertThat(result.getRemainingStock()).isEqualTo(9); verify(stockRepository).save(any(Stock.class)); } }这里用了AssertJ的流式断言assertThat可读性比JUnit原生的assertEquals好不少。Mockito的Mock配合InjectMocks是常规操作但要注意InjectMocks是构造器注入优先如果没有构造器它会尝试setter注入或者字段注入。如果Service里既有构造器又有settermock的注入可能会不符合预期这也是TDD逼出来的一个设计点——构造器注入是最稳妥的字段注入在测试时会很痛苦。4.3 集成测试中的事务回滚与数据准备单元测试把Redis和数据库都mock掉之后服务的集成测试就得用真实的数据库来做。JUnit生态里集成测试有一个特别省心的机制事务回滚。SpringBootTest Transactional class StockServiceIntegrationTest { Autowired private StockService stockService; Autowired private StockRepository stockRepository; Test void 集成环境下库存扣减持久化() { Stock stock new Stock(sku-1002, 100); stockRepository.save(stock); DeductResult result stockService.deductStock( new DeductCommand(sku-1002, 5, req-100) ); assertThat(result.isSuccess()).isTrue(); Stock updated stockRepository.findBySkuId(sku-1002).orElseThrow(); assertThat(updated.getAvailableStock()).isEqualTo(95); } }测试方法上的Transactional会在测试结束后自动回滚不会给数据库留脏数据。但注意这个回滚只对单库事务有效。秒杀系统里如果出现跨库事务、Redis和MySQL混合使用回滚机制就不够用了。Redis的操作走了另一个连接Spring的事务管理管不到它测试跑完Redis里的key还是残留着。这个问题我是在一次集成测试反复跑了几十次之后突然出现的第二次跑测试时Redis里的库存已经被上一次测试扣过了断言就莫名其妙失败。后面采取的方案是每次测试执行前显式清理相关Redis key或者在测试类的BeforeEach里把相关缓存全部清一遍。这个坑在Jest那边我倒是没遇到过因为Jest测试基本把Redis都mock了根本没有这种残留问题。4.4 并发扣减的确定性验证CountDownLatch 线程池真正写并发测试的时候JUnit这边不像Jest用Promise.all那么轻量得用ExecutorService加CountDownLatch手动制造并发风暴。核心思路是开N个线程先用CountDownLatch让所有线程停在同一起跑线上然后同时执行库存扣减。Test void 并发扣减不超卖() throws InterruptedException { int threadCount 20; int stockCount 10; Stock stock new Stock(sku-1003, stockCount); stockRepository.save(stock); ExecutorService executor Executors.newFixedThreadPool(threadCount); CountDownLatch readyLatch new CountDownLatch(threadCount); CountDownLatch startLatch new CountDownLatch(1); CountDownLatch endLatch new CountDownLatch(threadCount); AtomicInteger successCount new AtomicInteger(0); for (int i 0; i threadCount; i) { executor.submit(() - { readyLatch.countDown(); try { startLatch.await(); DeductResult result stockService.deductStock( new DeductCommand(sku-1003, 1, UUID.randomUUID().toString()) ); if (result.isSuccess()) { successCount.incrementAndGet(); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { endLatch.countDown(); } }); } readyLatch.await(); startLatch.countDown(); endLatch.await(); assertThat(successCount.get()).isLessThanOrEqualTo(stockCount); executor.shutdown(); }这段代码里有几个关键点。一是库存扣减的Service实现必须用乐观锁或者Redis原子操作否则findBySkuId出来的库存是同一份快照所有线程都会判定库存充足结果就是超卖。二是我在压测并发用例时会刻意把数据库连接池调小一点让线程尽量在数据库访问时碰撞而不是各自顺序执行完。三是在并发测试里如果实现代码用了synchronized或者RedisTemplate的某些非线程安全操作这个测试大概率能暴露出来。4.5 JUnit和Jest在测试反馈上的最大区别两个框架我都用来写秒杀测试之后最大的感触是反馈速度的差异。Jest的项目规模小BFF层测试几十毫秒就能跑完开发时可以做到每改一行代码就顺手跑一遍相关测试反馈几乎无感。JUnit那边的Spring Boot项目哪怕只跑一个Service层单元测试JVM启动加Spring上下文初始化也要几秒钟集成测试更是几十秒级别。这个差异直接影响了TDD的节奏。在Jest里“红-绿-重构”的循环可以非常快你可能一分钟之内就能完成好几次循环。在JUnit里因为反馈周期长强迫你一次就想清所有测试用例而不是一个个地加。这倒也不全是坏事我在写Java测试时会更为谨慎测试用例的设计质量反而更高。如果你在JUnit项目里做TDD我的建议是先把单元测试跑通别每次都启动完整Spring Boot应用用mvn -DtestStockServiceTest test只跑指定的测试类能省掉大量时间。5. 同一套需求两种框架的关键差异5.1 运行模型Jest的worker进程隔离 vs JUnit的线程复用Jest默认会启动多个worker进程并行跑测试文件每个文件在独立进程里运行好处是测试文件之间天然隔离不会因为静态变量、内存状态污染而互相影响。JUnit在同一个JVM里跑所有测试测试类之间默认串行但如果用了并发插件或者Execution(CONCURRENT)线程安全问题就变成测试本身需要考虑的问题了。在秒杀场景里这个差异非常实际。Jest的隔离机制让你可以放心地在每个测试文件里初始化真实的Redis内存模拟器或者数据副本文件之间不会互相干扰。JUnit则容易踩静态状态的坑比如某个单例持有了上一个测试留下的库存数据导致当前测试断言失败。我的JUnit测试规范是不允许在静态字段里保存业务状态所有状态都通过BeforeEach重新初始化。5.2 Mock风格对比Jest的运行时替身 vs Mockito的代理对象Jest的mock是运行时替换模块你可以在测试文件里直接改被测试模块的依赖行为。这个机制极其灵活但也容易过度使用。Mockito则是通过动态代理生成mock对象由Spring或手工注入到被测对象里。两者的哲学差异在于Jest默认“mock整个模块”Mockito默认“mock具体对象”。在秒杀场景里我的经验是Jest更适合对整条调用链做快照式的mock比如把整个redisClient模块替换掉而JUnit的Mockito更适合针对某个独立的Service依赖做精确的行为控制。如果你在Jest里也想做到Mockito那种精细控制得用jest.spyOn去mock一个对象的特定方法能做但没有Mockito那么直觉。5.3 并发测试写法Promise.all vs CountDownLatch这是两个框架在秒杀测试里最显著的对比。Jest利用JavaScript单线程事件循环Promise.all天然让所有异步任务并发发起模不模拟线程都无所谓因为真正的并发点在Redis服务端。JUnit必须真实创建线程并且要用CountDownLatch来控制线程同时起跑代码量大不少。但仔细想想这两个差异背后是业务形态的不同。BFF层的秒杀逻辑是IO密集瓶颈在网络和RedisJavaScript单线程完全够用。Java服务层的库存扣减逻辑是内存加数据库操作可能涉及本地锁和事务必须用多线程验证。所以不能说哪个写法更高级只能说各自适合各自的运行环境。5.4 断言与可读性对比Jest的断言是expect(x).toBe(y)读起来像一个英文句子。JUnit生态里原生断言比较呆板用AssertJ之后可以写成assertThat(x).isEqualTo(y)可读性也不错。两者我都用下来最喜欢的反而是AssertJ的isLessThanOrEqualTo这类专为边界条件设计的断言在检查“成功数不超过库存数”时特别清晰。对比维度JestJUnit运行模型worker进程隔离同JVM线程复用Mock风格替换模块运行时替身代理对象依赖注入并发测试Promise.all模拟并发CountDownLatch多线程断言风格expect(x).toBe(y)AssertJ流式断言反馈速度毫秒级秒级到分钟级典型生态BFF层/Node.js服务交易核心/Java服务5.5 团队协作中的实际感受在一个团队里同时维护两套测试框架最大的成本不是写测试而是统一测试心智。Java组的同学看Jest测试会觉得“这写得太随意了”Node组的同学看JUnit测试会觉得“这也太重了”。我的处理方式是在代码评审时明确每条测试用例的意图是验证业务规则还是验证并发正确性验证并发正确性的测试不管是Jest的Promise.all还是JUnit的CountDownLatch都必须标注注释说明要防的是什么竞态条件。只要意图清晰框架差异其实没那么重要。6. 踩坑记录与TDD的铁律6.1 坑一测试里sleep等待异步一跑就flaky刚开始在Jest侧写秒杀测试时我犯过一个经典错误在测试里写await new Promise(r setTimeout(r, 1000))来等异步任务完成比如等MQ消息被消费、等Redis的key过期。结果本地能过CI上偶尔能过偶尔挂排查了很久才定位到是sleep时间不够碰上CI机器负载高异步任务还没执行完断言就先跑了。正确的做法是用测试框架提供的可控时间工具。Jest里是fake timersJUnit里是Awaitility这个库它可以轮询等待某个条件成立超时再失败。await().atMost(5, TimeUnit.SECONDS) .untilAsserted(() - assertThat(mqMessage.getPayload()).isEqualTo(stock-deducted));6.2 坑二mock了Redis但没mock序列化测试全绿线上崩有一次在JUnit侧写集成测试mock掉了RedisTemplate之后测试飞快全绿。上线后发现库存扣减成功后Redis里存的库存数据掏出来变了形反序列化直接报错。排查下来是RedisTemplate的value序列化器配置成了JDK序列化而生产环境又改用了JSON序列化两边的配置不一致。这个问题的根源是单元测试把Redis整个mock掉根本没有触及序列化这个真实环节。我的体会是凡是涉及跨进程数据格式的测试比如Redis的key-value格式、MQ的消息体、下游HTTP接口的JSON结构必须保留至少一个集成测试走真实的序列化和反序列化链路不能全mock。6.3 坑三JUnit测试方法间的状态污染JUnit的测试方法默认复用同一个测试类实例所以实例字段是会跨方法共享的。有一次我在一个测试类里放了一个Autowired的缓存管理器第一个测试往缓存里写了一条数据第二个测试没清理断言库存为0就失败了。解决办法是给每个测试方法加BeforeEach做状态重置。Jest那边因为每个测试文件独立运行而且测试文件之间不共享JVM实例这个问题很少见但同一测试文件里的全局变量还是有污染风险。我的习惯是Jest测试文件里所有变量都在beforeEach里重新赋值避免测试之间互相咬合。6.4 铁律一每个线上Bug先写回归测试我参与秒杀系统开发时定过一条规矩线上出的每个Bug必须先用TDD写一个能复现它的回归测试再动手修代码。这样做的好处是改完代码之后跑一下回归测试就知道这个问题是不是真的解决了而不是靠“感觉应该没问题了”。我们遇到过一个非常隐蔽的Bug秒杀活动配置的时间用的是字符串有一个时间解析的地方用了系统默认时区导致活动开始时间在不同服务器上差了8小时。这个Bug排查了很久定位后我写了一个回归测试把系统默认时区设置成UTC断言活动开始时间解析后不会偏差。测试完成后我顺手把这个用例加进CI以后谁再改时区逻辑跑一遍测试就知道有没有把业务时间搞乱。6.5 铁律二测试代码的评审标准和业务代码一样高很多人觉得测试代码只要能跑看不看得懂无所谓。做了这么久TDD我的看法正好相反。测试代码是系统行为的活文档它的可读性直接影响团队后续的维护效率。我在评审测试代码时重点关注三件事测试方法名是否清楚地表达了业务场景断言是不是足够精准有没有“为了过测试而过测试”的水分比如把断言写在没被await的Promise里。拿秒杀库存的测试来说我见过有人写assertThat(result).isNotNull();这种断言等于没写因为一个返回null的失败实现也会让这个断言变红但一个返回了错误successfalse的实现却能让它变绿。好的断言应该直接锁定行为assertThat(result.isSuccess()).isTrue(); assertThat(result.getRemainingStock()).isEqualTo(9);测试代码写得越具体回归保护的能力越强。这一点不管是在Jest还是在JUnit项目里都适用。一点个人体会在秒杀系统这样高并发、高风险的业务里推行TDD最明显的收益其实是安全感。Jest和JUnit的语法机制各有各的顺手之处但真正改变团队状态的是那条“测试先行”的流程。你会在写实现之前先思考这个接口该怎么设计、这个规则怎么验证、这个边界怎么处理而不是写完之后再回头补测试。我自己用了这么久的一个判断标准是如果新写的业务代码在某一个测试步骤中感觉无从下手那大概率不是测试能力不够而是设计出了问题——要么依赖太深要么职责太杂。这时候回头重构设计往往比硬着头皮把测试写出来更有价值。秒杀系统设计几十年积累了那么多模式本质上都是在和不确定性做斗争。测试做得越扎实上线的时候心态就越是稳。希望这篇Jest和JUnit的实战对比能帮你在自己的秒杀项目里少踩几个坑。