
抢购开始后第 17 秒监控面板上的超卖订单数从 0 跳到 137我后背凉了半截。那是我第一次意识到在秒杀系统里测试驱动开发不是流程团队贴在墙上的仪式感而是保命的手段。之后我把这套秒杀系统的前端React TypeScript和后端Spring Boot整体用 TDD 重写了一遍过程中最大的收获不是修复了某个具体 Bug而是把 Jest 和 JUnit 两种测试框架在真实高并发场景下的能力边界彻底摸透了。这篇文章是那段时间的完整复盘包含两种框架在异步、时间控制、Mock、并发测试上的实测差异以及我在红灯-绿灯-重构循环里踩过的坑。适合正在做秒杀、抢购、限时活动系统的团队参考也适合想系统理解 Jest 和 JUnit 在 TDD 实战中定位差异的全栈工程师。1. 秒杀系统为什么需要TDD先看清这场测试的真正难点1.1 秒杀的技术诉求不只是高并发一说到秒杀大部分人的第一反应是扛住高并发。真做过之后才发现高并发只是最表面的那个问题藏在后面的是三个很容易被忽略的硬约束。第一是超卖控制。库存 500 件系统只能卖掉 500 件多卖一件不是 Bug而是事故。第二是幂等控制。用户抢购的请求在弱网下会被重试同一笔订单如果因为网络重发导致创建两单属于资损级别的问题。第三是状态一致性。从可抢购到已售罄从前端按钮到后端库存状态切换的时点和展示必须对得上。这三个约束有一个共同特点它们都发生在边界条件下。正常路径谁都会写对真正出问题的永远是刚好抢完的那一瞬间刚好同一秒重复点击两次刚好两个线程同时读到库存为 1。而测试的价值恰恰就在于把这些刚好变成可以在本地反复验证的确定性场景。1.2 先测试后实现的收益在秒杀场景被放大TDD 在普通 CRUD 业务里可能显得有点形式主义但在秒杀系统里收益会被放大好几倍原因有二。一是秒杀系统的实现方案会高频重构。今天用乐观锁扣库存明天为了降数据库压力改成 Redis Lua 原子扣减加异步落库今天前端用 setInterval 做倒计时明天为了省电改成基于时间戳差值的计算。如果没有一批先写好的行为契约测试每次重构都是拿线上环境当测试环境。TDD 先把行为钉死实现换几轮都心里有底。二是秒杀的状态分支太多。库存足够库存刚好只剩 1 件用户重复提交请求频率超限倒计时未归零用户就点击——这些分支如果等实现完了再补测试往往只会挑最简单的几路写反过来先写测试用例天然会把边界条件覆盖全因为你不把边界用例写进红灯阶段实现就永远不会走到那些分支。1.3 为什么是 Jest 和 JUnit 放在一起比这套系统是标准的前后端分离前端 React 技术栈测试框架自然是 Jest后端 Spring Boot测试框架是 JUnit 5。很多团队的痛点是前后端测试各自为政没人站在 TDD 方法论的角度把两者拉通看。拉通之后会发现一个很有意思的互补秒杀系统的时间敏感逻辑大量集中在前端倒计时、节流、轮询Jest 的时间控制能力极其顺手而数据一致性、并发控制集中在后端JUnit 的线程级测试配合真实数据库能逼出最棘手的竞态问题。这篇文章的对比本质上是同一套秒杀业务在不同技术栈里的测试打法差异。2. Jest实战倒计时、防重复提交与接口Mock的测试先行2.1 倒计时组件用Fake Timers把时间变成可控变量倒计时是秒杀前端最核心的组件也是最不适合拿真实时间测的东西。你不能真的等 5 秒去看状态变化那样测试又慢又不稳定。Jest 的 Fake Timers 把时间变成了一个可控变量。我先写测试定义倒计时结束之后按钮应该从禁用变为可点这个行为import { render, screen, act } from testing-library/react; import Countdown from ./Countdown; describe(Countdown 倒计时组件, () { beforeEach(() { jest.useFakeTimers(); jest.setSystemTime(new Date(2025-06-18T20:59:55)); }); afterEach(() { jest.useRealTimers(); }); test(倒计时归零后按钮状态从禁用切换为可抢购, () { render(Countdown endTime{new Date(2025-06-18T21:00:00)} /); expect(screen.getByText(05)).toBeInTheDocument(); expect(screen.getByRole(button, { name: /即将开始/ })).toBeDisabled(); act(() { jest.advanceTimersByTime(5000); }); expect(screen.getByText(00)).toBeInTheDocument(); expect(screen.getByRole(button, { name: /立即抢购/ })).toBeEnabled(); }); });这个用例当时是红的因为组件根本不存在。然后我用 setInterval 每秒减一的方式实现了 Countdown测试变绿。再后来我把实现重构为每次渲染时用 endTime - Date.now() 计算剩余秒数只保留一个每秒触发一次状态更新的 setInterval测试依然锁定行为重构才敢放开手脚。这里有个细节值得多说一句setSystemTime 特别适合秒杀这种活动时间敏感的场景。你可以直接模拟距离开始还有 5 秒距离结束还有 1 秒活动刚结束 0.1 秒等各种尴尬时刻真实环境里这些时间窗稍纵即逝测试里却可以随意定格。2.2 抢购按钮防重复提交先定义只能发一次秒杀开始的第一秒用户的典型操作是疯狂点击按钮。如果前端不做任何防护一个用户瞬间就能打出去十几条请求后端再强的限流也会被无效流量拖累。防重复提交的测试用例我定的是一秒内连续点击只能触发一次请求import { throttledSubmit } from ./submit; test(同一秒内连续点击只提交一次, () { const submit jest.fn(); const throttled throttledSubmit(submit, 1000); throttled(1); throttled(2); throttled(3); expect(submit).toHaveBeenCalledTimes(1); });红灯阶段这个测试必挂因为 throttledSubmit 还不存在。实现时记录上一次调用时间窗口内直接丢弃即可。跑了这个测试之后我又加了一条用例超过 1 秒后再次点击应该触发第二次请求。这条用例很关键它逼着实现保持窗口期外可以继续提交的能力而不是简单粗暴地只允许提交一次否则用户第一次点击失败后就再也没机会抢购了。同样的思路也可以用来约束轮询库存接口的行为。比如抢购中状态下每 3 秒轮询一次库存轮询逻辑一样可以用 Fake Timers 驱动测试里直接断言轮询函数被调用的次数而不是真的让测试去等 3 秒。2.3 接口Mock把后端依赖从前端测试里剔除前端的 TDD 必须保证一件事测试不依赖后端环境。否则后端接口一挂前端测试跟着红没人分得清是谁的问题。Jest 的模块 Mock 做这件事非常干净jest.mock(../api/seckill, () ({ seckill: jest.fn() .mockResolvedValueOnce({ code: 0, data: { orderNo: SN001 } }) .mockRejectedValueOnce({ code: 500, message: 系统繁忙 }), }));配合这个 Mock我可以分别验证抢购成功和失败的完整 UI 状态流转点击后进入 loading、请求成功后展示订单号、请求失败后按钮恢复可点并展示错误提示。三个用例把用户发起抢购这条链路的用户可见行为全部钉死。这里我要提醒一点Mock 层如果太深会掩盖真实接口的返回结构变化。前端测试 Mock 掉的只是网络这个不可控因素接口字段本身的正确性应该由契约测试或联调用例兜底不能因为前端测试全绿就觉得前后端对接万无一失。3. JUnit实战先写并发、幂等、限流用例再让实现去满足它们3.1 并发不超卖一个用例逼出三种扣库存方案后端 TDD 最值得写的一个用例就是并发场景下库存不超卖。这个用例直接定义了秒杀系统的核心数据契约无论多少线程同时抢购扣减成功的数量不能超过初始库存剩余库存也不能为负。Test DisplayName(100个线程并发抢购50件库存成功数不超过库存且剩余库存不为负) void concurrentDeductShouldNotCauseOversell() throws Exception { int stock 50; int threadCount 100; stockService.resetStock(SKU_ID, stock); ExecutorService pool Executors.newFixedThreadPool(16); CountDownLatch ready new CountDownLatch(threadCount); CountDownLatch start new CountDownLatch(1); FutureInteger[] futures new Future[threadCount]; for (int i 0; i threadCount; i) { futures[i] pool.submit(() - { ready.countDown(); start.await(); return stockService.deductStock(SKU_ID, 1) ? 1 : 0; }); } ready.await(); start.countDown(); int successCount 0; for (FutureInteger f : futures) { successCount f.get(); } pool.shutdown(); assertTrue(successCount stock); assertEquals(stock - successCount, stockService.getRemaining(SKU_ID)); }这个测试在红灯阶段几乎是必然失败的。用先 SELECT 判断库存再 UPDATE这种 check-then-act 写法50 件库存能卖出去 100 件。为了让测试变绿实现层我先后试过乐观锁UPDATE 语句中带 remaining 条件、悲观锁SELECT FOR UPDATE和 Redis Lua 脚本原子扣减。有意思的是这三种实现都能让这个用例通过而这个用例本身并不过问你是用锁还是用原子操作——它锁住的是行为契约这恰恰是 TDD 想要的效果。不过这种并发用例是出了名的偶发失败跑了 50 次挂 1 次的情况我见过太多次。为了让它稳定我会把线程数、库存数、执行次数调成可配置的参数并且在同一轮测试里循环跑 3 到 5 遍任何一轮失败都算失败把偶发问题尽量拉成确定性失败。3.2 事务边界为什么并发测试里不能随手加Transactional这是我个人踩得最深的一个坑。刚开始写 Spring Boot 测试时习惯性地给测试方法加 Transactional 依赖回滚保证数据库不被污染。这个习惯在普通 CRUD 测试里没问题但一放到并发测试里就灾难了。原因在于测试方法的事务是测试线程私有的子线程拿不到同一个事务上下文。并发子线程操作的数据要么看不到主线程事务里未提交的内容要么在主线程回滚时留下脏数据。结果就是并发断言时好时坏跟彩票中奖一样随机。后来我把并发测试改成不依赖测试事务而是让每个用例在 BeforeEach 里重置库存数据断言也只针对最终落库的状态这才彻底稳定。如果你希望并发测试走真实数据库强烈建议用 Testcontainers 起一个干净的 MySQL 实例而不是用 H2。H2 在事务隔离级别和锁行为上和 MySQL 有差异有些并发的坑在 H2 上根本复现不出来测试全绿了一上生产就原形毕露。3.3 幂等与限流把行为契约先钉死再谈实现秒杀系统的另一个核心契约是幂等同一用户同一场次的同一笔抢购重复提交只能成功一次。我先写用例再实现Test DisplayName(同一幂等键重复提交只会创建一个订单) void duplicateOrderNoShouldCreateOneOrder() { String orderNo SN202506180001; SeckillRequest request new SeckillRequest(userId, skuId, orderNo); assertTrue(orderService.submitSeckill(request)); assertThrows(DuplicateSubmitException.class, () - orderService.submitSeckill(request)); assertEquals(1, orderRepository.countByOrderNo(orderNo)); }这个用例最狠的地方在于最后一行断言不仅第二次提交要抛异常订单表里也必须真的只有一条记录。只靠先查重再插入显然是过不了并发场景的必须靠数据库唯一索引配合捕获 DuplicateKeyException 才行。这个结论不是我拍脑袋想出来的是这个测试在真实数据库上反复跑出来的。限流也一样滑动窗口、令牌桶、计数器不同算法实现的性能特性不同但对用户呈现的行为契约是统一的窗口内超过阈值就拒绝。把这条契约写成用例算法随便换行为不变。下面这个用例钉死的是滑动窗口限流1 秒内最多放行 3 次第 4 次必须被拒绝Test DisplayName(滑动窗口限流窗口内超过阈值被拒绝) void slidingWindowRejectExcessiveRequests() { RateLimiter limiter new SlidingWindowRateLimiter(3, 1000); assertTrue(limiter.tryAcquire(user-1)); assertTrue(limiter.tryAcquire(user-1)); assertTrue(limiter.tryAcquire(user-1)); assertFalse(limiter.tryAcquire(user-1)); }这类用例的价值在于它把限流、幂等这些偏中间件的逻辑和具体实现解耦了。你从 Guava 切换到自己实现的滑动窗口从单机限流切到 Redis 集群限流测试不用动跑一遍就知道行为有没有被改坏。4. Jest与JUnit横向对比同一套TDD循环里的体验差异4.1 测试组织与断言谁更适合测试即文档先放一张我在团队内部培训时常用的对照表维度JestJUnit 5用例组织describe / test / itTest 方法 Nested 类用例描述it(should ...) 一句话描述DisplayName(中文业务描述)断言风格内置 expect 链matcher 丰富自带断言偏基础一般配 AssertJ参数化test.each([...])ParameterizedTest ValueSource/CsvSource超时控制testTimeout 全局或单用例Timeout(seconds n)Mock内置 jest.fn / jest.mock / jest.spyOn需要单独引入 Mockito用下来的直观感受是Jest 的用例读起来更像业务需求。it(should disable button when countdown not finished)加上expect(...).toBeDisabled()的链式断言几乎是自然语言。JUnit 5 的 DisplayName 也能达到类似效果但它不是强制项团队里一旦有人偷懒测试方法名很快就变成 test1、test2 这种毫无信息量的东西。想做好 TDD 的测试即文档Jest 的门槛天然低一些JUnit 更依赖团队规范。4.2 Mock能力模块级Mock与对象级MockJest 的 Mock 体系是内建的最顺手的是模块级 Mock。前端代码依赖的是 import 进来的模块jest.mock 可以直接把一个模块整体替换掉不需要依赖注入容器。这对测试 React 组件非常友好状态管理、API 层、工具函数都可以逐层替换。JUnit 侧通常配合 Mockito是典型的对象级 Mock。Mock 一个 UserMapperInjectMocks 注入到 Service 里再用 when().thenReturn() 指定行为。这种方式的粒度高但要求在写实现时就考虑到依赖注入的可替换性。对于 TDD 来说这反而倒逼你把服务拆得更干净——一个 Service 如果内部 new 了一堆对象Mock 根本无处下手你会被迫改成构造器注入。有一个很现实的差异前端 Mock 太容易容易到可以顺手把 API 层 Mock 掉结果就是测试对真实接口结构不敏感后端因为 Mock 一般只到 Mapper 这一层更大的价值在于逼着团队写一批不 Mock 的真实仓储测试。Mock 能解决单元测试的隔离问题但解决不了接口契约的问题这一点前后端都一样。4.3 异步与时间控制前端控时间后端控线程异步测试是这套秒杀项目里 Jest 和 JUnit 差异最明显的地方。Jest 对异步原生的支持非常好async/await 直接可测配合 Fake Timers 还能控制定时器逻辑前面倒计时的例子已经展示了这个能力。它的短板是 Fake Timers 和真实 Promise 混用时会出一些诡异问题后面我会专门讲。JUnit 测异步的方法是真的把线程跑起来。无论是 ExecutorService 并发扣库存还是 CompletableFuture 异步落库JUnit 的测试本质上是真实多线程的演练。为了断言异步结果通常需要 CountDownLatch、Future.get() 或 Awaitility 轮询等待。控制时间则比较麻烦一般通过注入一个可变的 Clock 实现或者干脆把时间相关的逻辑包一层接口再 Mock。所以秒杀场景里的分工很自然前端的时间敏感逻辑交给 Jest 的 Fake Timers后端的并发一致性交给 JUnit 的真实线程两边各管一段合起来覆盖完整。4.4 运行速度与反馈闭环TDD 最怕测试慢TDD 的红灯-绿灯-重构循环依赖快速反馈。如果一次测试要跑一两分钟开发者很快就会放弃在改代码后频繁跑测试TDD 就名存实亡了。Jest 在 jsdom 环境下跑纯逻辑和轻量组件测试非常快毫秒级反馈。但一旦组件树的依赖变重、渲染层级变深速度会指数级下降。我后来的做法是把前端测试分成两层纯逻辑和轻量组件走 Jest 默认环境全链路组件冒烟测试单独放到 pre-push 或 CI 阶段跑。这样日常开发始终处于秒级反馈。JUnit 这边最大的速度杀手是 SpringBootTest。每跑一次都要启动完整 Spring 容器项目大了以后动辄十几秒。后来我把测试按是否依赖 Spring 上下文拆开纯 Service 单元测试不启动容器直接 new 目标类加 Mockito只有 Mapper 和需要真实事务的测试才用 MybatisTest 这类切片测试真正全链路集成测试数量控制在个位数。这套分层之后CI 全量测试从原来的半个多小时压缩到 8 分钟以内TDD 循环终于转得起来了。5. 踩坑记录两个框架各自把我坑得最惨的一次5.1 Jest Fake Timers 与真实 Promise 的时间断层有一次我写前端抢购流程的测试点击按钮后组件内部先 setTimeout 延时 300ms再调用 API等 API 返回后更新 UI。我用 jest.advanceTimersByTime(300) 推动时间然后等待 UI 变化结果要么状态一直不更新要么偶尔更新但断言时数据不对。后来才搞清楚原因Fake Timers 劫持的是 setTimeout、setInterval 这类宏任务但 Promise 的回调走的是微任务队列两者并不完全同步。advanceTimersByTime 只往前走宏任务的钟微任务没有来得及执行UI 自然停在旧状态。解决方法是把推进操作包在 async act 里用 advanceTimersByTimeAsync 或先 runAllTimers 再手动 await 一次微任务。这个坑的教训是用 Fake Timers 时要意识到时间推进和异步回调执行是两回事凡是涉及 Promise 的用例统一用 async act 包裹推进动作。5.2 JUnit并发测试里的共享状态污染后端并发测试另一个让人头秃的问题是共享状态。我维护的一个限流组件内部用 static 集合记录请求计数第一个用例跑完计数没清干净第二个用例的断言忽绿忽红。排查了很久最后发现是 ConcurrentHashMap 里的旧 key 一直残留BeforeEach 里没有重置静态资源。更隐蔽的一个坑发生在 JUnit 5 开启并行执行之后。多个测试类同时跑如果它们共用同一个数据库 schema而没有各自隔离的测试数据偶发失败会让人怀疑人生。我的处理方式是静态状态必须在 BeforeEach 重置每个用例尽量使用唯一的数据 key比如带 UUID 的订单号数据库测试统一走 Testcontainers每个测试类一个容器实例互不干扰。这些经验听起来像废话但每一句都是拿真实的失败换来的。5.3 测试全绿但线上还是崩了的两个案例如果说前面两个坑是测试写不好那这个案例是测试写得太好了反而产生了虚假的安全感。第一个案例在前端。倒计时组件测试里我用 jest.setSystemTime 固定了本机时间测试全绿。上线后部分用户反馈活动还没开始就显示已结束。排查发现这些用户设备的本地时间和服务器时间偏差超过 5 秒组件用本地时间计算剩余时长自然提前归零。修复方案是改用服务器时间偏移量计算同时补了一个设备时间与服务器偏差 10 秒时倒计时仍然正确的测试用例。这个案例说明时间敏感的测试不仅要测时间正常流逝还要测外部时间环境偏移。第二个案例在后端。幂等用例最初是 Mock 掉仓储层跑的按 orderNo 查重返回 false测试全绿。等真实联调上线预热时压测脚本用同一幂等键并发提交瞬间生成了 3 条订单。根因跟前面说的一样check-then-act 的查重逻辑在并发下根本不是原子操作。后来去掉了 Mock改用真实数据库跑幂等并发用例测试当场就红了。从那以后我定了一条规矩涉及并发正确性的用例禁止 Mock 仓储层必须走真实数据库否则绿灯没有意义。6. 秒杀系统里Jest与JUnit的落地分工与团队实践建议6.1 两端各自守住什么四条例行的硬性指标秒杀系统的质量底线可以拆成两个问题用户在前端看到的对不对数据在库里落得对不对。前端由 Jest 守重点用例是倒计时状态转换、防重复提交、抢购结果反馈后端由 JUnit 守重点用例是并发不超卖、幂等不重复、限流不超阈。两端各有各的主战场没有谁取代谁的问题。我个人在每个迭代里的硬性指标就四条前端倒计时与按钮状态测试必须绿、防重复提交测试必须绿、后端并发扣减不超卖测试必须绿、幂等创建订单测试必须绿。这四条守住秒杀系统不管怎么重构我都敢上线。6.2 红灯-绿灯-重构在两种框架里的不同节奏同样是 TDD 循环前后端的节奏感很不一样。前端因为反馈快红灯时间通常在几分钟内你写一个用例跑一个用例很快就能推进后端的红灯阶段往往更长因为你要设计并发用例、准备数据、还可能启动容器一个用例从写到绿可能花半小时。为了避免后端红灯阶段过久消磨耐心我会先把用例简化到最小可复现的模型先用内存 Map 模拟仓储验证并发逻辑本身再把实现切到真实数据库验证。重构阶段的差别也大。前端重构大多发生在组件内部测试稳定性很高后端重构常常涉及方案替换比如乐观锁换 Redis Lua、同步扣库存换异步削峰。方案替换期间老用例在一段时间内必然红需要明确哪些是实现变了导致行为暂时不符哪些是重构引入了真 Bug不要一刀切地追求全程全绿但要保证重构完成的那一刻所有契约用例恢复绿。6.3 团队落地时最容易忽略的三件事第一测试基础设施要舍得投入。并发测试走 Testcontainers、CI 里跑完整用例集、测试报告可视化这些都要时间和资源但它们是 TDD 能长期跑下去的前提。舍不得这笔投入TDD 就会沦为只在本地写几个用例的摆设。第二关键用例代码评审时逐行看。秒杀系统的并发用例、幂等用例和限流用例我建议评审时逐行过因为这类用例的写法直接决定它们能不能真实复现线上问题。Mock 边界、事务处理、数据清理都是容易蒙混过关的地方。第三线上事故之后先补用例再修代码。事后复盘如果只是改了 Bug同类问题大概率会在别的地方再次出现。先写一个能复现事故的测试用例看着它从红变绿才算真正修复。这个习惯在秒杀这种高并发场景里尤其值钱因为很多事故的复现窗口极其短暂不在事故现场留下用例过两天你可能连复现路径都想不起来了。最后说点个人体会。在这个项目之前我对 Jest 和 JUnit 的认知停留在前端框架后端框架这种贴标签的层面被秒杀系统这么一逼才真正理解了它们在测试哲学上的互补Jest 擅长把不可控的时间变成可控变量JUnit 擅长把隐藏的并发竞态变成可见的确定性失败。秒杀系统的复杂度恰好同时击中了两者的痛点也恰好把它们各自的优势发挥到了极致。如果你也在做类似的系统我的建议是从上面那四条核心用例开始先让它们在你的项目里跑起来。测试框架选型从来不是重点重点是有没有一批真正能锁死业务行为的测试在每次重构和上线前替你守住底线。