尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

对比 JUnit 与 Jest:秒杀库存扣减场景中的 TDD 实践

对比 JUnit 与 Jest:秒杀库存扣减场景中的 TDD 实践 写秒杀系统怕的不是代码多而是每次动库存相关的逻辑都手心冒汗。库存扣多一分是超卖直接经济损失扣少一分是用户体验崩坏活动还没结束用户就先怒了。这种情况下测试驱动开发TDD几乎是唯一能让人睡踏实的工作方式——先让测试把规则钉死再写实现改代码时回归测试一跑心里就有底了。最近我干了一件挺有意思的事用同一个秒杀库存扣减场景分别在 Java 生态用 JUnit 5、在 TypeScript 生态用 Jest各走了一遍完整 TDD 流程。两边都实现了“正常扣减、库存不足拒绝、并发不超卖”这三个核心约束代码都能跑测试都是先红后绿。但两套框架在实际体感上差别很大尤其是失败信息表达、异步并发测试写法、Mock 方式这几个点。这篇就把整个过程、代码细节和踩过的坑一起写出来。这篇文章适合正在做交易、秒杀、抢购类业务的后端开发也适合团队刚开始推行 TDD、想知道不同技术栈下怎么落地的人。不用纠结你是 Java 派还是 JS 派重点是看同一套思维在不同框架里是怎么表达的。1. 先说说这个对比是怎么来的1.1 为什么拿秒杀系统当TDD的样板秒杀系统里最核心、最容易被改坏的就是库存扣减。它的业务规则非常明确库存有限、先到先得、不能超卖、同一用户通常限购。规则明确意味着测试用例好写断言结果清晰适合用来练习 TDD。更重要的是库存扣减的错误代价极高。超卖一旦发生要么靠事后对账发现要么靠用户投诉发现那时已经造成真金白银的损失。TDD 的做法是在写实现之前先把“绝对不超卖”这条规则变成自动化断言每一次改动都跑一遍从机制上守住底线。选择秒杀场景还有一个好处它的逻辑复杂度适中但并发语义很丰富。版本号、CAS、乐观锁、重试机制这些在实际生产里至关重要的细节都能在这个场景里被自然地驱动出来。换句话说TDD 不是为了测试而测试而是通过测试把设计逼出来。1.2 为什么同时选Jest和JUnit现实里很多公司的技术栈是分裂的。核心交易系统用 Java单元测试自然是 JUnit营销活动、边缘服务或全栈项目用 TypeScript测试框架多半是 Jest。两边都在写业务、都要保证质量但测试代码的风格、工具链、思维方式完全不同。把同一个需求用两套框架各自 TDD 一遍能给团队带来一个直接的参照系当你从 Java 项目切到 Node.js 项目或者反过来不需要重新摸索 TDD 的节奏只需要理解当前框架的“表达习惯”就行。另外Jest 和 JUnit 代表了两种典型的测试框架设计思路。JUnit 本身比较克制只提供测试生命周期和基础断言更强大的断言、Mock、并发工具要靠 AssertJ、Mockito、Awaitility 这些周边生态来补。Jest 则是集成度极高的全家桶断言、Mock、覆盖率、异步测试、并发测试一把梭开箱即用。这种设计哲学差异在秒杀这种需要大量 Mock 和并发验证的场景里体感非常明显。1.3 我执行TDD时的基本循环前后两套实现我都严格遵守“红-绿-重构”三步循环。第一步写一个最小失败的测试运行确认它在预期的地方失败第二步用最简单的方式让测试通过第三步在测试保护下重构代码让结构更干净。这个循环最难的不是“写测试”而是“接受测试先失败”。很多新手会忍不住先写实现再把测试补上那不是 TDD只是“事后补测试”。TDD 的价值恰恰在于先失败失败信息就是在告诉你业务规则和代码行为之间目前存在什么差距。我先用 JUnit 把 Java 版本跑顺再用 Jest 把 TypeScript 版本跑顺两边都严格按照红绿重构来才有资格去对比两个框架。2. 测试先行把秒杀规则翻译成失败测试2.1 秒杀库存扣减的需求清单动笔写测试之前我先把需求整理成一条可验证的清单。不要小看这一步秒杀场景最怕的就是需求边界没想清楚就开写。库存充足时按请求数量扣减库存扣减后库存准确。库存不足时拒绝扣减库存保持不变。并发请求下总成功扣减数不能超过初始库存也就是不超卖。重复扣减应当有幂等或防重机制这个我放在扩展场景第一轮 TDD 先不碰。库存扣减的并发控制需要依赖版本号或等价机制不能靠简单读改写。这份清单看起来最简单但它是整个 TDD 过程的锚点。每条规则都要对应至少一个测试用例测试通过才算这条规则落地。秒杀团队经常犯的错是把精力全花在限流、缓存这些外围架构上反而把库存扣减这种核心链路的规则模糊化了这是本末倒置。2.2 先写哪条测试为什么TDD 的常见问题是不知道从哪里开始。我的经验是先写最简单的正常路径也就是“库存充足能扣成功”。理由很简单正常路径的测试代码最直观断言最容易理解能先把测试基础设施跑通。正常路径跑通后立刻写异常路径“库存不足要拒绝”。这条规则直接对应秒杀场景最典型的业务约束能逼着你去定义失败时的返回语义——是返回 false、抛异常还是返回错误码不同选择会直接影响上层调用方的写法。我在 Java 版本里选择了返回 boolean 加自定义异常的方式在 TypeScript 版本里选择了返回 boolean这是为了保持两个版本的可比性语言习惯上有些微差异但测试断言都能清晰表达意图。正常和异常两条路径都测试通过后才进入最难的并发测试。我强烈建议不要在第一个 TDD 循环里就写并发测试因为并发测试失败时信息不够直观如果正常路径都还没稳定排查起来会把简单问题复杂化。2.3 并发场景到底能不能用TDD覆盖我必须坦白说单元测试里的并发验证和压测、生产环境下的并发是两回事。用线程池或 Promise.all 模拟十几个并发请求并不能证明系统在十万 QPS 下不超卖这两者之间有本质差距。但 TDD 里的并发测试仍然很有价值它能验证的是“服务层的并发控制逻辑是否正确”。比如乐观锁的重试机制有没有写对、版本号冲突时的处理是否符合预期、CAS 失败后是否会导致错误失败。这些逻辑如果不通过并发测试驱动出来等到集成测试阶段才发现定位成本会高得多。所以在做并发测试时我刻意把它定位成“逻辑正确性验证”而不是“性能验证”。断言的目标是最终库存不超卖、成功数正确而不是响应时间达标。真正的高并发验证需要靠 JMeter、wrk、K6 这些压测工具和灰度监控来兜底TDD 解决的是代码正确性问题不是容量问题。3. 实战同一段逻辑JUnit和Jest各自怎么TDD3.1 JUnit侧的完整TDD过程Java 版本我用的是 JUnit 5配合 AssertJ 做断言。为了不依赖 Spring 容器我把库存存储抽象成接口单元测试里用内存实现这样 TDD 循环跑得极快不需要启动任何框架。先定义库存存储接口和库存对象这是测试能写下去的基础public record StockInfo(Long skuId, int quantity, int version) {} public interface StockRepository { StockInfo getStock(Long skuId); boolean tryDecrease(Long skuId, int expectVersion, int newQuantity); }第一轮 TDD我先写一个会失败的测试库存充足时能扣减成功。运行前我明确知道它是因为 StockKeeper 类还不存在而失败这是符合预期的红。class StockKeeperTest { private StockRepository repo; private StockKeeper keeper; BeforeEach void setUp() { repo new InMemoryStockRepository(); keeper new StockKeeper(repo); } Test void deductWithEnoughStockShouldSucceed() { repo.save(new StockInfo(1L, 10, 0)); boolean ok keeper.tryDeduct(1L, 3); assertThat(ok).isTrue(); assertThat(repo.getStock(1L).quantity()).isEqualTo(7); assertThat(repo.getStock(1L).version()).isEqualTo(1); } }写完测试先跑一次确认它确实失败然后再写 StockKeeper 的最简实现public class StockKeeper { private final StockRepository repo; public StockKeeper(StockRepository repo) { this.repo repo; } public boolean tryDeduct(Long skuId, int quantity) { StockInfo stock repo.getStock(skuId); if (stock null || stock.quantity() quantity) { return false; } return repo.tryDecrease(skuId, stock.version(), stock.quantity() - quantity); } }这个实现的核心是先查后写用版本号做乐观锁。库存不足直接返回 false不修改任何数据。这是秒杀库存扣减最典型的实现方式也最容易理解。接下来补库存不足的测试Test void deductWithInsufficientStockShouldFail() { repo.save(new StockInfo(1L, 2, 0)); boolean ok keeper.tryDeduct(1L, 5); assertThat(ok).isFalse(); assertThat(repo.getStock(1L).quantity()).isEqualTo(2); }到这里普通逻辑已经通过测试保护了。但秒杀系统真正的考验是并发所以我写了第三个测试用线程池模拟 20 个用户同时抢 10 件库存Test void concurrentDeductShouldNotExceedStock() throws Exception { repo.save(new StockInfo(1L, 10, 0)); int threads 20; ExecutorService pool Executors.newFixedThreadPool(threads); CountDownLatch ready new CountDownLatch(threads); CountDownLatch go new CountDownLatch(1); ListFutureBoolean futures new ArrayList(); for (int i 0; i threads; i) { futures.add(pool.submit(() - { ready.countDown(); go.await(); return keeper.tryDeduct(1L, 1); })); } ready.await(); go.countDown(); long successCount 0; for (FutureBoolean f : futures) { if (f.get()) { successCount; } } pool.shutdown(); assertThat(successCount).isEqualTo(10); assertThat(repo.getStock(1L).quantity()).isZero(); }这里有个关键细节InMemoryStockRepository 的 tryDecrease 必须是原子的否则单元测试本身就不可靠。我用 synchronized 修饰内存实现的关键方法保证同一时间只有一个线程能执行版本号比较和更新。真实生产里这个方法的原子性由 Redis Lua 脚本或数据库的原子 SQL 来保证但测试替身也必须模拟这种语义不然测试结果没有参考价值。public class InMemoryStockRepository implements StockRepository { private final MapLong, StockInfo stockMap new ConcurrentHashMap(); public synchronized boolean tryDecrease(Long skuId, int expectVersion, int newQuantity) { StockInfo current stockMap.get(skuId); if (current null || current.version() ! expectVersion) { return false; } stockMap.put(skuId, new StockInfo(skuId, newQuantity, expectVersion 1)); return true; } }这套并发测试在 JUnit 里跑得很稳定配合 CountDownLatch 能让 20 个线程尽量在同一时刻争抢库存模拟出竞争条件。JUnit 在这块的定位是给你提供线程编排的原语具体怎么编排是你的责任它对并发测试没有提供更高级的抽象。3.2 Jest侧的完整TDD过程TypeScript 版本我用 Jest 重写同一套逻辑。为了语言自然度我把接口命名调整为更接近 JS 生态的风格但核心逻辑和 Java 版本保持对称。先定义库存仓储接口和服务类type StockInfo { skuId: string; quantity: number; version: number }; interface StockStore { get(skuId: string): PromiseStockInfo | null; compareAndSet( skuId: string, expectVersion: number, newQuantity: number, newVersion: number ): Promiseboolean; } class InventoryService { constructor(private store: StockStore) {} async tryDeduct(skuId: string, quantity: number, maxRetry 3): Promiseboolean { for (let attempt 0; attempt maxRetry; attempt) { const stock await this.store.get(skuId); if (!stock || stock.quantity quantity) { return false; } const success await this.store.compareAndSet( skuId, stock.version, stock.quantity - quantity, stock.version 1 ); if (success) { return true; } } return false; } }注意这里我引入了重试机制。CAS 冲突在高并发下是正常现象Jest 版本的测试中我会刻意制造 CAS 失败来验证重试逻辑。这一点比 Java 版本更进了一步因为真实秒杀场景里乐观锁第一次不成功是常态必须要有重试策略。对应的 Jest 测试describe(InventoryService 库存扣减 TDD, () { let store: InMemoryStockStore; let inventory: InventoryService; beforeEach(() { store new InMemoryStockStore(); inventory new InventoryService(store); }); it(库存充足时扣减成功, async () { await store.init(SKU-1001, 10); const ok await inventory.tryDeduct(SKU-1001, 3); expect(ok).toBe(true); expect(await store.get(SKU-1001)).toMatchObject({ quantity: 7, version: 1, }); }); it(库存不足时拒绝扣减, async () { await store.init(SKU-1001, 2); const ok await inventory.tryDeduct(SKU-1001, 5); expect(ok).toBe(false); expect(await store.get(SKU-1001)).toMatchObject({ quantity: 2, version: 0, }); }); it(并发扣减不会超卖, async () { await store.init(SKU-1001, 10); const results await Promise.all( Array.from({ length: 20 }, (_, i) inventory.tryDeduct(SKU-1001, 1) ) ); expect(results.filter(Boolean)).toHaveLength(10); expect(await store.get(SKU-1001)).toMatchObject({ quantity: 0, version: 10, }); }); });Jest 的异步支持非常自然async/await 直接内建Promise.all 就是并发验证的利器。这里同样需要保证 InMemoryStockStore 的 compareAndSet 是原子操作。为了模拟真实网络环境下的 CAS 竞争我还在内存实现里加了一个随机的小延迟让并发请求出现更多的交错class InMemoryStockStore implements StockStore { private stockMap new Mapstring, StockInfo(); async init(skuId: string, quantity: number) { this.stockMap.set(skuId, { skuId, quantity, version: 0 }); } async get(skuId: string): PromiseStockInfo | null { const item this.stockMap.get(skuId); return item ? { ...item } : null; } async compareAndSet( skuId: string, expectVersion: number, newQuantity: number, newVersion: number ): Promiseboolean { await sleep(Math.random() * 5); const current this.stockMap.get(skuId); if (!current || current.version ! expectVersion) { return false; } this.stockMap.set(skuId, { skuId, quantity: newQuantity, version: newVersion, }); return true; } }加延迟是个很实用的技巧。不加延迟时单线程环境里的并发请求几乎串行执行CAS 基本不会冲突重试逻辑根本不会被触发加了延迟后并发请求的交错程度提高测试才能真实验证重试机制有没有写对。3.3 两侧过程对比两个版本走完后我最大的感受是TDD 的思维流程完全一致但框架的“手感”差异很大。JUnit 像一把精度很高的螺丝刀本身只提供最基础的功能但你可以自由组装各种工具到你习惯的形状Jest 像一套成套的工具箱打开就是完整的但它的工作方式会潜移默化地影响你怎么写测试。具体到代码上JUnit 的并发测试需要手动管理线程池、CountDownLatch、Future代码量明显更多但也更透明你能精确控制并发行为。Jest 的 Promise.all 写法极简几行就完成了并发模拟但你能控制的粒度也相应变少了。断言方面AssertJ 的链式断言和 Jest 的 expect 风格都很易读。不过 Jest 对“异步失败”的表达更自然因为整个框架就是围绕 async/await 设计的JUnit 则通过扩展模型来支持异步用起来相对笨重。4. 秒杀场景下Jest与JUnit的核心差异4.1 框架差异速查表为了让大家更直观地对比我整理了一张速查表覆盖了秒杀库存相关测试里最常用的维度对比维度JUnit 5JavaJestJavaScript/TypeScript断言能力基础断言较弱通常配 AssertJ内置断言丰富语义清晰异步测试需要依赖 Awaitility 或手动管理原生支持 async/await并发测试线程池 CountDownLatch 手动编排Promise.all 极简表达Mock 机制配合 Mockito需要显式配置内置 jest.mock开箱即用测试替身需要手写或通过框架创建jest.fn() / jest.spyOn() 非常轻量Redis 集成测试Testcontainers 生态成熟常用 ioredis-mock深度不足覆盖率报告JaCoCo内置 v8/istanbulTDD 启动成本需要选型组装生态脚手架自带全套成本低这两套框架没有绝对的好坏但选择会直接影响团队写测试的意愿。秒杀这种业务Redis 往往会参与库存预热和扣减所以“对 Redis 相关测试的支持度”是我判断框架适配性的一个重要指标。4.2 表达力与失败信息对TDD节奏的影响TDD 高频操作就是“跑测试看失败信息”所以失败信息的可读性直接决定开发效率。AssertJ 的assertThat(actual).isEqualTo(expected)失败时会输出期望值和实际值已经相当清晰。Jest 的expect(actual).toBe(expected)更夸张失败信息里会附带完整的 diff对象嵌套多深都能快速定位差异。这点在秒杀场景里很有用。比如并发测试失败可能是 quantity 不等于 0也可能是 version 不等于 10Jest 的 diff 直接告诉你“实际对象的 version 是 9期望是 10”省去大量 debug 时间。JUnit 配合 AssertJ 也能达到类似效果但需要你注意使用比较方法如果直接在断言里写assertTrue(repo.getStock(1L).quantity() 0)失败信息就只有 true/false排查全靠脑补。失败信息越好TDD 循环越顺畅。凡是写过“断言失败但不知道差多少”的人都懂这种体验一多就不想写测试了。4.3 并发测试的范式差异这是两个框架差异最明显的地方。JUnit 的并发测试偏向“控制并发原语”你手动创建线程池、用栅栏把线程对齐到同一出发线、收集 Future 结果再做断言。这种方式代码量大但你想怎么并发就能怎么并发精细可控。Jest 的并发测试更偏“共享并发上下文”直接用 Promise.all 把多个异步操作丢出去。异步操作本身在事件循环里交错执行不需要手动管理线程。这里有个认知差异JavaScript 是单线程事件循环所谓并发其实是异步交错和 Java 的多线程并发在底层完全不同。所以 Jest 的并发测试验证的是“异步时序正确性”JUnit 的并发测试验证的是“多线程共享资源的安全性”两者解决问题的手段和适用场景不一样。在秒杀场景如果库存扣减逻辑部署在 Node.js 服务里由于单线程特性理论上没有多线程竞争问题重点要防的是异步操作之间的交错导致的中间状态错误。而在 Java 服务里多线程竞争是常态必须从内存模型的角度去思考同步、锁、原子性。4.4 Mock与集成测试生态秒杀系统离不开 Redis、MySQL、MQ而这些外部依赖在单元测试里必须被隔离。JUnit 这边Mockito 的Mock、InjectMocks是标配但配置多一些真要模拟 Redis 的行为Testcontainers 能起一个真实 Redis 容器集成测试的可靠性极高不过启动慢不适合 TDD 高频跑。Jest 的 mock 设计得非常轻jest.mock(ioredis)一行就能替换整个 Redis 模块。简单场景下很好用秒杀这种需要精确控制 Redis 原子行为的场景轻量 mock 反而容易失真。我在 Jest 版本里选择手写一个 InMemoryStockStore就是为了在 mock 和真实之间取得平衡既快又能模拟 CAS 语义。我的建议是单元测试层用 TDD 验证服务逻辑用测试替身隔离外部依赖真正验证 Redis Lua 脚本或 SQL 并发控制的正确性必须靠集成测试JUnit 配 TestcontainersJest 配 real Redis 或更接近真实语义的模拟器。两者缺一不可。5. TDD过程中的常见问题与排查技巧5.1 典型问题速查表现象常见原因排查与解决办法测试第一次跑就绿忘了先写失败的测试或测试根本没被执行确认红绿循环故意改坏断言看测试会不会红并发测试偶现失败但重跑就过样本量不足或 CAS 冲突场景没被覆盖加大线程数在测试替身里加随机延迟增加交错测试里连了 Redis 导致不稳定单元测试和集成测试边界没分清抽象库存存储接口单元测试注入内存替身Jest 测试跑完后进程不退出异步资源未关闭存在 open handles检查 Redis 连接和定时器用--detectOpenHandles定位JUnit 测试启动极慢每个测试都启动 Spring 容器纯单元测试别用 SpringBootTest手动 new 依赖对象CAS 重试逻辑一直没有触发测试替身的原子性太强没有产生冲突在 compareAndSet 前后加延迟模拟网络往返5.2 一些容易被忽略的细节第一个容易被忽略的细节是测试替身必须模拟生产依赖的语义特征。内存实现的 compareAndSet 如果无条件覆盖数据那测试很快就绿了但它检验不出服务层的版本号逻辑是否正确。生产环境里 Redis 的 CAS 是有失败条件的测试替身也必须同样有条件失败否则 TDD 等于白做。第二个细节是并发测试里一定要设置“并发起点”。JUnit 里我用 CountDownLatch 让所有线程同时起跑Jest 里我用随机延迟制造交错目的都是让竞争条件有出现的可能。如果并发请求一个接一个地排队执行那测的不是并发是顺序执行断言写得再漂亮也没意义。第三个细节是重试机制的边界条件。我给 tryDeduct 设置了 maxRetry这个上限值也需要有测试覆盖。如果重试达到上限仍然失败调用方应该收到明确的失败信号而不是无限阻塞。秒杀场景里库存量小、并发量大的时候重试上限设置不合理会导致“明明有库存但用户抢不到”的问题这个逻辑必须靠测试钉死。最后还有一点心得双跑 TDD 的过程让我意识到工具只是载体真正值钱的是先把规则想清楚的技术习惯。开始动手前我先花十多分钟把需求列成可验证的清单再翻译成测试这个习惯保证了我不会在编码中途反复改需求。架构师口里的“先设计再编码”落到日常其实就是“先测试再实现”。这套方法在秒杀系统里有用换到别的业务照样有效。
返回列表