先说个结论:秒杀系统是测试驱动开发最能发挥价值的战场,同时也是最容易让人怀疑"测试到底有没有用"的项目类型。过去半年,我在两套秒杀服务上分别用 Jest 和 JUnit 完整走了一遍 TDD 流程,一套是基于 Node.js 的聚合层,另一套是基于 Java 的库存核心服务,期间踩的坑、对比出来的差异、沉淀下来的写法,都值得单独写一篇。这篇文章不是框架文档的复读,而是把两套框架放在同一类高并发业务场景下,用实战结果说话,给正在做秒杀、抢购、限量发放这类业务的团队一个可落地的参考。
我为什么把对比选在秒杀系统上?因为秒杀场景有一个天然优势:业务规则简单,但并发条件极端。简单意味着测试用例容易定义,极端意味着边界条件和时序问题极其容易暴露。这种环境最适合练习 TDD,也最能看出一个测试框架在"快速反馈、精准模拟、稳定运行"三件事上的真实水平。
1. 秒杀系统到底难测在哪,TDD 为什么特别适合这里
1.1 秒杀场景对测试提出的三道必答题
秒杀系统的业务逻辑本身并不复杂,核心就三个接口:商品详情、创建订单、支付回调。但它和普通电商系统有一个本质区别:瞬时流量放大。平时几百 QPS 的服务,活动一开始就冲到几万 QPS,而且流量集中在开门后几秒钟,库存又少得可怜,1000 件商品可能一瞬间就被抢完。
这种流量模型给测试带来了三个普通业务里很少遇到的要求。
第一个要求是并发正确性。多个请求同时扣减同一件商品的库存,最终不能出现负数,不能超卖。这个规则在单线程下怎么测都对,但并发到临界点时,谁成功谁失败必须有一个明确的、可断言的答案。
第二个要求是资源保护能力。秒杀接口必须有限流、防刷、熔断这样的保护机制,否则一个活动就能把下游数据库打挂。可限流阈值设多少、超限后返回什么、什么时候恢复放行,这些规则如果不用测试固定下来,上线后几乎必然出乱子。
第三个要求是降级兜底的确定性。Redis 抖动、MQ 堆积、数据库连接池耗尽,这些在高并发下不是"可能发生",而是"必然发生"。系统在依赖不可用时的行为到底是放行还是拒绝,必须有一个明确设计,而且要能在测试里稳定复现。
这三道必答题,本质上都是"边界条件"问题。而 TDD 的核心动作恰恰是把边界条件写成测试用例,在写实现代码之前就锁定行为规范。这就是我认为秒杀场景天然适合 TDD 的最根本原因。
1.2 传统测试流程在秒杀项目里的三个痛点
我见过很多团队做秒杀项目的测试方式是"先开发,后补测,活动前压测"。这种方式在普通 CRM 系统里也许够用,但在秒杀项目里会有三个非常现实的痛点。
痛点一是并发 bug 很难在功能测试阶段暴露。功能测试通常是手工或者接口自动化逐条验证,一次请求一个结果,两个线程同时抢同一条库存这种时序问题,手工点一百遍页面也不一定能触发一次。
痛点二是压测发现的问题定位成本极高。我经历过一次线上活动前的压测,压出超卖问题,当时第一反应是查 Redis 扣减逻辑,结果折腾了两小时,最后发现是订单创建服务里有个状态机处理并发重复提交时有 bug。压测只能告诉你"哪里冒烟了",但不告诉你"火柴在哪"。
痛点三是回归周期太长。秒杀系统每一次改动都可能牵连到限流、库存、订单、支付几个模块,如果没有自动化测试兜底,改一个库存逻辑就要人工回归一整条链路,活动周期又紧,最后只能靠"上线后盯监控"这种高风险方式收场。
1.3 TDD 改变的不是测试数量,而是发现问题的时间点
TDD 在秒杀项目里最大的价值,不是让代码写得快,而是把单点边界问题的发现时机从"压测阶段"提前到"开发阶段"。
我自己体会特别深的一个例子是库存扣减。按传统流程,先写减库存的代码,然后写个单测验证正常扣减,等压测时才发现并发扣减会超卖,再回来改代码、加锁、改 Redis 脚本,整个过程至少大半天。而用 TDD 的思路,我写扣减实现之前就先写一个并发扣 100 次的测试用例,断言"成功次数最多等于库存数",这个用例必然跑红,然后带着这个红去设计实现方案,实现出来跑绿,后面再压测,超卖问题几乎不可能再出现。
所以我对 TDD 的理解是:它改变的不是测试数量,而是问题暴露的时间点。秒杀这种高风险项目,越早发现问题,修复成本越低。
2. 选框架前先搞懂:Jest 和 JUnit 的底层设计差异
2.1 两者不是同类框架:一个面向 JavaScript 生态,一个扎根 JVM
很多人喜欢拿 Jest 和 JUnit 做对比,但首先要明白,它们不是同一生态里的"二选一",而是各自技术栈下最主流的默认选择。
Jest 是 Facebook 开源的 JavaScript 测试框架,核心卖点是零配置、内置断言、内置 mock、内置覆盖率收集。它对 Node.js 服务、前端工程、TypeScript 项目都非常友好。JUnit 则是 Java 生态里最经典的测试框架,从 4 走到 5 之后,基于 Jupiter 平台重构了扩展模型,配合 Mockito、AssertJ、Spring Boot Test 这套生态,成了 JVM 世界的事实标准。
选哪个不是比哪个更强,而是看你的服务是 Java 写的还是 JavaScript 写的。真正值得花时间研究的,是这两个框架在 TDD 工作流里的行为习惯差异。
2.2 断言、Mock、异步:三个维度看 TDD 手感的差异
我用一个表格把两个框架的核心差异先列出来,后面再逐一展开:
| 对比维度 | Jest | JUnit 5 |
|---|---|---|
| 语言生态 | JavaScript / TypeScript | Java / Kotlin / JVM |
| 断言风格 | expect(value).toBe(expected) 链式风格 | assertEquals(expected, actual),可配合 AssertJ |
| Mock 能力 | 内置 jest.fn()、jest.mock()、模块级自动 mock | 通常配合 Mockito、MockBean |
| 异步测试 | 原生 async/await 支持,测试函数直接返回 Promise | 支持,但 CompletableFuture 的等待处理相对繁琐 |
| 配置成本 | 零配置开箱即用 | 需要引入依赖、配置插件,Spring 项目还需额外装配 |
| 测试隔离 | 默认每个测试文件独立模块环境 | 默认共享实例,需要 @DirtiesContext 等控制 |
| 反馈速度 | 极快,改动即时生效 | 单个测试快,但 Spring 上下文启动慢 |
断言风格直接影响"红灯阶段"写测试的效率。Jest 读起来像自然语言,expect(limiter.check('user_1')).resolves.toEqual({ allowed: false })这行代码本身就描述了一个业务场景,不需要额外注释。JUnit 5 原生断言是assertEquals()这种静态方法风格,信息密度高但可读性稍弱,所以很多 Java 团队会引入 AssertJ,用assertThat(result).isEqualTo(...)找回流畅度。
Mock 能力的差异更明显。Jest 的 mock 是把整个模块或者某个函数替换掉,jest.mock('./redisClient')一行就能把整个依赖换成假实现,对 Node.js 这种模块化场景非常自然。Java 这边,Mockito 也足够强大,但要考虑类的访问权限、Spring 代理、final 类等问题,心智负担更重。我见过不少 Java 团队因为 mock final 类踩坑,最后用 Mockito 的 inline mock maker 才解决。
异步测试是 TDD 里绕不开的话题。秒杀场景尤其依赖异步逻辑:MQ 消费、缓存异步更新、线程池并发处理。Jest 对异步的原生支持是我用过的框架里做得最舒服的,写await expect(service.check()).resolves.toBe(true)完全符合直觉。JUnit 5 对异步的支持则需要借助CompletableFuture配合Awaitility之类的外挂,测试代码读起来会复杂不少。
2.3 我在 TDD 循环里最在意的三个框架细节
框架对比不能只停留在功能层面,真正影响 TDD 体验的通常是三个执行细节。
第一个细节是测试反馈的速度。TDD 是一个"红-绿-重构"的高频循环,你每写一小段代码就要跑一次测试,如果跑一次测试要等几秒钟,这个循环就会被拖垮。Jest 因为面向 Node.js,没有编译期,文件改动后瞬间就能跑完,体验非常顺滑。JUnit 的单测本身也够快,但如果你图省事写成@SpringBootTest,每次启动都要拉起一个 IoC 容器,那就是另一个故事了。
第二个细节是mock 与真实实现的一致性。Jest 的自动 mock 很方便,但也很容易让你 mock 出一个和真实实现行为不一致的假模块。我在 Node.js 项目里就遇到过,mock 的 Redis 客户端返回正常结果,测试全绿,但真实环境里 Redis 连接池超时导致线上报错。JUnit 这边,Mockito 的when().thenReturn()同样存在这个问题。所以我的原则是:mock 只用于隔离不稳定依赖,核心业务逻辑最好用真实对象或容器来测。
第三个细节是并发测试的稳定性。秒杀系统必然要写并发测试,但并发测试最容易成为 flaky test 的来源。Jest 里我常用Promise.all模拟并发,JUnit 里用线程池和CountDownLatch控制同时起步,但这些测试对 CI 机器的 CPU 核数、执行时间很敏感,断言必须只关注业务结果边界,而不能依赖"某个线程必然先执行"这类时序假设。
3. Jest 实战 TDD:给秒杀接口写一套限流模块
3.1 需求定性:限流模块要守住哪三个边界
限流是秒杀系统的第一道防线。我们用 TDD 来开发一个基于 Redis 的接口限流模块,需求明确为三条:按用户维度限流、滑动窗口计数的"每秒最多 10 次"不是固定窗口、超过阈值时返回"请求被拒绝"并附带重试等待时间。
这三个需求分别对应三个边界条件:用户维度边界(不同用户互不影响)、窗口边界(第 10 次放行、第 11 次拒绝)、时间边界(窗口滑过之后恢复放行)。TDD 的出发点就是先把这三个边界写成测试用例。
3.2 红灯阶段:先写一个必然失败的测试
我新建一个rateLimiter.test.js文件,先不实现rateLimiter.js,直接定义行为:
const { createRateLimiter } = require('./rateLimiter'); describe('秒杀接口限流模块', () => { let store; let limiter; beforeEach(() => { store = { incr: jest.fn(), expire: jest.fn(), }; limiter = createRateLimiter({ store, windowMs: 1000, max: 10, }); }); test('同一用户每秒访问超过10次时,第11次请求被拦截', async () => { store.incr.mockResolvedValueOnce(11); const result = await limiter.check('user_1'); expect(result.allowed).toBe(false); expect(result.retryAfterMs).toBeGreaterThan(0); }); test('同一用户每秒访问未超过上限时,请求放行', async () => { store.incr.mockResolvedValueOnce(5); const result = await limiter.check('user_1'); expect(result.allowed).toBe(true); }); test('不同用户的访问计数互不影响', async () => { store.incr.mockResolvedValueOnce(11); store.incr.mockResolvedValueOnce(3); const first = await limiter.check('user_1'); const second = await limiter.check('user_2'); expect(first.allowed).toBe(false); expect(second.allowed).toBe(true); }); });运行npm test,提示找不到./rateLimiter模块,测试报红。这个红是 TDD 的起点,它说明测试正在定义"还不存在的东西应该有什么行为"。
3.3 绿灯阶段:用最简实现让测试通过,别急着上"高级方案"
红灯之后,我新建rateLimiter.js,用最直接的方式实现,目标是让测试尽快变绿:
function createRateLimiter({ store, windowMs, max }) { return { async check(userId) { const key = `rate:${userId}`; const count = await store.incr(key); if (count === 1) { await store.expire(key, Math.ceil(windowMs / 1000)); } if (count > max) { return { allowed: false, retryAfterMs: windowMs }; } return { allowed: true, retryAfterMs: 0 }; }, }; } module.exports = { createRateLimiter };这个实现里每一次请求都调用store.incr(key),通过 Redis 的原子自增获取当前计数,如果自增结果为 1 说明是窗口内第一次访问,需要设置过期时间。当计数超过阈值就拒绝。整个过程没有"先读再写",而是靠 Redis INCR 的原子性来保证高并发下计数不丢。跑一遍测试,三个用例全部通过,绿灯达成。
这里我特意不用任何"高级方案"。有的同事一上来就想写滑动窗口的队列数据结构、令牌桶算法,但这些复杂度应该由需求驱动,而不是由实现者的技术偏好驱动。TDD 的价值之一就是约束你:先满足用例,再考虑优化。
3.4 重构补充:假时钟、边界值、mock 隔离
测试通过之后进入重构阶段。我在这一步补充了几类重要的用例,也踩过几个坑。
第一类是边界值测试。当前测试覆盖了第 11 次拦截、第 5 次放行,但第 10 次(等于阈值)也必须放行。我补了一个用例:store.incr.mockResolvedValueOnce(10),断言结果为allowed: true。这个用例看似多余,但秒杀场景里阈值边界的错误定义会直接导致可用性事故。
第二类是时间窗口测试。限流模块依赖窗口过期,但测试里如果使用真实时间,窗口边界很难稳定控制。正确做法是使用假时钟。Jest 提供了jest.useFakeTimers(),但要注意它只影响 JavaScript 层面的定时器,不影响你 mock 出来的expire方法。在这个模块里,我更关注的是"第 1 次访问后是否正确设置了过期时间",所以直接断言:
test('窗口内首次访问时设置过期时间', async () => { store.incr.mockResolvedValueOnce(1); await limiter.check('user_1'); expect(store.expire).toHaveBeenCalledWith('rate:user_1', 1); });这个用例验证了窗口管理的核心逻辑。
第三类是mock 隔离问题。Jest 的jest.fn()在不同用例之间默认不会自动清除调用记录,所以我在afterEach里加了jest.clearAllMocks(),防止用例之间的 mock 调用相互污染。这个坑我踩过一次:某个用例断言store.incr被调用了两次,结果发现是上一个用例的调用记录没清掉。
Jest 这套 TDD 流程走下来,最大的感受是反馈节奏非常舒服,写一个用例跑一次测试,前后的等待时间几乎可以忽略。这对于秒杀场景这种需要频繁验证边界条件的开发过程,价值非常大。
4. JUnit 实战 TDD:用 Lua 脚本守住库存扣减的原子性
4.1 需求定性:防超卖问题的本质是什么
秒杀系统里最核心的领域逻辑就是库存扣减。防超卖问题的本质不是"写一个减库存的方法",而是"在极高并发下,库存扣减操作必须原子化"。
我们面临的情况是:100 件商品,200 个请求同时抢购,最终扣减成功的次数最多只能有 100 次,库存不能变成负数。这个需求用 Java 实现,通常的落地方案是 Redis + Lua 脚本,因为 Redis 的 Lua 脚本执行是原子的,可以一次性完成"判断库存足够 + 扣减库存"两个操作。现在用 TDD 把"原子扣减"这个行为固定在测试里,再倒逼出 Lua 脚本的落地。
4.2 红灯阶段:先写并发场景下的失败用例
在 Java 项目里,我新建DeductStockServiceTest.java。注意这里测试描述的不是某个具体行代码,而是"库存扣减服务的对外行为":
package com.example.seckill; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.data.redis.core.StringRedisTemplate; import java.util.List; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.atomic.AtomicInteger; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; public class DeductStockServiceTest { private StringRedisTemplate redisTemplate; private DeductStockService deductStockService; @BeforeEach void setUp() { redisTemplate = mock(StringRedisTemplate.class); deductStockService = new DeductStockService(redisTemplate); } @Test void 库存充足时扣减成功返回true() { when(redisTemplate.execute(any(), anyList(), any(Object[].class))) .thenReturn(50L); boolean result = deductStockService.deduct("seckill:stock:1001", 1); assertEquals(true, result); } @Test void 库存不足时扣减失败返回false且库存不为负() { when(redisTemplate.execute(any(), anyList(), any(Object[].class))) .thenReturn(-1L); boolean result = deductStockService.deduct("seckill:stock:1001", 1); assertEquals(false, result); } @Test void 并发扣减时成功总数不超过初始库存() throws InterruptedException { int stock = 100; int threadCount = 200; ExecutorService executor = Executors.newFixedThreadPool(32); CountDownLatch ready = new CountDownLatch(threadCount); CountDownLatch start = new CountDownLatch(1); CountDownLatch done = new CountDownLatch(threadCount); AtomicInteger successCount = new AtomicInteger(0); when(redisTemplate.execute(any(), anyList(), any(Object[].class))) .thenAnswer(invocation -> { // 模拟并发下每次只有一个线程能成功扣减 return successCount.incrementAndGet() <= stock ? 1L : -1L; }); for (int i = 0; i < threadCount; i++) { executor.submit(() -> { ready.countDown(); try { start.await(); boolean ok = deductStockService.deduct("seckill:stock:1001", 1); if (ok) { successCount.incrementAndGet(); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { done.countDown(); } }); } ready.await(); start.countDown(); done.await(); executor.shutdown(); int success = successCount.get() - stock; assertEquals(true, success <= stock); } }注意看第三个用例,我通过CountDownLatch让 200 个线程同时起步,模拟真实的并发碰撞。mock 的thenAnswer用了一个计数器模拟"只有前 100 个请求能扣减成功"。运行测试,DeductStockService类还不存在,编译失败、测试跑红。这正是 TDD 的红灯阶段。
4.3 绿灯阶段:用 Redis Lua 脚本实现原子扣减
红灯之后,我创建DeductStockService.java,用 Redis Lua 脚本实现原子扣减:
package com.example.seckill; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.core.script.DefaultRedisScript; import java.util.List; public class DeductStockService { private static final String LUA_SCRIPT = "local stock = redis.call('get', KEYS[1]) " + "if not stock or tonumber(stock) < tonumber(ARGV[1]) then " + " return -1 " + "end " + "return redis.call('decrby', KEYS[1], ARGV[1])"; private final StringRedisTemplate redisTemplate; public DeductStockService(StringRedisTemplate redisTemplate) { this.redisTemplate = redisTemplate; } public boolean deduct(String stockKey, int count) { DefaultRedisScript<Long> script = new DefaultRedisScript<>(LUA_SCRIPT, Long.class); Long result = redisTemplate.execute(script, List.of(stockKey), String.valueOf(count)); return result != null && result >= 0; } }这段 Lua 脚本先读取当前库存,如果库存不存在或者库存小于扣减数量,返回 -1;否则执行decrby完成扣减。因为 Lua 脚本在 Redis 中是原子执行的,所以"检查库存"和"扣减库存"之间不会被其他请求插入,天然避免了超卖问题。
再次运行测试,三个用例全部通过,绿灯达成。
这之后我做了两步重构:一是把 Lua 脚本抽成常量放到专门的StockScripts类里,方便其他服务复用;二是引入DefaultRedisScript的复用,避免每次扣减都创建一个新实例。TDD 流程走到这里,"红-绿-重构"才算完整闭环。
4.4 落地时的三个坑:序列化、上下文装配、并发断言
JUnit 实战中最容易踩的坑有三个,每个我都在真实项目里付出过成本。
第一个坑是Redis 序列化问题。用StringRedisTemplate没问题,因为它默认使用 String 序列化器。但如果团队习惯用RedisTemplate<Object, Object>,执行 Lua 脚本时键名和参数可能被 JDK 序列化器变成带前缀的乱码,导致 Redis 里根本读不到库存键。TDD 阶段由于 mock 了redisTemplate.execute,这类问题不会暴露,只有到了集成测试阶段才会炸出来。所以 JUnit 项目里,集合测试或集成测试不能省。
第二个坑是Spring 上下文装配。很多 Java 开发者写单测时习惯直接用@SpringBootTest,把整个容器拉起来。但在 TDD 的红-绿循环里,每次跑测试都要启动容器,几秒钟的等待非常影响节奏。我在这个例子中特意用mock(StringRedisTemplate.class)创建纯单元测试,不启动 Spring,跑一个测试只要几百毫秒。如果你的团队 TDD 效率低,先检查是不是把 Spring 容器拉进了每一个测试。
第三个坑是并发测试的断言方式。并发测试最容易写出不稳定的断言,比如"某个特定线程必须成功"。正确的断言方式应该只关注边界条件:成功的总次数不超过库存数,失败的请求都返回 false。在 CI 环境里,线程调度不可控,只有这种基于结果的边界断言才稳定可靠。
5. 两套框架实战结果对比:怎么选不亏
5.1 反馈速度与维护成本:实测感受
同样是"红灯-绿灯-重构"的循环,两套框架给我的体验差异非常明显。
Jest 的反馈速度可以用"零负担"来形容。文件保存后跑测试几乎是瞬时完成,我可以在一个 TDD 循环里反复修改实现、运行测试,完全不觉得等待是一种成本。这种高频反馈带来的好处是,你愿意把大改动拆成很多小步骤,每个步骤都用测试验证,代码质量自然更稳。但 Jest 的维护成本在工作量大之后会体现出来——mock 的管理不够克制时会变得混乱,经常出现"测试全绿但真实环境出问题"的情况。
JUnit 的反馈速度有天花板。纯单元测试配合 Mockito 速度尚可,但如果习惯性用@SpringBootTest,一次几秒钟的等待就会拖垮 TDD 的节奏。不过 JUnit 的维护成本更可预期,Spring Boot Test 体系提供了丰富的测试切片,如@WebMvcTest、@DataJpaTest,能精确控制测试上下文的大小,帮你找到反馈速度和真实性的平衡点。
5.2 测试金字塔里的分工差异
把测试划分成单元测试、集成测试、端到端测试三个层级之后,两套框架的适用位置就非常清晰了。
Jest 更适合承担单元测试和组件测试职责。以秒杀链路的 Node.js 聚合层为例,限流模块、路由转发、响应组装这些逻辑讲求快速迭代,用 Jest 写单元测试覆盖率也容易提升。Jest 对模块 mock 的强大支持,也让服务之间依赖的测试隔离变得简单。
JUnit 则更适合承担服务层和集成层的测试职责。库存扣减、订单状态机、分布式锁这些核心领域逻辑在 Java 服务里,和 Spring 容器、Redis、数据库打交道的机会更多。JUnit 配合 Testcontainers 可以做真实 Redis/MySQL 的集成测试,配合 Spring Boot Test 可以拉起一整套应用上下文做端到端验证。
所以我的观点是:如果一个秒杀系统前后端都从零开发,前端聚合层用 Jest 做快速的单测和组件测试,Java 核心服务上用 JUnit 做单元测试加集成测试,这才是更合理的技术组合。
5.3 团队选型建议:别让框架之争拖累 TDD 本来的目标
最后说说团队选型。我的建议很直接:不要为了"统一技术栈"强行在两个生态里二选一,更不要因为"听说某某框架更好"就把现有测试框架推倒重来。
如果你是纯 Node.js/TypeScript 团队,或者服务形态是 BFF(Backend for Frontend)聚合层,选 Jest 几乎没有悬念。它的零配置、内置 mock、异步测试支持,都和 Node.js 开发者的心智模型高度契合。
如果你是 Java 后端团队,长在 Spring Boot 生态里,选 JUnit 5 + Mockito + AssertJ 是行业标准配置,更关键的是它和 Spring 全家桶的集成深度没有任何替代方案可以比肩。
如果你所在的公司两个体系都有,我建议不要追求一套测试通用方案,而是保持"各技术栈用各自最顺手的框架",但在以下三个层面强制统一:测试金字塔的比例、覆盖率门槛、CI 流程中测试必须作为合并代码的前置门禁。真正对项目质量负责的并不是"用了哪个测试框架",而是"团队是否把测试优先级提到了实现优先级之前"。
我还想多说一句:很多团队纠结 Jest 还是 JUnit,其实是把测试框架当成了救命稻草。框架本身不产生质量,质量来自团队是否认真思考"这个并发场景的边界条件是什么"以及"这个边界条件有没有被一个稳定可重复的用例固定下来"。我在两个框架里走完同一套 TDD 流程后,最终留下的不是哪个框架更好用的结论,而是一整套针对秒杀场景的测试清单:限流阈值边界、库存扣减原子性、重复下单幂等性、Redis 抖动降级行为。这些用例用 Jest 能写,用 JUnit 也能写,区别只在于写的顺手程度。
所以别再纠结框架之争了。如果团队正准备在一个高并发项目里引入 TDD,我的建议是从限流或库存扣减这种边界清晰的小模块开始,用你当前技术栈对应的框架,写一条会红的用例,然后看着它变绿。
我自己在两套秒杀服务里做完 TDD 后还有一个额外收获,就是养成了"先写 bug 测试再修代码"的习惯。遇到线上超卖或者限流失效的问题,我第一反应不是去看代码、猜原因,而是先把"必现 bug"的测试写出来,让它在旧代码上跑红,然后修代码让它跑绿。这个习惯用 Jest 和 JUnit 都行得通,但前提是你已经把 TDD 的节奏融入到了日常开发里,而不是把它当成一个需要"专门腾时间来做"的额外任务。