写秒杀系统,怕的不是代码多,而是每次动库存相关的逻辑都手心冒汗。库存扣多一分是超卖,直接经济损失;扣少一分是用户体验崩坏,活动还没结束用户就先怒了。这种情况下,测试驱动开发(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各自怎么TDD
3.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); List<Future<Boolean>> 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 (Future<Boolean> 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 Map<Long, 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): Promise<StockInfo | null>; compareAndSet( skuId: string, expectVersion: number, newQuantity: number, newVersion: number ): Promise<boolean>; } class InventoryService { constructor(private store: StockStore) {} async tryDeduct(skuId: string, quantity: number, maxRetry = 3): Promise<boolean> { 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 Map<string, StockInfo>(); async init(skuId: string, quantity: number) { this.stockMap.set(skuId, { skuId, quantity, version: 0 }); } async get(skuId: string): Promise<StockInfo | null> { const item = this.stockMap.get(skuId); return item ? { ...item } : null; } async compareAndSet( skuId: string, expectVersion: number, newQuantity: number, newVersion: number ): Promise<boolean> { 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 5(Java) | Jest(JavaScript/TypeScript) |
|---|---|---|
| 断言能力 | 基础断言较弱,通常配 AssertJ | 内置断言丰富,语义清晰 |
| 异步测试 | 需要依赖 Awaitility 或手动管理 | 原生支持 async/await |
| 并发测试 | 线程池 + CountDownLatch 手动编排 | Promise.all 极简表达 |
| Mock 机制 | 配合 Mockito,需要显式配置 | 内置 jest.mock,开箱即用 |
| 测试替身 | 需要手写或通过框架创建 | jest.fn() / jest.spyOn() 非常轻量 |
| Redis 集成测试 | Testcontainers 生态成熟 | 常用 ioredis-mock,深度不足 |
| 覆盖率报告 | JaCoCo | 内置 v8/istanbul |
| TDD 启动成本 | 需要选型组装生态 | 脚手架自带全套,成本低 |
这两套框架没有绝对的好坏,但选择会直接影响团队写测试的意愿。秒杀这种业务,Redis 往往会参与库存预热和扣减,所以“对 Redis 相关测试的支持度”是我判断框架适配性的一个重要指标。
4.2 表达力与失败信息对TDD节奏的影响
TDD 高频操作就是“跑测试看失败信息”,所以失败信息的可读性直接决定开发效率。AssertJ 的assertThat(actual).isEqualTo(expected)失败时,会输出期望值和实际值,已经相当清晰。Jest 的expect(actual).toBe(expected)更夸张,失败信息里会附带完整的 diff,对象嵌套多深都能快速定位差异。
这点在秒杀场景里很有用。比如并发测试失败,可能是 quantity 不等于 0,也可能是 version 不等于 10,Jest 的 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 配 Testcontainers,Jest 配 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 的过程让我意识到,工具只是载体,真正值钱的是先把规则想清楚的技术习惯。开始动手前,我先花十多分钟把需求列成可验证的清单,再翻译成测试,这个习惯保证了我不会在编码中途反复改需求。架构师口里的“先设计再编码”,落到日常其实就是“先测试再实现”。这套方法在秒杀系统里有用,换到别的业务照样有效。