
秒杀系统大概是程序员最常拿来练手、也最容易被面试官问崩的场景之一。一万个人抢一百个库存听起来不就是个条件判断吗可真到线上超卖、重复下单、库存扣成负数、接口被刷爆哪个坑都能让你半夜被电话叫起来。更麻烦的是这种系统业务逻辑不算复杂但并发边界特别多改代码的时候心里总发虚明明功能看着没问题一上线就出事。我这些年两种技术栈都写过Node侧用JestJava侧用JUnit而且在秒杀这个业务上完整走过测试驱动开发TDD的流程。说实话TDD不是银弹但在这种“边界条件极多、回归成本极高”的业务里它的价值被放得非常大。这篇文章我把两种框架下的TDD体验、代码实现、踩坑记录全拉出来对比一遍希望能帮你少走点弯路。1. 秒杀系统为什么是TDD的练兵场1.1 超卖、幂等、并发秒杀业务的三座大山秒杀业务最核心的需求就一句话库存有限先到先得。但这句话落到代码里至少拆成三个必须同时满足的约束第一不超卖。无论多少请求同时进来已售数量绝对不能超过总库存库存字段不能出现负数。第二不重复。同一个用户在同一场秒杀里只能成功一次哪怕他前端点了十次、后端重试了五次最终订单也只能有一个。第三不阻塞。不能用一把大锁把所有请求串行化否则性能完全扛不住秒杀直接变“秒没”。这三个约束单独拿出来都不难实现难的是同时满足。而TDD恰恰是最适合在这种场景里使用的方法论——先写测试把约束定义死再写代码去满足这些测试。测试就是需求的可执行版本代码只是为了让测试变绿而存在。我在实际项目里见过太多“先写实现再补测试”的团队补出来的测试基本都是顺着实现逻辑写的代码有bug测试跟着一起错。秒杀系统尤其不能这么干因为你以为的“逻辑没问题”和真实的并发行为往往是两回事。1.2 Jest与JUnit代表的两种技术流派这次要对比的Jest和JUnit恰好是当前最主流的两大生态里的测试框架代表Jest是JavaScript/TypeScript生态里的测试框架Facebook出品以零配置、开箱即用出名。断言、Mock、覆盖率、异步测试、watch模式全内置了对于Node.js后端项目来说装一个jest就能解决所有测试需求。JUnit是Java生态里的测试框架从JUnit 4到JUnit 5Jupiter它本身只负责测试的生命周期管理和断言。Java项目里通常要搭配AssertJ写更流畅的断言、Mockito做Mock、JaCoCo统计覆盖率甚至还要引入Spring Boot Test来做集成测试。跟Jest的“全家桶”模式比JUnit更像“半成品”得自己组装配件。这不是谁好谁坏的问题而是两种生态的哲学差异。JavaScript生态讲究“约定大于配置”尽量降低使用门槛Java生态则更推崇“显式组合”框架只做核心事其他的你自己选。这个差异直接影响了TDD的实践体验后面我会细说。1.3 TDD解决的是“不敢改代码”的问题很多人对TDD有误解以为TDD就是“先写测试再写代码”多了一道工序而已。实际上TDD的核心价值在于给你一张安全网让你敢对代码做大手术。秒杀系统上线后一定会遇到需求变更比如“每人限购一件”改成“每人限购三件”、“普通用户和会员库存分开”等等。没有测试保护的情况下每次改并发逻辑都像在雷区里走路。而有了TDD打底你可以先改测试、看它变红再改实现、让它变绿——整个过程对系统的其他部分是透明的安全感完全不一样。所以这篇文章不打算只讲“怎么写测试”而是把秒杀系统里最常见的库存扣减、并发控制、幂等判断这三个场景在Jest和JUnit下分别用TDD方式实现一遍让你直观看到两种技术栈的完整玩法。2. 一个库存扣减用例的红绿循环对比2.1 Jest侧先用真实需求写失败测试TDD的标准流程是红灯—绿灯—重构。红灯就是先写一个必然失败的测试用来描述你想要的行为绿灯是写最少的代码让它通过重构是在测试保护下优化代码结构。我用JavaScript实现一个库存服务先定义一个测试文件stock.test.js// stock.test.js const createStockService require(./stockService); describe(库存服务 - 基础扣减, () { test(库存充足时扣减成功并返回剩余库存, () { const service createStockService(100); const result service.deduct(1); expect(result.success).toBe(true); expect(result.remaining).toBe(99); }); test(库存不足时扣减失败且库存不变, () { const service createStockService(2); const result service.deduct(3); expect(result.success).toBe(false); expect(result.remaining).toBe(2); }); });第一次跑这个测试肯定失败因为./stockService这个模块根本不存在。但失败正好因为我们现在很清楚自己想要的API是什么样子了传入初始库存deduct方法接受扣减数量返回一个包含success和remaining的对象。接下来写最小实现// stockService.js function createStockService(initialStock) { let stock initialStock; function deduct(quantity) { if (stock quantity) { return { success: false, remaining: stock }; } stock - quantity; return { success: true, remaining: stock }; } return { deduct }; } module.exports createStockService;跑测试绿色。从这里就能看出TDD的一个特点你写代码时完全不纠结“要不要加个日志”“要不要做参数校验”因为测试没要求这些你就没必要做。所有超出测试范围的代码都是多余的这是TDD帮我们克制“过度设计”欲望的天然机制。2.2 JUnit侧边界条件与并发语义先行同样的用例用Java写一遍。先用JUnit 5写好测试类// StockServiceTest.java import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; class StockServiceTest { Test DisplayName(库存充足时扣减成功并返回剩余库存) void shouldDeductSuccessfullyWhenStockEnough() { StockService service new StockService(100); DeductResult result service.deduct(1); assertTrue(result.isSuccess()); assertEquals(99, result.getRemaining()); } Test DisplayName(库存不足时扣减失败且库存不变) void shouldFailWhenStockNotEnough() { StockService service new StockService(2); DeductResult result service.deduct(3); assertFalse(result.isSuccess()); assertEquals(2, result.getRemaining()); } }先写测试再写实现Java这边也不例外。第一次运行同样会报编译错误因为StockService和DeductResult都还不存在。在Java这种强类型语言里“红灯”不只是断言失败编译失败也是一种红灯逼着你先把类型设计出来。然后写实现// StockService.java public class StockService { private int stock; public StockService(int initialStock) { this.stock initialStock; } public synchronized DeductResult deduct(int quantity) { if (stock quantity) { return DeductResult.fail(stock); } stock - quantity; return DeductResult.success(stock); } }// DeductResult.java public class DeductResult { private final boolean success; private final int remaining; private DeductResult(boolean success, int remaining) { this.success success; this.remaining remaining; } public static DeductResult success(int remaining) { return new DeductResult(true, remaining); } public static DeductResult fail(int remaining) { return new DeductResult(false, remaining); } public boolean isSuccess() { return success; } public int getRemaining() { return remaining; } }这里我给deduct方法加了synchronized关键字是为了保证多线程环境下扣减操作的原子性。单机场景下这个简单方案是可用的但真实秒杀系统不会用synchronized后面我讲到并发测试时会聊为什么。2.3 红灯之后写的最小实现究竟有多小TDD新手最容易犯的毛病是在写“最小实现”时忍不住把功能做得过满。比如上面这个例子有人会顺手加上“库存扣减日志”、“用户维度限购”、“扣减失败原因分类”等等。这些功能也许未来需要但当前测试根本不需要。“最小实现”这四个字的衡量标准很简单代码写得再少只要能让当前所有测试变绿就够了。哪怕你觉得这代码写得丑、不够健壮、性能不好——别急那是重构阶段的事。先让测试绿再谈优化。我在带团队做TDD时反复强调红灯阶段思考的是“我要什么行为”绿灯阶段思考的是“我能多快地让测试通过”重构阶段才思考“代码该怎么写得更好”。三个阶段的目标完全不同混在一起就会变成“写代码时顺便补测试”的老路。3. 最硬核的部分并发不超卖测试怎么落地3.1 单线程陷阱为什么普通单元测试测不出并发问题上面写的两个测试无论Jest版还是JUnit版都是单线程顺序执行的它们只能验证“业务规则对不对”完全验证不了“并发条件下系统稳不稳”。而秒杀系统的核心风险恰恰在并发上。有个很典型的坑很多人在本地写了一个库存扣减方法单测全过一压测就超卖。原因就是代码里的“检查库存—扣减库存”两步之间存在时间窗口多线程同时进入这个窗口就会出问题。理解了这一点你就能明白为什么TDD在这类系统里如此重要——不是因为它能自动解决并发问题而是因为它逼着你在写业务代码之前先把“并发场景下应该表现为什么样”用测试定义出来。这个测试写不出来说明你根本不了解自己要解决的问题。3.2 Jest侧用并发测试戳破JavaScript的单线程幻想JavaScript本身是单线程的但你依然会遇到并发问题——不是线程并发而是事件循环里多个异步操作交错执行。在秒杀场景里这意味着两个请求可以同时进入deduct函数在第一个请求还没执行完扣减之前第二个请求已经读到了旧的库存值。在Jest里用Promise.all模拟并发请求// stock.concurrency.test.js const createStockService require(./stockService); describe(库存服务 - 并发抢购, () { test(1000个并发请求抢100件库存最终售出数量等于100, async () { const service createStockService(100); let successCount 0; const requests Array.from({ length: 1000 }, (_, i) service.deduct(1).then((result) { if (result.success) { successCount; } }) ); await Promise.all(requests); expect(successCount).toBe(100); expect(service.getRemaining()).toBe(0); }); });问题来了如果我们用之前实现的stockService.js这个测试能通过吗答案是不确定但更可能是失败的因为deduct里的“检查库存”和“扣减库存”在异步场景下并不原子。不过在实际运行中由于JavaScript单线程和微任务调度的特性service.deduct(1)如果不包含真正的异步操作Promise.all里的任务其实是按顺序执行的测试可能“碰巧”通过。这就是最危险的情况——测试碰巧通过但你的代码其实有问题。要真正测出问题需要在deduct里加一个真实的异步边界比如模拟一次Redis网络调用// stockService.withLatency.js function createStockService(initialStock) { let stock initialStock; async function deduct(quantity) { // 模拟网络延迟让出事件循环 await new Promise((resolve) setTimeout(resolve, Math.random() * 10)); if (stock quantity) { return { success: false, remaining: stock }; } stock - quantity; return { success: true, remaining: stock }; } return { deduct, getRemaining: () stock }; }这个版本的deduct加入了异步延迟1000个并发请求会在事件循环里交错执行测试大概率失败超卖场景才能真正复现出来。那怎么做才能让测试通过让原子性从业务代码里上移交给底层存储来解决比如改用Redis的DECR命令或者Lua脚本。如果你在测试里依赖真正的Redis集成测试会变慢变脆弱如果Mock掉Redis又测不到真正的并发行为。这是Jest侧TDD最尴尬的地方后面我会单独展开。3.3 JUnit侧用真实多线程复现超卖Java这边的并发测试就直白多了——直接创建多个线程用CountDownLatch让它们同时起跑制造真实竞争// StockServiceConcurrencyTest.java import org.junit.jupiter.api.Test; import java.util.ArrayList; import java.util.List; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; import java.util.concurrent.atomic.AtomicInteger; import static org.junit.jupiter.api.Assertions.assertEquals; class StockServiceConcurrencyTest { Test DisplayName(1000个并发请求抢100件库存不会超卖) void shouldNotOversellUnderConcurrency() throws Exception { StockService service new StockService(500); // 故意给个大库存 int requestCount 1000; int threadCount 100; ExecutorService executor Executors.newFixedThreadPool(threadCount); CountDownLatch ready new CountDownLatch(requestCount); CountDownLatch start new CountDownLatch(1); AtomicInteger successCount new AtomicInteger(0); ListFuture? futures new ArrayList(); for (int i 0; i requestCount; i) { futures.add(executor.submit(() - { ready.countDown(); try { start.await(); DeductResult result service.deduct(1); if (result.isSuccess()) { successCount.incrementAndGet(); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } })); } ready.await(); start.countDown(); for (Future? future : futures) { future.get(); } executor.shutdown(); assertEquals(100, successCount.get()); assertEquals(400, service.getRemaining()); } }这里我故意把初始库存设成500目标售出100件最终期望库存剩余400。这个测试用CountDownLatch保证所有线程尽可能同时进入deduct方法最大程度制造竞争。如果你把之前写的StockService改成不带synchronized的版本这个测试会稳定失败超卖数量不确定。而带synchronized的版本能稳定通过——但代价是吞吐量极低1000个请求全被锁串行化了。这让我想起一个残酷的现实JUnit和synchronized可以帮你验证单机并发正确性但真实秒杀系统的吞吐需求注定要求你使用更底层的并发原语。3.4 为什么真实系统要用Redis Lua而不是synchronizedsynchronized的问题在于它是进程内锁只对单机有效。一旦系统部署多实例锁就失效了。秒杀系统通常是集群部署所以需要一把“跨进程的锁”——把库存扣减的操作原子地下沉到共享存储里。目前主流做法是用Redis的Lua脚本。因为Redis是单线程处理命令的Lua脚本在Redis里执行天然具备原子性。下面这个脚本是秒杀库存扣减的标配-- 返回 1 表示扣减成功返回 0 表示库存不足 local stock tonumber(redis.call(GET, KEYS[1])) if stock 0 then return 0 end redis.call(DECR, KEYS[1]) return 1这种场景下你的JUnit或Jest测试怎么写不是Mock掉Redis而是应该起一个真实的Redis实例比如Testcontainers用集成测试验证整个链路。TDD到这里就演变成了“行为驱动”的测试——你不再关心代码内部的锁实现而是关心“当库存为1时有100个并发请求最终恰好只有1个成功”。4. 幂等测试与测试替身的对比4.1 幂等同一个用户不能秒两次秒杀系统除了不超卖还有一个硬性约束同一个用户只能下单一次。前端按钮可以防重复点击但拦不住懂技术的人直接刷接口所以后端必须做幂等判断。这个需求用TDD写测试非常直观。Jest侧// seckill.order.test.js const createSeckillService require(./seckillService); describe(秒杀服务 - 幂等控制, () { test(同一用户重复提交只成功一次, () { const service createSeckillService({ totalStock: 100 }); const firstResult service.grab(user_001); const secondResult service.grab(user_001); expect(firstResult.success).toBe(true); expect(secondResult.success).toBe(false); expect(secondResult.reason).toBe(重复下单); expect(service.getSoldCount()).toBe(1); }); });JUnit侧// SeckillServiceTest.java Test DisplayName(同一用户重复提交只成功一次) void shouldAllowOnlyOneOrderPerUser() { SeckillService service new SeckillService(100); GrabResult first service.grab(user_001); GrabResult second service.grab(user_001); assertTrue(first.isSuccess()); assertFalse(second.isSuccess()); assertEquals(duplicate, second.getReason()); assertEquals(1, service.getSoldCount()); }在实现层面幂等有两种常见做法一种是用数据库唯一索引兜底用户ID加活动ID作为唯一约束重复插入直接报错另一种是用Redis的SETNX做标记抢购前先占位。我用Redis方案写得比较多因为它能把“占位”和“扣库存”合并成一个事务性操作但无论如何测试写好的行为约束是永远不变的。4.2 Mock外部依赖的测试对比在写秒杀系统的测试时不可能每次都连真实的数据库、真实的Redis、真实的MQ。于是Mock就成了TDD里绕不开的环节。Jest和JUnit在这方面的体验差异非常明显。Jest的Mock是内建的写法极其简洁。比如我们依赖一个PaymentClient可以直接这样Mockconst paymentClient require(./paymentClient); jest.mock(./paymentClient); test(下单成功后调用支付接口, async () { paymentClient.pay.mockResolvedValue({ success: true }); const service createSeckillService({ paymentClient }); const result await service.grab(user_001); expect(result.success).toBe(true); expect(paymentClient.pay).toHaveBeenCalledWith(user_001, 99.9); });jest.mock是模块级别的自动Mock你甚至不用手工创建Mock对象框架会自动把模块的所有方法替换成Mock函数。这种设计在TDD里非常舒服——你可以在不实现任何依赖的情况下先写测试让行为定义先行。JUnit侧则需要借助Mockito// SeckillServiceTest.java import static org.mockito.Mockito.*; Test void shouldCallPaymentWhenGrabSucceeds() { PaymentClient paymentClient mock(PaymentClient.class); when(paymentClient.pay(user_001, 99.9)).thenReturn(true); SeckillService service new SeckillService(100, paymentClient); GrabResult result service.grab(user_001); assertTrue(result.isSuccess()); verify(paymentClient).pay(user_001, 99.9); }Mockito的mock()和verify()也很好用但你需要单独引入依赖而且要理解动态代理、字节码增强这些底层概念学习曲线比Jest陡峭一些。从TDD体验上看我认为Jest的自动化Mock更适合快速上手JUnit Mockito的显式声明虽然略繁琐但胜在可控性强mock什么、verify什么都写得明明白白多人协作时代码自解释性更好。4.3 两种框架下测试组织结构的差异Jest默认按__tests__目录和*.test.js文件后缀组织测试测试文件可以和源码放在一起也可以单独放test目录。JUnit则没有“约定”你可以在任意目录写测试类Maven/Gradle会按src/test/java下的*Test.java自动识别。这个差异看似微不足道实际影响了团队协作时的习惯。Jest的“就近测试”模式让人更容易保持测试与代码同步更新因为测试就在手边JUnit的“镜像目录”模式则让测试集中管理大型项目里更清爽。我两种方式都试过最终更加偏爱Jest的就近模式——不是因为它更好而是因为对我这种“懒人”来说测试文件离实现文件越近越不容易被遗忘。5. 常见问题与排查技巧实录5.1 测试不稳定先怀疑共享状态写过并发测试的人一定见过这种场景测试连续跑三次前两次通过第三次失败再跑一次又通过了。这种“脆性测试”是最让人抓狂的。经验法则测试不稳定先怀疑共享状态。是不是有全局变量被多个测试修改了是不是单例类的某个字段在测试之间残留了数据是不是用到了同一个数据库表、同一个Redis key没清理这些在所有框架下都是万能排查顺序。Jest里可以用beforeEach重置模块状态beforeEach(() { jest.resetModules(); });JUnit里用BeforeEach重新创建被测对象BeforeEach void setUp() { service new StockService(100); }核心原则就是每个测试用例跑完后被测对象和外部依赖都必须恢复到一个确定的初始状态不允许测试之间存在任何因果依赖。5.2 测试跑得慢区分单元测试与集成测试秒杀系统的性能测试一上来测试执行时间就爆炸。你以为是在做TDD实际每天光等测试就跑好几十分钟。这时候要分清测试类型单元测试Mock掉所有外部依赖只测内存里的业务逻辑毫秒级。集成测试连接Redis、MySQL等真实组件验证跨组件交互秒级甚至分钟级。端到端测试模拟完整用户链路最慢一般不在日常开发中跑。Jest里可以用testMatch区分目录用--runInBand调串行/并行JUnit里用JUnit 5的Tag注解给测试分类配合Maven/Gradle的profile灵活执行。我个人习惯是日常开发只跑单元测试提交前跑全量测试CI里才跑集成测试。这样TDD的“短反馈循环”才不会被拖垮。5.3 覆盖率不是目标行为才是TDD做到后面很多团队跑偏到“百分之百覆盖率”的KPI里开始为覆盖率写测试而不是为行为写测试。这在我看来是本末倒置。秒杀系统的核心行为只有三个不超卖、不重复、不阻塞。你能用测试把这三点钉死覆盖率即使只有70%也比那些“为了凑覆盖率而测了一堆getter/setter”的代码强得多。覆盖率可以作为参考但TDD真正考核的是你写的每个测试是否对应一个真实的需求行为。5.4 从Jest切到JUnit或反过来的心理落差最后说一个我自己的体会。我最早是Java出身习惯了JUnit的严谨和显式依赖后来切到Node生态用Jest时简直被它的零配置体验感动到流泪。但等我在Jest里写多了再切回Java写JUnit时又发现这种“麻烦”其实有它的价值——Java的编译期检查能提前发现很多类型错误Mockito的verify比Jest的toHaveBeenCalledWith在报错信息上清晰得多。所以如果你问我到底该学Jest还是JUnit我的答案始终是取决于你的技术栈。但TDD的心法完全一致——先写测试描述行为再写实现让测试变绿最后重构优化内功。工具只是表达行为的语言语言不同但逻辑是相通的。秒杀系统这种并发边界密集的业务恰好是练这套心法最好的磨刀石。如果你正在做秒杀系统我强烈建议你别急着撸业务代码先把你承诺的每一个行为用测试写下来然后让代码去兑现这些承诺。一次完整的红绿循环带给你的确定性远比自己闷头写一百行代码再提心吊胆地上线要踏实得多。