216个测试全部通过。功能却彻底不能用。这两件事同时成立,中间隔着一道缝。 我在做一个加密通讯工具。消息端到端加密,负责转发的服务器读不到任何内容,这部分是好的。我给它加的是离线投递:你给一个没打开应用的人发消息,服务器先把消息存着,等对方回来再交出去,然后删掉。不该留的东西一秒都不多留。 代码写完了,测试也写了。存储层有单元测试,对着真实的Postgres跑集成测试,走真实WebSocket连接的端到端测试。每一个都过。 然后我把它跑在部署好的构建上,关掉一个浏览器,发两条消息,再打开。 什么都没收到。 Bug 1:测试根本抓不到的竞态 连接一打开,服务器立刻把暂存的消息交出去。而客户端要从浏览器存储里加载解密密钥,这一步是异步的。 于是消息到了,却还没有东西能解密它,直接被丢掉。 测试里看不到这个问题,因为测试环境里密钥加载几乎是瞬时的。“已连接”和“可以解密”之间的那个窗口,只在真实机器做真实I/O时才存在。 修复方式是把顺序倒过来:先恢复状态,再建立连接,中间不留缝。教训是,如果你的测试环境瞬间完成、生产环境不是,那你测的根本不是同一个系统。只要代码在“已上线”和“已就绪”之间写了await,就可能有东西在这个间隙里到达。 Bug 2:确认了错误的事件 这个更糟,因为它会毁数据。 客户端一确认,服务器就删掉暂存的消息。我的客户端在消息“到达”时就确认了。 到达不等于投递。消息到了socket,但应用还没解密、没存储、没展示给任何人。服务器照样删了。这条消息在两端同时消失。 修复是只在消息真正被处理之后才确认,没处理完的就留在服务器上,下次再来。有一个刻意的例外:这条设备永远读不了的消息——它属于一个密钥已经丢失的会话——仍然确认,因为再要一次也没用。 教训是,“收到”和“处理完”是两个不同的事件,只有一个能安全地作为删除依据。做任何至少一次投递的系统,都要想清楚自己操作的是哪一个。 Bug 3:永远不会执行的清理路径 消息重新流通之后,我去看数据库,发现我发过的每一条消息都有一份副本,包括双方都在线时即时送达的那些。 逻辑本来是这样的:每条消息都存,收件人确认后删除。但确认只发生在从存储里投递的消息上。即时送达的消息直接到了对方那里,没有任何东西会去确认它,也就没有任何东西会删它。 一段活跃的对话,正在服务器上悄悄积累自己的副本,只有每周一次的清扫才会清掉。 修复是只在没人接收的时候才存。教训是,只在一条代码路径上执行的删除不叫删除。当你写下“我们稍后清理”时,要确认每一条路径都能走到清理,而不只是你脑子里想的那一条。 这件事的影响不止于存储。服务器握有一整段对话的副本,和只暂存几秒钟,是两种完全不同的隐私承诺。 Bug 4:活得比主人还久的状态 删除一个聊天会删掉消息,但把加密密钥留下了。重新加回同一个人,应用会兴高采烈地试图恢复一段对方早已丢弃的对话。两端不再对任何事达成一致,什么都解不开。 教训是,删一个东西的时候,要删掉所有由它派生出来的东西。孤儿状态不会安静地待着,它会在之后被那些默认它仍然有效的代码捡起来用。 我真正改掉的东西 不是“多写测试”。我测试已经够多了,它们全绿,而功能完全不能用。 这四个Bug的共同点是,每一个都活在组件之间的缝隙里:socket打开和密钥加载之间,服务器认为的“已投递”和客户端认为的之间,一条投递路径和另一条之间。每个组件单独看都行为正确,系统不是。 所以我现在遵循的规则很简单:任何碰到真实存储、真实网络或另一台真实机器的东西,在我说它做完之前,都要对着部署好的构建跑一遍。不是开发服务器,不是测试框架,是我真正发布出去的那个东西,配一台真实的第二设备,做用户会做的事。 那一次测试跑下来找到四个Bug,其中两个会丢用户数据。花了大约十分钟。 我是因为主动去找才找到它们的。这个功能从“写完、评审过、测过、部署了”的意义上说已经上线了。如果我信了那些绿色的对勾,它就会作为一个会悄悄吞掉消息的通讯工具到达测试者手里。 测试告诉你零件能用。它们远不擅长告诉你整个东西能用。
![]()
特别声明:以上内容(如有图片或视频亦包括在内)为自媒体平台“网易号”用户上传并发布,本平台仅提供信息存储服务。
Notice: The content above (including the pictures and videos if any) is uploaded and posted by a user of NetEase Hao, which is a social media platform and only provides information storage services.