deep-dives

23 posts

你有一半的 Cucumber 场景被跳过了。这就是那个状态在欺骗你的原因。

Cucumber 的 'skipped' 状态隐藏了三种完全不同的问题。以下是如何诊断是哪一种在侵蚀你的测试套件,以及如何阻止它。

你有一半的 Cucumber 场景被跳过了。不是失败,是跳过。 那个黄色的状态比红色构建更糟糕。它让你的套件看起来很健康,却在一个礼貌的标签下隐藏了三种完全不同的问题。一个被跳过的场景可能意味着你的 tag 过滤排除了它,某个 step definition 丢失了,或者一个 hook…

你的 Gherkin 规格说明正在对你撒谎

一旦你指望人工手动维护,Gherkin 规格说明就会立刻与现实脱节。本文介绍如何通过自动化检查,让 feature 文件始终如实反映系统行为。

你的 Gherkin 规格说明正在对你撒谎。 并非故意。它们起初是忠实的。但六个冲刺之后,有人重构了结账流程,却忘了更新 这一步。 文件仍然通过,因为 step definition 依然存在。只是它调用的代码早已不再与场景实际描述的行为相符。你拿到了一片绿的测试,以及虚假的安心。这就是 BDD…

编译器检查语法,测试应该检查架构。

大多数团队把架构规则写在维基里。这里介绍如何把它们写成可执行的测试,当你的依赖图发生漂移时让 CI 挂掉。

你的测试套件验证了 在输入正确时返回 42。但它没有验证 是否被允许导入 。编译器对两者都满意。你的单元测试对两者都满意。但其中之一是架构违规,六个月后会让你花上一周时间来重构。 这就是盲区。我们为逻辑写测试,却假设结构会自行管好。并不会。…

你的领域层引用了 Postgres。你的 CI 却视而不见。

整洁架构的图在白板上看着很美。本文介绍如何在构建流水线中强制执行依赖方向,让领域代码永远无法触及基础设施。

团队里有人刚在 里引入了 。PR 编译通过。测试全绿。代码审查长达三百行,却没人发现。 三个月后,你想把领域逻辑抽成一个共享包。做不到。它依赖了 Postgres 的类型、连接池逻辑,以及一个只在单体应用里存在的自定义驱动封装。白板上那套同心圆架构图,此刻成了笑话。 这就是没有自动化 enforcement…

你的架构图早就是谎言了

架构文档在你保存的那一刻就开始腐烂。以下是如何利用代码生成图表、ADR 和自动化架构测试来保持文档的诚实。

我在 wiki 里见过的每一张架构图都是错的。不是那种惊天动地的错,而是悄无声息、日积月累的错。标着 "Auth" 的服务六个月前就被拆成了三个微服务。标着 "sync call" 的箭头现在已通过队列变成了异步调用。标着 "PostgreSQL" 的数据库在一次紧急故障处理中被迁移到了别的系统,却没人更新那个方框。…

你的重试循环假设第一次请求失败了。大概率并没有。

超时或崩溃并不意味着你的 API 请求已经丢失。本文介绍幂等键(idempotency key)如何让重试变得安全,以及真正防止重复请求的数据库存储模式。

你的服务在处理 请求时中途崩溃。客户端看到超时,于是重试。结果出现了两笔扣款。客户很生气。数据库是一致的。但业务逻辑不是。 这不是边缘情况。这是分布式系统的默认行为。网络丢包。容器在请求中途被 OOM 杀掉。负载均衡器对已经到达后端的请求返回 502。如果你的 API…

比进程更长寿的锁:分布式租约的真实工作原理

内存中的互斥锁在服务器重启后随即消失。本文介绍带有 fencing token 和 TTL 的分布式租约如何防止崩溃后的重复执行,以及它们在哪里仍然会失效。

你的 无法挺过 。它也无法挺过 OOM、部署滚动更新或节点重启。进程退出的瞬间,锁就消失了。如果这个锁正在保护一个定时任务、数据迁移或主节点选举,那么你现在会有两个进程都坚信自己是唯一在运行的那个。 这不是你的互斥锁有 bug。这是一个范畴错误。进程本地的锁无法保护集群范围的资源。…

无 goroutine、无定时器、无后台开销的熔断器

大多数熔断器库都会启动后台线程来探测恢复。你根本不需要它们。本文介绍一种请求驱动的设计,在消除所有后台开销的同时,不牺牲正确性。

我审查过的每一个生产环境熔断器,最终都会拉起一条后台线程。它可能是 Go 的 goroutine、Java 的 ,或是 Rust 的 tokio task。干的事情永远一样:每隔几秒唤醒一次,检查下游服务是否已经恢复,然后把状态从 OPEN 切回 CLOSED。…

你的 Web 服务有一条优雅关闭路径。这就是 Bug。

Crash-only 软件将每一次失败视为崩溃,将每一次启动视为恢复。对于 Web 服务来说,这意味着删除你的关闭逻辑,并设计出能在 kill -9 下存活的状态。

你的 Web 服务有一个关闭处理器。它会刷写缓冲区、关闭连接、写入检查点。也许你曾经测试过一次。在生产环境中,它可能只在每年一次的有计划部署时才会运行。其余时间,你的服务死于 OOM kill、节点驱逐、断电,或是超时后被 SIGKILL 的部署。 Crash-only…

看不懂突变改了什么,怎么写测试杀死它?

mutation testing 发现了一个 survivor,但你根本不知道这个 mutation 做了什么。这里有一个分步方法,让你不用先理解 mutant 也能写出正确的测试。

你的 mutation testing 报告里全是 survivor,其中至少有一个你完全看不懂。 工具说它把第 47 行的 换成了 ,或者把整个条件块替换成了 ,又或者变异了一个你根本不知道正在被测试的字符串字面量。你 diff 看了三遍。你还是不明白这个 mutant…

认证代码需要 90% 的变异覆盖率。你的字符串工具函数不需要。

为什么在整个代码库强制执行单一变异分数是错误的,以及如何根据实际风险设置按模块划分的阈值。

在整个代码库强制执行单一变异分数,是让团队厌恶测试的绝佳方式。 用 PIT 或 Stryker 跑一个典型仓库,你会看到同样的模式:认证模块得 40%,字符串工具类冲到 95%,ORM 层则在 60 多分徘徊。本能反应是设一个全局门槛,比如 70%,堵住所有低于它的 PR。两个冲刺之后,就会有人在 CI…

你的测试全过了。变异得分只有40%。以下是你该从存活变异体中读到的东西。

代码覆盖率告诉你很安全。变异测试告诉你,你的测试大多只是摆设。以下是存活变异体如何暴露这一鸿沟,以及如何弥合它。

你的测试全过了。覆盖率报告显示87%。但你的变异得分是40%,一半的变异体还活着。 这个40%不代表你的代码有毛病。它代表你的测试有毛病。覆盖率衡量的是测试运行期间哪些行被执行了。变异测试衡量的是,如果那些行开始做错事,你的测试会不会发现。40%的变异得分意味着,60%本可以被引入代码的 bug 会大摇大摆地通过…

Rust 的变异测试确实能用,但你的编译时间会恨你

cargo-mutants 能找出那些只是假装在验证代码的测试。本文介绍变异测试在 Rust 中的工作原理、它能捕捉什么问题,以及编译时间成本是否值得。

你有 100% 的行覆盖率。每个分支都执行到了。每个函数都被调用了。然后有人在定价逻辑里把一个 改成了 ,跑了一遍测试,全部通过。 这不是理论问题。它真实发生在你的测试执行了代码,却没有真正验证行为的时候。覆盖率衡量的是哪些行被执行了,而不是哪些输出被检查了。变异测试通过故意引入小的…

变异测试需要跑4小时。团队到底怎么在CI里用它?

大多数团队不会每次提交都跑完整的变异测试套件。以下是工程团队如何在不破坏构建流水线的情况下,真正把变异测试集成进CI的做法。

如果你的变异测试套件需要跑四个小时,恭喜你。你证实了大家早就怀疑的一件事:你的测试套件存在漏洞。 你不可能每次 push 都到 CI 里跑这个。没有哪个团队会这么干。问题不在于你能否承受每次提交花四小时,而在于你能否承受带着“测试通过但实际上什么也没验证”的代码上线。…

单元测试全绿,但你的数据照样消失

Mock 数据库测试验证的是 SQL 语法,而不是数据行能否在崩溃、并发写入或 schema 不匹配时存活。下面介绍如何真实地测试持久化。

如果你在测试中 Mock 数据库,你实际上只是在验证仓库层调用了正确的方法。你并没有测试数据能否在崩溃后存活、唯一约束是否真的会阻止重复数据、或者事务失败时是否会回滚。 这个区别很重要。Mock 的 返回你预设的值。真实的…

不被 mock action 淹没地测试 Redux

为每个 Redux action 写 mock 会让你的测试变成 changelog 校验器。下面介绍如何用真实的状态流转来测试 store。

如果你写过这样的测试:验证 被调用时传入的 payload 结构完全匹配,那么你的测试会在每次有人重命名常量时崩溃。 这不是在测试你的状态逻辑,而是在测试你的手指有没有敲对字符串。 Redux 测试教程通常以 Jest mock 开头:spy ,断言 action creator 被调用了,断言 type…

100 次测试运行是个谎言:如何真正确定你的 Property-Based Tests 规模

Property-based testing 中默认的 100 个 example 是一种社会妥协,而非统计策略。以下是如何选择与你的信心需求和 CI 预算相匹配的运行次数。

如果你用默认的 100 个 example 来运行 property-based tests,那你两头不讨好。你的 CI 比实际需要更慢,而你仍然没有抓到真正重要的 bug。 这个数字并不神奇。包括 Hypothesis 在内的大多数库默认设为 100,只是因为它是一个看起来保险的整数。但「感觉保险」不是测试策略。…

Rust 中的 property-based tests 能找到单元测试漏掉的 bug

基于示例的测试只覆盖你想得到的输入。property-based testing 生成随机数据,检查 invariants,并将失败 shrink 到最小反例。

你写了一个 函数。你用 和 测了它。测试通过。你发布了。 用户传入了一个单元素切片。你的函数把它漏掉了。他们提了 issue。你盯着测试文件,想不通这么明显的问题自己是怎么漏掉的。 你漏掉它,是因为 example-based testing 只能抓住你提前预料到的 bug。测试套件里的每一个…

你的单元测试通过了,但生产代码仍然是坏的

代码覆盖率指标制造了一种虚假的安全感。以下是单元测试为何漏掉那些让你夜不能寐的 bug,以及你应该测什么。

你有 90% 的代码覆盖率,却仍在凌晨两点被告警吵醒。 单元测试通过了。CI 是绿的。bug 还是溜进了生产环境。覆盖率没有撒谎,但它也没有说出真相。它衡量的是哪些行被执行了,而不是哪些行为真正被验证了。…

Rust 的 Runtime Contracts 可以在 Release 构建中零开销,但编译器不会替你实现

Rust 会自动剥离 debug assertions,但真正的 design-by-contract 需要的远不止 debug_assert!。本文介绍如何在 Release 二进制文件中实现零成本的 runtime contracts,并让它们彻底消失。

Rust 可以在开发阶段强制执行 runtime contracts,并在 release 构建中将其完全抹除。前提是这门语言并没有把 contracts 当作一等公民。你会拿到所有积木,但得自己把它们拼起来。 是最显而易见的起点。它在 debug 构建中执行,在 release 构建中编译为空。这对简单的…

零个、一个还是十二个:生产函数到底需要多少个断言

开发者要么像撒彩纸一样到处抛撒断言,要么完全避之不及。这个决策框架能帮你区分有用的 invariants 和生产环境崩溃的触发器。

大多数 production codebases 都会分成两派。A 派把 当成装饰性调料,每隔一行就撒一点,直到函数读起来像个偏执律师写的法律合同。B 派把断言当作只在开发阶段使用的辅助轮,构建时全部剥离,然后祈祷代码能在生产环境跑起来,因为测试曾经通过过一次。…

你的验证层比业务逻辑还庞大

手动验证会让 codebase 膨胀不堪,却依然遗漏边界情况。下面介绍如何通过声明式 schemas 强制执行 runtime contracts,同时让它们不碍你的事。

每次 API 收到请求,你都要验证。每次函数收到来自外部系统的参数,你都要检查。如果手动完成这些工作,单个端点积累的验证代码就可能超过业务逻辑本身。 这是 runtime contracts 的隐性成本。你需要它们,因为 type system 会撒谎:通过 HTTP 传输的…

TypeScript strictNullChecks 是编译时守卫,而非运行时盾牌

严格模式能捕获你写出来的 null,却无法捕获来自 API、DOM 查询和 JSON.parse 在运行时抵达的 null。类型系统止步之处,正是你的防御开始之时。

你在 里启用了 ,修掉了每一个红色波浪线,满怀信心地把代码发布到生产环境,以为 和 已经是过去式。 随后后端响应变了结构,一次 DOM 查询返回了空值, 在 TypeScript 曾信誓旦旦保证安全的代码里抛出了 。到底发生了什么? TypeScript…