ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

前后端TDD实战:用Jest与JUnit构建高可靠秒杀系统

前后端TDD实战:用Jest与JUnit构建高可靠秒杀系统 线上事故复盘时最怕听到一句话这个分支不是测过了吗尤其秒杀系统这种流量洪峰场景测试全绿、上线秒挂的案例我见过不止一次。后来我把秒杀系统前后端的实现方式整个换了一遍后端用 Spring Boot JUnit 5 写服务前端用 React Jest 写组件全程按测试驱动开发TDD的节奏推进——先写一个必然失败的用例再写让它通过的实现最后重构。这篇东西不是来争论 Jest 和 JUnit 谁的断言更好看而是把同一套秒杀需求分别放到两端跑一遍 TDD聊聊各自的难点、收益和节奏差异。适合正在带电商中台、对测试框架选型和落地方式摇摆不定的团队参考。1. 秒杀系统的复杂性坑位几乎是为 TDD 量身定做的题目什么样的系统最适合用 TDD我的判断标准是业务规则必须明确而且回归风险要高。秒杀系统正好同时满足这两点。它表面上是一个商品定时开抢但往里看规则死角非常多。1.1 秒杀业务里最容易被写错的三个规则第一个是库存扣减的原子性。秒杀场景下的库存不是一个普通的商品库存字段它在活动开闸一刹那可能同时被几十万个请求一起读改写。如果业务代码里没有原子操作超卖就会像呼吸一样自然发生。这个规则不是写对就行而是要写对到并发场景下仍然成立。第二个是售罄状态的传导链。从数据库看库存可能只是从 1 变成 0但从业务体验看它意味着后端下单接口要立刻返回已抢光前端按钮要立刻禁用、文案切换。中间隔着服务层、缓存、接口、组件状态好几层任何一层断了用户就会看到有库存但点不动或者抢到了但实际超卖的诡异现象。第三个是限流与放行的边界。限流不是简单粗暴地拦一拦而是要精确到每秒允许多少请求进入多余的排队还是拒绝这个阈值直接关系整个系统的成本和体验。写死在代码里的数字没有意义要有测试把它固定下来让每一次改动都能被感知。1.2 TDD 在这里的作用不是多写测试而是先规定行为没有 TDD 的时候大多数人写秒杀功能是这样的先写好库存扣减逻辑再写接口最后补一个测试验一把。如果测试没过就顺手改业务代码让它过。这种做法最致命的地方在于测试很容易被顺着实现写出来于是测试和实现共享同一个错误假设最后一起错。TDD 的做法恰恰相反。先写一个描述正确行为的测试比如库存扣到 0 之后再来请求必须失败而且库存不再变化。这个测试先失败因为你还没写任何实现。然后在让它变绿的过程中实现被逼着去满足行为而不是让行为迁就实现。高并发场景下这个差别尤其明显。秒杀涉及锁、Redis、消息队列开发者经常在思考我应该怎么处理并发的时候糊里糊涂地写出有缺陷的方案。先写测试会强制把语义讲明白到底什么情况下算成功、什么情况下算失败、失败之后状态如何。这些边界在红灯亮起的那一刻就被定义好了而不是等到线上事故才被暴露。1.3 为什么同时用 Jest 和 JUnit 各跑一遍选 Jest 是因为它在前端的组件测试、交互测试、异步测试上足够成熟JSDOM 加 fake timers 的组合非常适合模拟秒杀页面里的倒计时、按钮竞态和请求防重逻辑。选 JUnit 5 则是因为它是 Java 生态里最标准的单元测试与集成测试框架配合 Spring Boot Test 和 Mockito 可以直截了当地验证服务层、数据层和限流组件的行为。同一套秒杀需求前后端各跑一遍最大的好处是把测试设计这件事从维度上拆开了前端重点测交互后端重点测数据和并发然后再互相衔接。这才是完整实践记录的逻辑起点。2. 动手前先把需求翻译成测试清单TDD 起步最纠结的不是怎么写第一个测试而是不知道要测哪些点。秒杀系统的需求描述往往是做一个秒杀活动用户到点可以抢购商品这种一句话需求直接开始写测试会无从下手。我的习惯是先把需求拆成一张行为清单每个行为对应一个或多个测试。2.1 后端行为清单库存、并发、限流三个维度我把后端的核心行为整理成下面这张表每一行都是一个测试要点编号行为描述测试要点B01库存足够时用户可以扣减成功返回值、剩余库存、扣减记录B02库存不足时扣减失败返回失败、库存不为负数B03并发请求下不允许超卖并发 N 个请求成功数等于库存量B04同一用户对同一商品不允许重复下单重复请求只成功一次B05限流器每秒放行不超过设定阈值突发请求数被限制B06超卖发生后事务回滚数据库一致性不被破坏这些行为在写业务代码之前就固定下来后端的 TDD 就变成了一个让表格逐行变绿的过程。每完成一个行为代码库就多一层保障。2.2 前端行为清单状态、交互、请求防重前端的重点不在数据一致性而在用户可感知的状态和交互。秒杀页面最常见的问题就是按钮可点时用户狂点、倒计时归零瞬间没有切换状态、接口失败后页面提示不友好。我列出的清单如下编号行为描述测试要点F01活动未开始时显示倒计时且按钮禁用倒计时格式、按钮状态F02活动进行中按钮可用且文案正确按钮文案、可点击性F03库存为 0 时按钮禁用显示已抢光禁用状态、文案F04点击后进入提交中状态防止重复点击重复调用不触发多次请求F05接口返回失败后给用户明确提示错误文案、恢复可操作F06秒杀结果弹窗正确展示成功或失败弹窗内容、关闭交互2.3 测试清单的优先级清单列完之后不要急着全部实现。TDD 讲究小步快跑我通常先把 B01、B02、F01、F02 这类主链路行为做掉因为它们决定功能能不能成立接着处理 B03、B04、F03、F04 这类并发与防重行为它们决定系统在真实流量下会不会出事最后再处理 B05、B06、F05、F06 这类兜底行为它们是锦上添花但也必不可少。这样拆的好处是即使时间不够至少主链路和防并发这两层是可靠的不会出现基本功能能跑、但一上线就炸的尴尬。3. Jest 侧的 TDD 实战从组件到请求层的红绿循环Jest 侧我选了一个典型的秒杀商品卡片作为实验对象包含倒计时、价格、秒杀按钮、结果弹窗。整个页面的行为被我拆成三个测试块下面挨个过。3.1 第一个测试倒计时格式化函数写倒计时组件之前我先写了formatCountdown这个纯函数的测试。纯函数不依赖 DOM也不依赖任何 API是前端 TDD 最舒服的起点。// countdown.test.js import { formatCountdown } from ../src/utils/countdown; describe(秒杀倒计时格式化, () { test(剩余1分钟格式化为 00:01:00, () { expect(formatCountdown(60 * 1000)).toBe(00:01:00); }); test(剩余0毫秒归零, () { expect(formatCountdown(0)).toBe(00:00:00); }); test(超过1小时正确进位, () { expect(formatCountdown(90 * 60 * 1000)).toBe(01:30:00); }); test(负数按0处理, () { expect(formatCountdown(-1)).toBe(00:00:00); }); });写完测试先跑一遍此时实现文件还不存在Jest 会报找不到模块这就是标准的 red 阶段。然后去写实现// countdown.js export function formatCountdown(diffMs) { const safe Math.max(0, Math.floor(diffMs / 1000)); const hours Math.floor(safe / 3600); const minutes Math.floor((safe % 3600) / 60); const seconds safe % 60; return [hours, minutes, seconds] .map((v) String(v).padStart(2, 0)) .join(:); }跑测试全绿refactor 阶段暂时没有要处理的。这个例子很小但它示范了最重要的一件事先定义格式化成什么样是正确再写计算逻辑。后面无论你怎么改实现只要测试不坏前端展示就不会偏差。注意负数的用例是从真实踩坑里来的——实际秒杀活动里存在本地时钟与服务器时钟不同步导致倒计时为负的情况。如果你写的测试没有覆盖这个边界真实用户就会看到-1:-30:00这种离谱时间。顺带说一句倒计时组件本身我用了jest.useFakeTimers()来推进时间。它有一个比较隐蔽的坑必须在beforeEach里调用useFakeTimers在afterEach里调clearAllTimers否则定时器泄漏会让后续用例随机失败。倒计时格式的纯函数要单独抽出来测组件里别在被定时器包裹的状态里做过多逻辑。3.2 第二个测试库存为 0 时按钮禁用并显示已抢光倒计时完成后页面的核心交互是秒杀按钮。这个按钮的状态必须跟库存严格绑定库存有货时可点抢光后变灰。我用 Jest React Testing Library 写组件测试。// FlashSaleButton.test.jsx import { render, screen, fireEvent } from testing-library/react; import FlashSaleButton from ../src/components/FlashSaleButton; describe(秒杀按钮, () { test(库存为0时按钮禁用并展示已抢光, () { render(FlashSaleButton stock{0} onClick{jest.fn()} /); const btn screen.getByRole(button); expect(btn).toBeDisabled(); expect(btn).toHaveTextContent(已抢光); }); test(库存大于0时按钮可点击, () { render(FlashSaleButton stock{5} onClick{jest.fn()} /); const btn screen.getByRole(button); expect(btn).not.toBeDisabled(); }); });这里写测试的时候会逼你想清楚一个问题按钮的禁用状态是由父组件传进来的stock决定的还是按钮自己内部判断的从组件设计的角度看应该由父组件负责查库存并把状态传给按钮按钮只做展示。这在 TDD 里表现为测试不会去测你调用过接口没有而是测你收到 0 库存之后表现成什么样子。职责边界在写测试的过程中被自然划清了。实现FlashSaleButton只需要十几行但先写测试的收益在改版的时候体现得最明显。后来产品提了一个需求说抢光之后要把文案换成下一场再战我直接改组件跑测试确认按钮禁用逻辑没有被破坏文案变更再新增一个测试覆盖整个过程非常安心。3.3 第三个测试请求层的幂等保护秒杀页面和高并发关系最密切的其实是请求层。用户手速再快一秒钟也不可能点几百次但用脚本刷的人可不一定。最稳妥的做法是让提交函数自带幂等保护——第一次提交后后续重复调用直接忽略。// orderSubmitter.test.js import axios from axios; import { createOrderSubmitter } from ../src/services/order; jest.mock(axios); describe(秒杀订单提交器, () { test(连续调用只发起一次实际请求, async () { axios.post.mockResolvedValue({ data: { code: 0, msg: 排队中 } }); const submitter createOrderSubmitter(); await Promise.all([submitter.submit(1001), submitter.submit(1001)]); expect(axios.post).toHaveBeenCalledTimes(1); }); test(第一次提交失败后允许重试, async () { axios.post .mockResolvedValueOnce({ data: { code: 500, msg: 系统繁忙 } }) .mockResolvedValueOnce({ data: { code: 0, msg: 排队中 } }); const submitter createOrderSubmitter(); const first await submitter.submit(1001); const second await submitter.submit(1001); expect(first.success).toBe(false); expect(second.success).toBe(true); expect(axios.post).toHaveBeenCalledTimes(2); }); });这个测试有意思的地方在于第二个用例幂等保护不能影响用户失败后的重试。如果简单地把提交中的 flag粗暴地设置成永久锁定失败后就永远无法再次提交了。测试把这一层微妙的规定写清楚了。对应的实现// order.js export function createOrderSubmitter() { let pending false; return { async submit(productId) { if (pending) { return { success: false, msg: 正在提交请勿重复操作 }; } pending true; try { const { data } await axios.post(/api/flashsale/order, { productId }); return { success: data.code 0, msg: data.msg }; } finally { setTimeout(() { pending false; }, 500); } }, }; }这里用setTimeout模拟了一个 500ms 的提交冷却期失败后 500ms 内不允许再次点击之后恢复。实际项目中这个冷却期很容易被忽略但正是这个细节挡住了绝大多数重复请求。3.4 Jest 侧 TDD 的节奏心得在 Jest 侧跑完这几个用例我最强烈的感受是watch 模式提供的那种即时反馈是后端体验不到的。你写一行实现观察一次测试是否变绿这个循环非常细粒度。但也要控制粒度别为了绿灯写一堆琐碎的实现那就变成了测试表演。另一个心得是别把组件测试写得太脆。比如断言按钮 textContent 等于 已抢光会比按钮 disabled 属性为 true更容易在样式调整时误伤。组件测试应该优先测行为而不是 DOM 细节。4. JUnit 侧的 TDD 实战库存扣减与限流规则后端的 TDD 没有前端那么直观因为很多逻辑依赖数据库、Redis 这些外部组件。好在秒杀系统的核心业务规则可以抽象成服务层的几个方法这给了单元测试很大的发挥空间。4.1 第一个测试库存扣减的黄金法则先写库存扣减服务的测试。我定义了一个FlashSaleStockService核心方法是tryDeduct(productId, userId)。它应该完成两件事库存足够就扣减并返回成功库存不够就返回失败并且库存不能变成负数。package com.example.flashsale.service; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; import static org.junit.jupiter.api.Assertions.*; SpringBootTest class FlashSaleStockServiceTest { Autowired private FlashSaleStockService stockService; private static final long PRODUCT_ID 1001L; BeforeEach void setUp() { stockService.resetStock(PRODUCT_ID, 10); } Test DisplayName(库存充足时扣减成功且库存减一) void shouldDeductWhenStockEnough() { boolean success stockService.tryDeduct(PRODUCT_ID, 1L); assertTrue(success); assertEquals(9, stockService.getStock(PRODUCT_ID)); } Test DisplayName(库存不足时扣减失败且库存不为负) void shouldRejectWhenStockNotEnough() { stockService.resetStock(PRODUCT_ID, 1); assertTrue(stockService.tryDeduct(PRODUCT_ID, 1L)); assertFalse(stockService.tryDeduct(PRODUCT_ID, 2L)); assertEquals(0, stockService.getStock(PRODUCT_ID)); } }这里我故意把库存不为负和扣减失败放在同一个用例里验证因为真实的秒杀场景中如果第一层 cache 库存判断没有抗住并发第二层数据库库存就可能在 0 的基础上继续被扣成负数。测试必须同时锁定两个行为操作结果正确数据状态正确。为了让这个测试通过我写了一个基于 JVM 内存的简易实现用AtomicInteger模拟原子扣减。这个实现不依赖 Redis 和数据库非常适合单元测试环境下跑Service public class FlashSaleStockService { private final ConcurrentHashMapLong, AtomicInteger stockMap new ConcurrentHashMap(); public void resetStock(long productId, int stock) { stockMap.put(productId, new AtomicInteger(stock)); } public int getStock(long productId) { return stockMap.getOrDefault(productId, new AtomicInteger(0)).get(); } public boolean tryDeduct(long productId, long userId) { AtomicInteger stock stockMap.get(productId); if (stock null) { return false; } while (true) { int current stock.get(); if (current 0) { return false; } if (stock.compareAndSet(current, current - 1)) { return true; } } } }这里用compareAndSet保证了扣减操作的原子性读改写三步在并发下不会被穿插。B04 里提到的同一用户不能重复下单在真实项目里我一般放进订单模块单独防御先写第二次创建必须失败的用例再实现一张以userId productId为键的幂等表。思路跟这里完全一样只是焦点从库存转向了订单幂等。4.2 第二个测试并发扣减不超卖单线程的扣减测试通过后立刻要补的就是并发场景。这是秒杀系统最容易翻车的环节。我写了一个并发测试初始化库存 10用 20 个请求同时发起扣减最后断言成功的次数恰好等于 10库存归零。Test DisplayName(并发20个请求只会扣减成功10次) void shouldNotOversellUnderConcurrency() throws InterruptedException { stockService.resetStock(PRODUCT_ID, 10); int threadCount 20; ExecutorService pool Executors.newFixedThreadPool(10); CountDownLatch ready new CountDownLatch(threadCount); CountDownLatch start new CountDownLatch(1); CountDownLatch done new CountDownLatch(threadCount); AtomicInteger successCount new AtomicInteger(); for (int i 0; i threadCount; i) { pool.execute(() - { ready.countDown(); try { start.await(); long userId ThreadLocalRandom.current().nextLong(100000, 999999); if (stockService.tryDeduct(PRODUCT_ID, userId)) { successCount.incrementAndGet(); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { done.countDown(); } }); } ready.await(); start.countDown(); done.await(); assertEquals(10, successCount.get()); assertEquals(0, stockService.getStock(PRODUCT_ID)); pool.shutdown(); }这里CountDownLatch的作用是让 20 个线程尽可能在同一时刻冲出去模拟真实秒杀瞬间的并发冲击。注意start.countDown()要在所有线程都准备好之后再调用否则并发度会打折扣测试就失去了意义。TDD 的节奏在这里非常清晰先跑这个测试如果用普通的synchronized或者在没有原子保护的地方先 get 再 set结果很容易断言失败改成compareAndSet之后测试变绿这个过程就是行为驱动实现的典型呈现。4.3 第三个测试限流器的阈值边界限流是秒杀系统的第二个安全阀。我用一个固定窗口计数器来模拟限流器每秒最多放行 N 个请求超过的直接拒绝。写实现之前先写测试package com.example.flashsale.limit; class FixedWindowRateLimiterTest { Test DisplayName(1秒内最多放行规定数量的请求) void shouldLimitRequestsWithinWindow() { FixedWindowRateLimiter limiter new FixedWindowRateLimiter(10, 1000); int granted 0; for (int i 0; i 50; i) { if (limiter.tryAcquire()) { granted; } } assertEquals(10, granted); } Test DisplayName(窗口过期后恢复放行) void shouldResetAfterWindowExpires() throws InterruptedException { FixedWindowRateLimiter limiter new FixedWindowRateLimiter(2, 200); assertTrue(limiter.tryAcquire()); assertTrue(limiter.tryAcquire()); assertFalse(limiter.tryAcquire()); Thread.sleep(250); assertTrue(limiter.tryAcquire()); } }第二个测试定义了一个容易被忽视的边界固定窗口的重置时机。如果窗口过期判断写错过期的请求永远不能通过活动开始瞬间用户连进都进不来或者窗口重置太激进限流形同虚设。测试先把窗口过后恢复放行这一行为钉死实现就不会跑偏。通过测试的简单实现如下public class FixedWindowRateLimiter { private final int limit; private final long windowMs; private int counter; private long windowStart; public FixedWindowRateLimiter(int limit, long windowMs) { this.limit limit; this.windowMs windowMs; this.windowStart System.currentTimeMillis(); } public synchronized boolean tryAcquire() { long now System.currentTimeMillis(); if (now - windowStart windowMs) { windowStart now; counter 0; } if (counter limit) { counter; return true; } return false; } }需要注意固定窗口限流本身有临界突刺问题窗口边缘两侧可以各放行 N 个请求造成 2N 的突发流量。若要根除这个边界风险需要换成滑动窗口或令牌桶算法。但在秒杀这种先到先得的场景下固定窗口的误差通常可以接受而且代码简单测试也更容易写清楚。4.4 JUnit 侧 TDD 的实践经验后端 TDD 的节奏没有前端轻快原因在于依赖注入、Spring 上下文启动、数据库事务都会拖慢测试执行。所以我的建议是把纯逻辑如限流器、库存计数拆成不依赖容器的类用普通 JUnit 测试跑把服务层与数据层的集成测试放到独立的 profile 中避免每次改代码都要等 Spring 上下文重新启动。另外JUnit 5 的DisplayName值得好好用。中文场景描述比testXxx这种命名直观得多秒杀业务的评审同事即使不写代码看测试报告也能明白噢这个用例在验证并发不超卖。5. 同一套业务两种框架的 TDD 节奏对比两边都跑完之后我发现最妙的不是单个框架的使用技巧而是同一套业务在两端跑 TDD 时暴露出的手感差异。把它们摊开对比才知道在哪个环节应该更用力。5.1 断言风格决定了测试像什么Jest 的链式断言expect(btn).toBeDisabled()几乎等同于自然语言读起来像在描述界面行为JUnit 的assertEquals(10, successCount.get())更工程化像在验证一条业务契约。这两种风格的差异会影响你写测试时的思维方式。前端测试天然倾向用户感受所以自然语言风格的断言更贴切后端测试关心状态是否严格正确断言方法组合起来像在写法律条文反而是优点。5.2 Mock 的对象层级完全不同Jest 里mock axios 模块是一个很自然的操作因为前端请求层就是一个模块边界jest.mock(axios);JUnit 侧通常用 Mockito 来 mock 一个类或者一个 Bean。问题在于秒杀系统的数据一致性恰恰依赖于数据库、Redis 这些基础设施的真实行为如果过度 mock测试通过得再快也是虚假繁荣。我在 JUnit 侧的做法是对限流器、库存计数器这种纯逻辑类直接用真实实现测对数据库这类外部依赖才考虑用MockBean做隔离。对比维度JestJUnit 5断言可读性链式、接近自然语言方法调用、接近契约Mock 风格模块级 mockjest.mockBean/类级 mockMockito异步处理async/await fake timersExecutorService CountDownLatch并发验证事件循环内的协作式并发真实多线程抢占式并发运行反馈watch 模式毫秒级纯 JUnit 快Spring 上下文慢5.3 并发验证的颗粒度差异Jest 侧模拟并发最接近的手段是Promise.allawait Promise.all([submitter.submit(1), submitter.submit(1)]);但 JavaScript 单线程模型决定了这只是在同一个事件循环里交替执行微任务并不存在真正的并行写竞争。它验证的是同一时间点发起多次调用是否会重复触发而不是两个线程同时读写一个变量是否数据一致。JUnit 侧用CountDownLatch ExecutorService则是真正的多线程并行可以制造真实的竞争条件。这两者在语义上有本质区别如果你把前端的 Promise.all 结果当成并发证明就会产生虚假的安全感。5.4 反馈节奏决定了 TDD 的舒适度Jest 的 watch 模式几乎可以做到每次保存文件测试结果即时更新这种毫秒级反馈让红绿循环变得非常顺畅。JUnit 如果用纯单元测试不启动 Spring 上下文反馈也很快但只要一用到SpringBootTest启动容器的时间瞬间拉到秒级红绿循环的节奏就被拖慢了。我的折中方案把业务逻辑尽量写成 POJO 加接口加轻量实现核心规则用纯 JUnit 测只有真正需要验证数据落地和事务的时候才引入 Spring Boot 集成测试。这样既保住了 TDD 的快节奏又不牺牲集成可靠性。5.5 前后端怎么衔接才不会各测各的前后端测试是两条平行线但它们必须共享同一个协议。我的做法是维护一个最简接口契约文件定义/api/flashsale/order的请求参数、成功响应和失败响应。前端 Jest 测试中 mock 的响应数据必须基于这个契约后端 JUnit 测试里验证的 JSON 字段也必须与契约一致。哪怕只是新增一个code429的限流返回码也要先更新契约再各改各的测试。这套机制本质上让 TDD 从单一模块扩展到了整个前后端协作边界。6. 踩坑笔记TDD 全绿之后仍然爆雷的三种场景TDD 不是银弹。我在实验过程中踩了一些坑这些坑不是框架造成的而是使用姿势与业务理解的问题。写出来给大家避雷。6.1 测试环境的执行顺序与生产不同第一个坑出现在库存服务的并发测试里。我的测试先调用了resetStock再跑并发扣减这在单测中理所当然。但真实秒杀活动里库存的初始化是由运营后台在活动开始前配置的不会每次都重置。如果业务代码在活动进行中出现一次异常导致部分库存回滚而下次并发请求又没经过初始化直接打到扣减逻辑上行为可能和测试里的不完全一致。解决办法是在测试里增加一个不重置库存直接基于现有状态扣减的用例模拟活动中途的库存状态。这个用例帮助我发现了一个 buggetStock在全内存模式下读到的可能是旧值导致前端显示的库存和真实可扣减库存不一致。后来把所有读取统一走同一个查询方法解决。6.2 过度 Mock 导致真实调用挂掉前端有一个用例我把 axios mock 成了永远返回成功。测试全绿但上线后发现当接口返回 HTTP 500 时前端提交器没有做任何错误处理用户点击后界面一直停在提交中。原因在于我的 mock 只模拟了成功分支错误分支在真实调用前从未被执行过。这是一个很典型的 TDD 盲区mock 过度掩盖了真实接口的错误语义。修正方式给createOrderSubmitter增加对 axios 错误的测试用例模拟 reject 的情况并断言最终状态能回到可重新提交。从此无论接口出什么错页面至少不会卡死。6.3 对着实现写测试测试失去了回归价值我还犯过一次方向性错误在一个支付回调的测试里我先看了tryDeduct的实现然后照着它的分支把测试写出来。测试全绿但它测的只是实现路径不是业务契约。后来有需求调整了支付回调里重复回调的处理方式实现变了测试却还是照旧通过——它根本没在保护任何行为。正确的做法是写完测试后你应当能把它读给不懂技术的运营同事听并且让对方判断这就是我们想要的业务规则。如果做不到这一点说明测试在描述实现而不是描述业务。6.4 我的 TDD 落地清单结合这次的实验我总结了一份可复用的 TDD 落地清单写测试前先把行为清单列出来用用户/系统应当……的句式描述而不是用这个方法返回 true/false描述。每个测试只绑定一个行为维度避免把库存扣减和返回到前端状态混在一个用例里。mock 外部依赖时至少覆盖成功、失败、异常三条链路。并发测试尽量使用真实的线程和闩锁工具不用死循环阻塞代替真正的并发。保持红绿循环足够短红灯亮了别急着写实现先确认是需求没满足还是测试写错了。把秒杀系统前后端各跑一遍 TDD 之后我对测试驱动开发这四个字的理解比之前清晰了很多——它不是一种写代码的技巧而是一种逼迫你先把需求想清楚、把边界定义出来、把行为契约钉死的工作方式。Jest 和 JUnit 的差异更多体现在反馈节奏和测试粒度上真正重要的不是选哪个框架而是愿不愿意在写代码之前先用失败用例把自己的思路拦住。最后再说一个实操中的感受如果你所在的团队还没有实践过 TDD别想着一次性把秒杀这种大工程全部 TDD挑一个库存扣减或者限流器这样的小模块先写完测试再写实现把红灯变绿的过程走一遍比看一百篇文章都有效。
返回列表