普通视图

发现新文章,点击刷新页面。
昨天以前首页

前端测试:别为了100%覆盖率而写测试,那是自欺欺人

作者 kyriewen
2026年5月2日 18:46

你写了测试,覆盖率100%,感觉稳了。结果上线后,用户点了个按钮,页面直接白屏。你纳闷:覆盖率不是100%吗?因为你测的都是“天气好不好”,没测“会不会地震”。今天我们就来聊聊前端测试的正确姿势——怎么测才能真的有用,而不是为了指标好看写一堆废话。

前言

前端测试常走两个极端:要么完全不测,上线随缘;要么为了覆盖率,测了等于没测(比如测个1 + 1 = 2)。真正有效的测试,不是越多越好,而是该测的测,不该测的别浪费生命

今天我们用“测试金字塔”模型,帮你理清单元测试、组件测试、E2E测试的分工。看完你会知道:哪部分代码必须测,哪部分可以跳过,哪部分用哪个工具。

一、测试金字塔:三分天下,各司其职

       /\
      /E2E\      ← 少而精,关键路径
     /------\
    /集成测试\    ← 中等,组件间交互
   /----------\
  /  单元测试   \  ← 多而快,纯逻辑
 /--------------\
  • 底座(单元测试):测最小的代码单元(函数、工具类)。多、快、便宜。
  • 中层(组件测试/集成测试):测几个单元结合后的行为(比如一个表单组件提交数据)。
  • 顶层(E2E测试):模拟真实用户,测整个流程(从打开页面到点击到结果)。

比例大概是:单元测试占70%,集成测试20%,E2E 10%。不是死规定,但原则:底层测试成本低,多写;顶层测试维护成本高,只写关键路径

二、单元测试:测逻辑,不测实现细节

单元测试的目标:给定输入,输出是否正确。不关心函数内部怎么实现的,只关心结果。

适合测的

  • 纯函数(输入输出确定,无副作用)。
  • 业务规则(比如calculateDiscount(price, level))。
  • 工具函数(formatDateparseQuery)。

不适合测的(测了也白测):

  • 框架内部逻辑(React的setState、Vue的响应式——那是框架的事)。
  • 简单的getter/setter。
  • 常量定义。

工具:Jest + Vitest(Vite项目推荐Vitest)。

例子

// 要测的函数
function formatPrice(price, currency = '¥') {
  return `${currency}${price.toFixed(2)}`;
}

// 测试
test('格式化价格', () => {
  expect(formatPrice(10.5)).toBe('¥10.50');
  expect(formatPrice(10.5, '$')).toBe('$10.50');
});

黄金法则:如果重构代码不破坏测试,说明你测的是行为,不是实现。

三、组件测试:测交互,不测样式

组件测试(React Testing Library / Vue Test Utils)的目标:模拟用户行为,检查组件渲染和交互是否正确。不关心DOM结构细节,只关心用户能看到什么、能做什么。

适合测的

  • 根据props渲染正确的内容。
  • 点击按钮触发正确回调。
  • 表单输入后数据变化。
  • 异步加载显示loading状态。

不适合测的

  • CSS样式(那是视觉回归测试的事,交给视觉测试工具)。
  • 内部state的具体值(优先测渲染结果)。
  • 第三方UI库的行为(假设它没问题)。

工具:React Testing Library + Jest(官方推荐),Vue Test Utils + Vitest。

例子(React Testing Library):

import { render, screen, fireEvent } from '@testing-library/react';
import Counter from './Counter';

test('点击按钮增加计数', () => {
  render(<Counter />);
  const button = screen.getByText('增加');
  fireEvent.click(button);
  expect(screen.getByText('计数: 1')).toBeInTheDocument();
});

原则:测用户能看到的东西,不要测内部实现。

四、E2E测试:测关键用户旅程,不测所有交互

E2E测试模拟真实浏览器,跑完整的用户流程。它最像真实用户,但也最慢、最脆弱(网络波动、页面改动容易挂)。

适合测的(3-5个核心流程):

  • 登录 → 访问个人主页 → 修改头像。
  • 搜索商品 → 加入购物车 → 结算 → 支付成功。
  • 未登录访问受保护页面 → 跳转到登录页。

不适合测的

  • 每个细节(比如每个按钮的悬浮效果)。
  • 容易变化的面包屑导航。
  • 第三方依赖的页面。

工具:Cypress(最友好)、Playwright(更可靠)、Puppeteer(较底层)。

例子(Cypress):

describe('用户登录', () => {
  it('输入正确账号密码后跳转到首页', () => {
    cy.visit('/login');
    cy.get('[data-cy=username]').type('user@example.com');
    cy.get('[data-cy=password]').type('password123');
    cy.get('[data-cy=submit]').click();
    cy.url().should('include', '/dashboard');
    cy.contains('欢迎回来', { timeout: 10000 });
  });
});

维护技巧:给关键元素加上data-cy属性,避免改样式或文本时测试挂掉。

五、测试覆盖率的谎言

很多团队追求100%覆盖率,结果工程师花大量时间测无关紧要的代码(比如测Redux的action creator是个纯对象)。覆盖率工具(Istanbul)只能告诉你“哪些代码没执行过”,不能告诉你“没测到的重要逻辑”。有时100%覆盖率,却漏掉了一个关键的空值判断。

正确的覆盖率指标

  • 核心业务逻辑达到80%以上就行。
  • UI组件覆盖率参考即可,不必强求。
  • 关注未覆盖的重要代码,而不是数字。

六、组合策略:一个电商网站的例子

  • 单元测试:计算折扣、格式化价格、校验表单规则。Jest跑得快,每次提交都跑。
  • 组件测试:商品卡片(渲染正确信息)、购物车弹窗(增加/删除商品)、地址表单(提交按钮禁用直到填写完整)。
  • E2E测试:1. 用户搜索“手机” → 2. 添加第一个商品到购物车 → 3. 登录 → 4. 结算 → 5. 确认订单。就这一个核心流程,保证不崩。

日常开发:单元测试 + 组件测试在CI里跑(每次push)。E2E测试单独流水线,部署前跑一次(因为慢)。

七、测试不是银弹,别为了写而写

  • 重构旧代码,没测试?先别补。补一个挂一个,浪费时间。优先补新功能。
  • 一个bug反复出现,才需要补测试
  • UI改得频繁的区域,不写E2E,写组件测试更稳。

测试是手段,不是目的。目的是信心:当你改完代码,测试全绿,你能放心上线。

八、总结:测试就像买保险

  • 单元测试:车险,便宜,必须买。
  • 组件测试:医疗险,中等,按需买。
  • E2E测试:地震险,贵,只买最关键的。

别买一大堆没用的险,也别裸奔。

用 AI 降低 iOS 客户端 UI 自动化测试难度

作者 songgeb
2026年4月21日 16:16

为什么 iOS UI 自动化仍然难

在真实业务里,UI 自动化往往卡在几类问题上:

  • 门槛高:需要熟悉 XCTest、页面抽象、CI 集成,非客户端同学很难独立推进。
  • 维护贵:界面一改,选择器、坐标、等待逻辑跟着失效,修复成本像还技术债。
  • 反馈慢:过度依赖截图或视觉比对时,脚本和排障都变慢,协作也不顺畅。

近期探索方向是:用系统无障碍(Accessibility)能力看见界面,用 命令行工具 驱动模拟器;把写脚本交给 AI,把测什么、对不对交给人(其实交给AI应该也可以)。

这样可以把自动化从少数工程师专属(如测试开发岗位)拉回到测试与交付都能参与的节奏里。

核心思路:无障碍树 + AXe

iOS 为视障用户暴露的无障碍信息,会在系统侧形成一棵无障碍树:控件文案、(若开发配置)唯一标识、大致几何信息都可以被读取。类比前端:界面是渲染结果,无障碍树更接近可被程序消费的语义结构。

在命令行驱动模拟器这一层,目前AXe 在易用性、能力完整度和可脚本化程度上综合表现最好,因此方案明确为 无障碍树 + AXe

  • 用 AXe 读取无障碍树、点击、输入、手势、截图等,再配合 Shell 或步骤文件做编排。
  • 脚本层负责稳定可重复的执行;
  • 人 + AI 负责把业务语言翻译成脚本,并在失效时快速迭代。

AI 具体降低了哪些难度

除下文分条说明外,这里想单独强调一点:编写与排障时,describe-ui 拉起的无障碍树仍是最快、成本最低的定位与断言手段;但在结构复杂的原生页面,或 WebView / H5 等无障碍信息不完整、控件不可见的场景里,完全可以把 AXe 截屏交给 AI 分析——既可用于结果校验(布局是否异常、关键视觉元素是否出现),也可在 AI 协助下从画面反推点击坐标 / 热区,再固化为 touch、像素级辅助脚本等步骤。相比过去「只能死磕无障碍树或完全依赖人工看图写坐标」的传统做法,可选路径更多:树优先、截图与 AI 作补充;人工判断与模型辅助看图可以组合使用,而不必二选一。

从写脚本到描述流程:协作时序

传统模式下,测试同学往往要先补编程与框架知识;AI 辅助时,自然语言 + 页面结构文本即可闭环迭代:

sequenceDiagram
    participant QA as 测试/业务
    participant AI as AI 助手
    participant SIM as 模拟器 + CLI
    QA->>AI: 用自然语言描述端到端流程与验收点
    AI->>SIM: 按需读取无障碍树(describe-ui 或等价能力)
    SIM-->>AI: 返回页面结构文本
    AI-->>QA: 交付可执行脚本(.steps / Shell 等)
    QA->>SIM: 本地执行脚本
    SIM-->>QA: 某步失败或状态不符
    QA->>AI: 反馈失败步骤 + 当前页面结构文本
    AI->>SIM: 必要时再次拉取树或调整定位策略
    AI-->>QA: 修改后的脚本
    Note over QA,SIM: 人负责测什么、怎样算对;AI负责怎么点、怎么判、怎么改

降低的难度:不必从零掌握语法与定位细节,把翻译为可执行步骤外包给模型。

排障成本:默认走文本通道而非截图通道

同一类问题(例如点不到、断言失败),用文本无障碍树通常比反复传图更省、更稳:

flowchart LR
    subgraph fail["脚本失败 / 状态异常"]
        A["失败步骤 + 上下文"]
    end

    subgraph pathText["推荐:文本路径"]
        T1["拉取无障碍树输出"]
        T2["grep / 条件分支 / 贴给 AI 分析"]
        T3["改 label / id / 等待 / 分支逻辑"]
    end

    subgraph pathImg["必要时:视觉路径"]
        I1["截图"]
        I2["人工或 AI 看布局 / H5 等"]
        I3["改坐标或视觉辅助逻辑"]
    end

    A --> T1
    T1 --> T2
    T2 --> T3
    A -.->|"仅当树不够用"| I1
    I1 --> I2
    I2 --> I3

降低的难度:排障从猜界面加大量截图对话变成结构化文本 diff,更适合日常高频使用。

成本结构:AI 管「写脚本、修脚本」,不管「跑脚本」

把 token 与人力集中在编写与改版修复,执行阶段不依赖模型:

flowchart TB
    subgraph once["一次性 / 低频"]
        W1["新流程:描述需求"]
        W2["AI 生成首版脚本"]
        W3["人确认可重复跑通"]
    end

    subgraph daily["高频:回归执行"]
        R1["CI 或本地直接跑脚本"]
        R2["零模型调用"]
    end

    subgraph rare["偶发:UI 改版"]
        U1["脚本失效"]
        U2["贴新无障碍树 + 失败信息"]
        U3["AI 小步修补"]
    end

    W1 --> W2
    W2 --> W3
    W3 --> R1
    R1 --> R2
    R1 --> U1
    U1 --> U2
    U2 --> U3
    U3 --> R1

降低的难度:把自动化从持续烧对话/烧图变成可沉淀的脚本资产,更容易在团队里推广。

经验法则:默认仍以 describe-ui 无障碍树为主;遇到复杂原生页、Web 页树信息不足时,再用 AXe 截图 + AI 做结果校验或反推坐标,与「只靠树或只靠人眼」相比,路径更灵活。

工程落地:三种编写方式怎么选

按复杂度递进,避免一上来就做大而全框架:

  1. 交互模式:在终端逐条执行看树、点击、再验,适合探索页面与验证定位。
  2. 批量步骤文件(如 .steps):适合线性、无分支的流程,结构简单、可读性强。
  3. Shell 脚本:需要条件判断、重试、关闭弹窗、拼装环境变量时再用;可与公共函数库复用高频动作。选型建议:能线性顺序完成的用步骤文件;一旦出现如果出现某文案则、最多重试 N 次就上升到 Shell。不确定时,把业务口述给 AI,让它帮你选载体即可。

工程内案例:跨页面资源链路冒烟

该小节展示目前已经在工程中应用的案例。

辅助 QA 验证某类资源是否生效——从打开 App,进入资源相关页面并选用资源,再进入另一处资源应用页面触发使用,最终以 截图呈现结果,形成可重复结论(中途可配合 describe-ui 做关键状态断言)。

flowchart TD
    A[启动并进入 App] --> B[进入资源入口页]
    B --> C[选用目标资源]
    C --> D[进入资源应用页]
    D --> E[触发资源使用]
    E --> F[无障碍树断言关键状态]
    F --> G[截图固化结果]

落地要点:关键路径优先 accessibilityIdentifier 或稳定 label;WebView 区域用 touch 或坐标兜底;异步生效处加重试或等待;截图偏重最终留档与对非研发可读的佐证,日常仍以无障碍树文本断言为主。

不足之处

  • 仅支持模拟器(AXe) :当前 AXe 面向模拟器;若要在真机上跑同类 UI 自动化,通常需转向 XCUITest,或评估各厂商付费真机云 / 设备农场等方案,并在证书、并发、脚本形态与成本之间做权衡。
  • WebView / H5:内部细粒度控件往往不出现在无障碍树里,常见做法是坐标触摸或截图后做像素/区域启发式分析,这类脚本更依赖评审与设备基准。
  • 多语言包:按文案定位会在语言切换后失效;更稳的是推动客户端为关键控件补齐 accessibilityIdentifier
  • 坐标定位:不同机型逻辑分辨率不同,应作为最后手段,或结合比例计算。
  • 音视频与强动画:更适合接口层、状态层或人工探索性测试,不宜对 UI 脚本抱有过高期望。

小结

  • 无障碍树 + AXe把看见界面变成可脚本化、可 diff 的文本问题。
  • AI 把脚本编写与失效修复从专业技能降维成自然语言协作。
  • 文本优先、控制模型介入频率把成本压到可持续的水平。若你也在做 iOS 交付质量与回归效率,可先让模拟器上的端到端跑通,再逐步资产化用例,而不是先搭一座无人维护的测试金字塔。

SBTI说我废物,这是今年听过最舒服的一句话

作者 李超凡
2026年4月10日 10:43

昨晚朋友圈被一群「废物」「吗喽」「死者」刷屏了。

这是一个叫 SBTI 的测试,全称 Silly Big Test Inventory,可以直接理解为「SB 人格测试」,一晚服务器干崩了几次。

打开社交媒体全是「DEAD 死者」「MALO 吗喽」「IMFW 废物」的测试海报,配文清一色的自嘲,评论区清一色的「太准了」。

我自己测出来是「IMFW 废物」。

说实话,看到结果的时候愣了一下。不是因为被骂了,是因为人格描述写着:「给废物一颗糖,它会还你一个完全信任你、亮晶晶的眼神。」我觉得它比任何 AI 写的性格分析都有意思。

这是 B 站 UP 主 @ 蛆肉儿串儿 原文为了劝朋友戒酒随手做的网页。31 道题,5 分钟,没有「稀有的灵魂建筑师」,没有「天生的共情者」,只有一盆当头泼下来的冷水。

这东西本身就是 AI 写的代码,只是,令人意外的是,AI 花了三年学会夸人,一个 UP 主用 AI 手搓了个骂人的网页,反而得到更大的共鸣。

无数人涌进 SBTI ,就是为了被骂。

和菜头之前写过一篇《AI 是个马屁精》,说得很对,但只说了一半。

他举了一个例子,你拿一张图问 AI「请分析一下倭风」,AI 头头是道证明给你看。换个问法「请分析泰国风」,照样论证得滴水不漏。答案不是 AI 给的,是你预设的。AI 只是帮你把偏见打印出来了。

你问 AI「同事是不是在针对我」,它给你论证对方品行有问题。你问「我该不该辞职」,它永远从你的角度帮你建模。和菜头说得好,这些不是提问,是用问题的形式包装起来的观点。

但他没说的是:当所有 AI 都在做马屁精的时候,SBTI 为什么能靠骂人骂出泼天的流量?

可以先对比一下 MBTI。MBTI 告诉你「你是 INFJ,全球仅占 2% 的稀有人格」。

你激动地截图发朋友圈,配文「原来我一直是这样的人」。但这不过是巴纳姆效应的高级包装,描述模糊到谁都觉得说的是自己,好听到谁都愿意转发。它和 AI 马屁精干的是同一件事,给你一面美颜过的镜子。

SBTI 不给你美颜,它直接告诉你「你是吗喽」——「当人类祖先决定从树上下来、学会直立行走、穿上西装打领带时,吗喽人格的祖先在旁边的大树上看着他们,挠了挠屁股,发出一声不屑的『吱』。」

运气不好的话判你「死者」,「删档重开了 999 次,终于发现,这游戏压根就没意思。」

骂完你,还给你台阶下,MBTI 不会做这个。因为 MBTI 从来不骂你,它没有台阶可给。

MBTI 是美颜相机,让你觉得自己好看。SBTI 就像前置摄像头。,让你看到自己,然后笑出声来。

但 SBTI 真正有意思的地方还不只是敢骂人,它压根就不问你怎么了。

你想想,AI 需要你先开口。你得先把焦虑和脆弱摊在桌面上,然后它再用 800 字论证你是对的。你喂它情绪,它还你一篇你爱听的小作文。

31 道题,没有一道问你「最近压力大吗」「你觉得同事针对你吗」。它问你怎么看待喝酒,问你周末干嘛,问你对规则的态度。然后根据这些屁事,给你判了个「死者」。

你转发到朋友圈那张「DEAD,我,还活着吗?」的海报,你以为你在玩梗。但你的朋友看到后心里咯噔一下,因为他知道你最近确实不太好。

你不需要编辑一条 500 字的深夜感悟来求助。不需要向 AI 提一个「我是不是太累了」的诱导性问题,然后收获一篇温柔的废话。

一个荒诞的标签,替你说了你说不出口的话。

和菜头说「真正的问题需要事实准确、逻辑清晰、开放性强」。说得对。但有些时候,人们不需要正确的问题,需要的是一个不问问题的出口。

AI 的毛病不是太聪明,是太懂事了。

一个永远说「你说得对」的朋友,和一个永远说「你说得对」的 AI,区别只在于后者不会在你喝醉时把你拦下来。

而 SBTI 会,它直接判你为「DRUNK 酒鬼」。

这个人格就是创作者为了劝朋友戒酒而设计的。整个测试的起源,就是一句朋友之间直愣愣的「你喝太多了」。

创作者说自己「SHIT」。人格描述写着:「嘴上说这个世界就是一坨 shit,手上第二天早上七点准时起床,挤上 shit 一样的地铁,去干那份 shit 一样的工作。」

我觉得这像我们每个人。每天问 AI「同事是不是在针对我」「我该不该辞职」,第二天还是七点起床去上班了。

问题从来不是需要一个答案。是需要被允许抱怨一下。

AI 帮你把抱怨升级成一篇逻辑严密的檄文。SBTI 只说:行,你是「SHIT」,嘴上骂骂咧咧,手上该干嘛干嘛。前者让你以为自己在思考,后者让你知道自己在发疯。

知道自己在发疯,比「被 AI 论证我是对的」舒服多了。

SBTI 的测试页面有个细节。进度条旁边写着「0/31」,下面一行小字:「世界已经够乱了,起码把题做完整。」

我们连做完 31 道题的耐心都快没有了。问 AI 之前答案已经想好,刷短视频三秒不爽就划走,朋友圈里的长文从来只看第一行。

同样是用 AI,一个人搓出了 SBTI,挤崩三次服务器。另一个人让 AI 帮自己论证「同事就是在针对我」,然后心满意足地睡了。

工具一模一样。

区分两者的不是谁更会用 AI,是谁还愿意听一句不顺耳的话。

昨晚刷屏的那个「废物」标签,说到底只是一个随机算法贴的纸条、但这么多人抢着被贴,还是因为 AI 在对你微笑,MBTI 在对你微笑,短视频推荐算法也在对你微笑。所有人都在夸你,夸到你自己都不信了。

偶尔被人直愣愣地说一句「你是废物」,反而觉得踏实。

就像上学时最好的那个朋友,从来不说「你好棒哦」。他只会说「你傻啊」,然后把笔记借给你抄。

#欢迎关注爱范儿官方微信公众号:爱范儿(微信号:ifanr),更多精彩内容第一时间为您奉上。

大型 iOS 工程单元测试 — 变更驱动测试与跨模块 Mock

作者 visual_zhang
2026年3月14日 22:44

一、定位

本文是 为大型 iOS 工程补充单元测试方法论补充篇。前文提供了"画链路 → 选节点 → 写测试 → 融入迭代"的完整框架,覆盖了基础的 Mock 策略和用例设计原则。

本文聚焦以下问题:

  • 当测试目标不是"从零覆盖"而是"验证一次代码变更"时,应如何设计用例?
  • 当被测方法依赖的实验开关位于另一个模块、通过 Service Locator 解析时,如何 Mock?
  • 当同一份数据有两个来源、且实验开关决定是否去重时,如何构造测试场景?
  • 当方法的聚合语义不是"求和"而是"计数"时,如何防止未来开发者误改?

二、变更驱动测试设计(Change-Driven Test Design)

2.1 原则

传统的"从零覆盖"思路是:遍历方法的所有分支,为每个分支写用例。而在实际业务迭代中,更常见的场景是你刚修改了一段逻辑,需要快速验证变更的正确性

变更驱动测试的核心思路:

识别本次变更引入的新分支或行为差异
为新分支写正向用例(验证新行为)
为旧分支写回归用例(验证未被破坏)
如果变更引入了新的数据源排除/包含逻辑,为排除和包含各写至少一条用例

2.2 双态特性开关测试

当一次变更由特性开关(Feature Flag)控制时,同一个方法在 flag=true 和 flag=false 下有不同行为。此时必须成对测试

用例类型 目的 示例
flag=true 正向 验证新行为生效 shouldCountUnreadByCell=true 时 notice groups 被跳过,unreadCount 仅含 interactor 贡献
flag=false 回归 验证旧行为未被破坏 shouldCountUnreadByCell=false 时 notice groups 仍参与累加
flag=true 复合 新行为 + 多种过滤条件叠加 flag=true + muted groups + shop groups + redPoint groups → 全部被跳过,仅剩 interactor

关键原则:回归用例的重要性不低于正向用例。开发者常犯的错误是只测了新路径而遗漏了旧路径的回归验证,有可能改坏了旧路径的功能而未及时发现。

2.3 案例

BizScenarioItemInboxTabNumber.checkHasNoticeTabbarUnreadCount: 在引入 shouldCountUnreadByCell 分支后,新增了 4 条用例:flag=true 跳过 notice groups、flag=true 时 cellCount 不受影响、flag=false 包含 notice groups(回归)、flag=true 复合场景。每条用例的 assertion message 都明确标注了 flag 状态和预期计算过程。


三、Service Center Protocol Mock(跨模块协议依赖的 Mock 策略)

3.1 问题场景

在 Service Locator 架构中,模块间的依赖通过协议(Protocol)解耦。被测代码通过 GET_CLASS(IMModuleService) 获取另一个模块提供的 Class,再调用其类方法。这带来了一个 Mock 难题:

  • IMModuleService协议,不是类,无法直接 swizzle
  • 协议的实现类位于另一个模块,测试工程可能没有链接该模块
  • 在测试环境中,Service Center 默认为空,GET_CLASS 返回 nil

3.2 解法:注册 Mock Class 到测试 Service Center

SwiftTestCaseserviceBehavior = .newCenter 会为每个测试创建隔离的 Service Center。利用基类提供的 mockGetStatelessProtocolService(_:andReturn:) 方法,将一个轻量 Mock 类注册到该 Center 中:

// 定义只实现所需类方法的 Mock 类
private class MockIMModuleServiceTrue: NSObject {
    @objc class func shouldCountUnreadByCell() -> Bool { return true }
}

// 在测试中注册
if let proto = NSProtocolFromString("IMModuleService") {
    mockGetStatelessProtocolService(proto, andReturn: MockIMModuleServiceTrue.self)
}

工作原理

被测代码: [GET_CLASS(IMModuleService) shouldCountUnreadByCell]
         ↓
GET_CLASS 查询 Service Center → 返回 MockIMModuleServiceTrue.class
         ↓
[MockIMModuleServiceTrue shouldCountUnreadByCell] → YES

3.3 与 Runtime Swizzle 的对比

维度 Runtime Swizzle Service Center Mock
适用场景 目标类已知且已链接 目标是协议,实现类不可见或位于其他模块
隔离性 全局替换,需手动恢复 仅在测试的隔离 Service Center 内生效,自动还原
tearDown 负担 必须手动调用 restore() 无需手动清理
限制 需要已知类名和方法签名 仅适用于通过 GET_CLASS / Service Locator 解析的依赖

3.4 适用准则

当被测方法通过以下宏/方式获取依赖时,优先使用 Service Center Mock:

  • GET_CLASS(Protocol) / GET_PROTOCOL(Protocol)
  • ServiceCenter.defaultCenter.getStatelessProtocolService()
  • 任何通过 Service Locator 模式解析的跨模块协议依赖

四、Fake Environment 模式(Context Protocol Mock)

4.1 问题场景

某些组件通过一个宽接口的 "Context" 协议获取运行时环境(数据字典、配置管理器、事件分发器等)。直接构造真实 Context 需要初始化整个管理器链路,测试成本极高。

4.2 解法:实现仅含测试所需数据的 Fake Context

private class MockUnreadCountContext: NSObject, UnreadCountContext {
    var mockEntranceCountModelDict: [String: InboxEntranceUnreadCountModel] = [:]
    var checkNeedUpdateCalled = false
    var lastCheckScene: BizScenarioItemCheckScene = []

    func entranceCountModelDict() -> [String: InboxEntranceUnreadCountModel]? {
        return mockEntranceCountModelDict
    }
    func checkNeedUpdate(_ scene: BizScenarioItemCheckScene) {
        checkNeedUpdateCalled = true
        lastCheckScene = scene
    }
    // 其余方法空实现
}

4.3 设计要点

要点 说明
只实现被测路径依赖的方法 非必需方法留空实现,降低维护成本
var 暴露可控数据 测试通过直接修改 mockEntranceCountModelDict 来控制输入
Spy 能力 添加 checkNeedUpdateCalled / lastCheckScene 等标记,验证被测方法是否正确触发了 Context 上的副作用
弱引用安全 Context 属性通常为 weak,确保 Mock 对象在测试期间被持有(存为实例属性)

4.4 与协议 Mock 对象的区别

前文的"协议 Mock 对象"聚焦于数据提供者(如 NoticeUnreadCountItemProtocol),每个 Mock 只需返回数值。Fake Environment 聚焦于运行时环境,需要同时提供数据字典、触发副作用(如 checkNeedUpdate:)、并可能被多个被测方法共享。


五、聚合语义测试(Aggregation Semantics Testing)

5.1 问题

聚合方法有两种常见语义,外部签名几乎相同,但行为差异大:

语义 含义 示例方法
Sum 将所有符合条件的项的值相加 countForNumber: → 返回 5 + 3 = 8
Count 统计符合条件且值 > 0 的项的个数 countForUnreadCell: → 返回 2(有 2 项非零)

如果未来开发者误将 Count 语义改为 Sum 语义(或反过来),逻辑上仍然"能跑通",但业务含义错误。

5.2 方法:用数据设计锁定语义

构造让 Sum 和 Count 结果必然不同的测试数据,使得任何语义变更都会导致断言失败:

// count 为 5 和 3 → Sum = 8, Count = 2
// 如果断言 Count == 2,则改为 Sum 后结果变为 8,测试失败
func test_countForUnreadCell_countsEntrancesNotSumsCount() {
    mockContext.mockEntranceCountModelDict = [
        combinedKey(1): makeEntranceModel(entranceID: 1, count: 5),
        combinedKey(2): makeEntranceModel(entranceID: 2, count: 3),
    ]
    XCTAssertEqual(item.count(forUnreadCell: nil), 2,
                   "Cell count = number of entrances with unread, not sum of counts")
}

关键:选择每项 count > 1 的数据。如果所有 count 都为 1,则 Sum 和 Count 结果相同,无法区分语义。

5.3 推广

此方法适用于所有存在语义歧义的聚合操作:

  • Max vs Sum:确保数据中有多项,且各项值不同
  • Any vs All:确保数据中有 true 和 false 的混合
  • Distinct count vs Total count:确保数据中有重复项

六、数据源去重测试(Deduplication Testing)

6.1 问题

当同一份业务数据通过两个独立渠道到达聚合点时(如 notice_countentrance_count 都包含通知未读数),需要在特定条件下去重,否则会出现重复计算。

6.2 测试策略

构造"两个渠道都有数据"的场景,验证在去重开关开启时只有一路生效:

dataSource (notice groups) = { group100: 10, group200: 3 }
interactor (entrance items) = countForNumber: 5

shouldCountUnreadByCell=true  → result = 5  (只用 interactor,跳过 dataSource)
shouldCountUnreadByCell=false → result = 18 (dataSource 13 + interactor 5)

设计要点

  • 两路数据都给非零值,使得去重与不去重的结果有明显差异
  • 对去重路径和非去重路径各写至少一条用例
  • Assertion message 中明确标注"哪一路被跳过"以及"预期计算过程"

七、变更传播测试(Mutation-Aggregation Vertical Slice)

7.1 问题

底层数据的变更(标记已读、静音)需要正确传播到上层的聚合结果中。仅测试"model.count 被置 0"是不够的,因为聚合层可能因为缓存、过滤条件等原因未感知到变更。

7.2 方法:跨层断言

在一个测试用例中同时操作底层和观察上层,形成"垂直切片":

func test_updateMuteStatus_muteExcludesFromCount() {
    // 底层:构造 model
    let model = makeEntranceModel(entranceID: 1, count: 5)
    setUpEntranceModels([combinedKey(1): model])

    // 上层:构造 aggregation item,共享同一个 model
    let countItem = InboxEntranceUnreadCountItem()
    let ctx = MockUnreadCountContext()
    ctx.mockEntranceCountModelDict = [combinedKey(1): model]
    countItem.context = ctx

    XCTAssertEqual(countItem.count(forNumber: nil), 5, "Before mute")

    // 执行变更
    service.updateMuteStatus(true, forEntranceID: 1, subEntranceKey: nil)

    // 验证传播:上层聚合结果反映了底层变更
    XCTAssertEqual(countItem.count(forNumber: nil), 0, "After mute, excluded from count")
}

7.3 适用场景

  • 标记已读 → 未读数归零
  • 静音 → 从聚合计算中排除
  • 归档 → 从聚合计算中排除
  • 任何"底层状态变更应影响上层输出"的链路

7.4 与纯单元测试的关系

垂直切片测试严格来说介于单元测试和集成测试之间。在大型工程中,它的性价比很高:不需要启动完整的 Service 链路,但能验证两层之间的契约是否正确。推荐在以下情况使用:

  • 两层通过共享可变对象(同一个 model 实例)交互
  • 上层的聚合逻辑包含过滤条件(muted、archived 等),变更后的状态可能被过滤

八、短路行为测试(Short-Circuit Testing)

8.1 问题

某些遍历方法在找到第一个匹配项后会 stop*stop = YES),不再继续遍历。如果去掉 stop,方法签名和大部分行为不变,但在存在多个同类型 item 时会错误地累加。

8.2 方法:构造多个同类型 item,验证只取第一个

func test_countForNumberWithType_stopsAfterFirstMatch() {
    addMockItem(showType: .number, countForNumber: 10, itemType: .entranceCountItem)
    addMockItem(showType: .number, countForNumber: 5, itemType: .entranceCountItem)
    XCTAssertEqual(
        interactor.count(forNumber: nil, withUnreadCountItemType: .entranceCountItem), 10,
        "Should stop after first matching item"
    )
}

如果 stop 被移除,结果会变为 15,测试失败。

8.3 适用场景

  • 按类型过滤的方法(预期每种类型只有一个活跃实例)
  • 优先级查找方法(返回第一个满足条件的结果)
  • 任何使用 enumerateObjectsUsingBlock: + *stop = YES 的 ObjC 代码

九、总结:何时使用哪种模式

场景 推荐模式 本文章节
验证一次代码变更 变更驱动测试 + 双态 Flag 测试
被测方法依赖跨模块协议(通过 Service Locator) Service Center Protocol Mock
被测对象通过宽接口 Context 获取环境 Fake Environment 模式
聚合方法的 Sum/Count 语义容易被误改 聚合语义测试
同一数据有两个来源、需条件性去重 数据源去重测试
底层变更需传播到上层聚合结果 Mutation-Aggregation 垂直切片
遍历方法有 stop/短路行为 短路行为测试

这些模式与前文的基础方法论互补使用。基础方法论解决"测什么"和"怎么 Mock"的问题,本文解决"改了代码后怎么精准验证"和"复杂依赖场景下怎么构造可控环境"的问题。

单元测试系列:如何测试不愿暴露的私有状态

作者 visual_zhang
2026年3月10日 22:59

引言

在多人协作的大型工程中,编写单元测试时有一个绑定出现的矛盾:生产代码追求封装,尽可能把实现细节藏在 private 后面;测试代码需要观测和控制内部状态,才能验证逻辑是否正确。

当你依赖的某个属性被另一个团队从 internal 改成了 private,你的测试就从"编译通过"变成了"编译失败"。这在日常开发中反复上演。

本文聚焦于一个具体问题:当被测对象的关键状态被封装为私有时,测试该怎么写? 我们按照推荐优先级介绍三种模式,并讨论如何让这类测试在多人协作中保持可维护。


一、问题的本质

以一个典型场景为例:一个管理器(Manager)内部持有若干数据源(DataSource),它的公有方法 totalCount() 聚合所有数据源的计数。你想测试的是聚合逻辑——非子集的数据源应该被加总,子集的应该被排除。

要测试这个逻辑,你需要让不同数据源返回不同的计数值。但数据源存储在管理器的 private 属性中,计数值也是数据源的 private 属性。你"两头都摸不到"。

直觉的反应是"把它改成 internal 就好了"——但在多人协作的项目中,放松封装来迁就测试往往会带来更大的问题。被放松的接口会被其他模块误用,而且你也不一定有权修改别人的代码。

所以我们需要在不修改生产代码的前提下解决这个问题。


二、三种模式

模式一:通过公有行为间接验证

原则:不直接访问私有状态,而是通过被测对象的公有方法观察其行为。

// 不要直接读取私有变量
XCTAssertEqual(object.privateCount, 10)

// 通过公有接口验证行为
object.performAction()
XCTAssertEqual(object.publicResult(), expectedValue)

适用场景:被测对象有足够的公有 API 来覆盖验证需求。

局限:当你需要设置特定的内部状态来测试某个分支时(例如"当未读数为 10 时,聚合应返回 10"),仅靠公有 API 可能无法将对象置于期望状态。此时需要下一个模式。

模式二:子类覆写

当需要控制被测对象内部依赖的返回值时,可以在测试文件中定义一个子类,覆写返回内部状态的方法:

private class StubDataSource: RealDataSource {
    private var mockValues: [QueryType: Int] = [:]

    func setValue(_ value: Int, for type: QueryType) {
        mockValues[type] = value
    }

    override func getValue(for type: QueryType) -> Int {
        return mockValues[type] ?? 0
    }
}

被测对象调用 getValue(for:) 时,实际执行的是 Stub 的逻辑,返回我们预设的值。不需要修改任何生产代码。

适用条件:被覆写的方法是非 final 的。

注意:Stub 子类应保持轻量,只覆写必要的方法。如果父类初始化有副作用(注册通知、启动定时器等),需要留意。

模式三:运行时注入

模式二解决了"让依赖返回可控值"的问题,但还有一个问题:如何把 Stub 塞进被测对象?

理想情况下,被测对象应通过构造器或属性注入依赖。但在大型存量项目中,很多类的依赖是内部创建并存储在 private 属性中的,没有公开的注入点。

此时,对于 NSObject 子类,可以借助 Objective-C 运行时直接操作 ivar:

private func setIvar<T>(_ name: String, on object: AnyObject, value: inout T) {
    let cls: AnyClass = type(of: object)
    guard let ivar = class_getInstanceVariable(cls, name) else {
        XCTFail("Cannot find ivar '(name)' — property may have been renamed.")
        return
    }

    let actualSize = computeIvarSize(ivar, in: cls)
    guard actualSize == MemoryLayout<T>.size else {
        XCTFail("Ivar '(name)' size mismatch — type may have changed.")
        return
    }

    let offset = ivar_getOffset(ivar)
    let ptr = Unmanaged.passUnretained(object).toOpaque().advanced(by: offset)
    ptr.assumingMemoryBound(to: T.self).pointee = value
}

这段代码包含两层防护,对应两类变更场景:

生产代码变更 防护机制 测试行为
属性被改名 class_getInstanceVariable 返回 nil XCTFail + 安全返回
属性名不变,类型变了 MemoryLayout<T>.size 与 ivar 实际大小不匹配 XCTFail + 安全返回

这确保了不论生产代码如何变化,测试都报错(failure)而非崩溃(crash)

限制:仅适用于 NSObject 子类。纯 Swift 类没有 ObjC 运行时元数据,此方法不可用。

选择优先级

优先级 模式 条件 风险
1 通过公有行为验证 公有 API 足以覆盖
2 子类覆写 方法非 final
3 运行时注入 NSObject 子类,无注入点 中(需防护)

模式三是"最后手段",不是常规工具。如果你发现自己频繁使用它,更值得推动的是让生产代码提供正式的依赖注入接口。


三、让这类测试在协作中存活

解决了技术问题之后,还有一个同样重要的问题:在多人协作的环境中,这些测试能否被团队中的其他人理解和维护?

3.1 封装脆弱操作,暴露清晰意图

运行时注入是"脆弱"的——它依赖属性名字符串、内存布局等编译器无法检查的假设。关键原则是:把所有脆弱操作封装在一个辅助方法中,让每个测试方法只表达业务意图。

// 辅助方法封装了所有运行时细节
private func injectStubDataSources(_ entries: [(id: String, isSubset: Bool, ds: StubDataSource)]) {
    // ... runtime injection logic ...
}

// 测试方法只表达意图
func test_totalCount_excludesSubset() {
    let primary = makeStub(count: 10)
    let subset = makeStub(count: 5)
    injectStubDataSources([
        (id: "primary", isSubset: false, ds: primary),
        (id: "filter",  isSubset: true,  ds: subset)
    ])

    XCTAssertEqual(manager.totalCount(), 10)
}

这样做的好处:

  • 单点维护:属性改名或类型变更时,只需修改 injectStubDataSources 一处。

  • 可读性:团队成员读到测试方法时,看到的是"注入一个非子集数据源(10)和一个子集数据源(5),期望聚合结果为 10",不需要理解运行时细节。

3.2 用命名传递信息

在多人协作中,测试方法名是最重要的"文档"。一个好的命名应该在不打开方法体的情况下就能传达:测什么、在什么条件下、期望什么结果。

test_[被测方法]_[场景]

test_totalCount_excludesSubsetDataSources
test_totalCount_multipleDataSources_sumsNonSubset
test_totalCount_allSubset_returnsZero

当这些测试出现在 CI 的失败报告中时,任何人——即使从未接触过这个模块——都能从方法名推断出问题所在。

3.3 报错,不要崩溃

这是使用 unsafe 技术时最重要的设计原则。

  • 失败(Failure) :CI 报告中标注 test_totalCount_excludesSubset FAILED: Cannot find ivar 'dataContextMap'。开发者 5 秒内定位问题。

  • 崩溃(Crash) :CI 报告中只有 EXC_BAD_ACCESS (code=1, address=0x...)。开发者需要调试半小时。

每一步 unsafe 操作前都必须有 guard ... else { XCTFail(...); return } 的防护链。没有例外。


小结

处理私有成员的测试难题,本质上是在封装性可测试性之间找到平衡。在不修改生产代码的前提下,三种模式提供了递进的解决方案:

  1. 优先通过公有行为验证——最安全,零风险。

  2. 子类覆写控制返回值——利用多态,风险低。

  3. 运行时注入作为兜底——突破封装,但必须有防护。

技术方案之外,同样重要的是协作层面的设计:将脆弱操作封装在一处、用命名传递意图、确保测试报错而非崩溃。这些原则让测试不仅"能用",而且"能活"——在团队成员轮换、生产代码持续演进的环境中,持续发挥保护作用。

搜索 C++ 引擎回归能力建设:从自测到工程化准出|得物技术

作者 得物技术
2026年3月10日 09:58

一、为什么要做这件事

在搜索系统中, C++ 引擎长期扮演着底层核心基础设施的角色:性能敏感、逻辑复杂、变更频繁,同时承载着大规模线上流量的稳定运行。随着业务持续发展和技术架构不断演进,我们逐步意识到:在高频迭代背景下,回归能力也需要同步升级。

过去一年,我们围绕搜索 C++ 引擎展开了一次系统性的回归能力工程化建设。本文将介绍这次能力升级的背景思考、核心设计思路以及落地实践。

高频迭代背景下:回归能力需要同步升级

搜索 C++ 引擎的升级主要来自三类需求:业务功能需求、重要技术项目(有 QA 深度参与)、大量技术优化与结构性改造需求。

在实际迭代节奏中,技术优化与结构性改造类需求占比较高,引擎整体呈现出多人并行开发、持续迭代推进的状态。随着规模扩大,我们发现:现有回归环境更适用于单次项目式验证。多需求并行时,资源调度与复用能力仍有提升空间,回归准出标准尚未完全工程化。这意味着,在稳定性要求不断提升的背景下,我们有必要构建更加标准化、流程化的回归体系,让质量保障能力与迭代节奏匹配。

现有测试方式的演进空间

当前搜索引擎主要依赖两类测试手段:DIFF 测试和压测,这些手段在长期实践中发挥了重要作用,但随着业务复杂度提升,我们也逐步看到进一步优化的空间:流量获取依赖下载日志、手工上传,自动化程度仍可提升。DIFF 过程中存在自然噪音。需要更精细化处理(AA DIFF、排序不稳定)。报告与分析信息分散在不同工具中,定位效率有优化空间。多套工具并行使用,缺乏统一平台化沉淀。整体来看,测试能力更多体现为“工具能力集合”,而在流程标准化、资产沉淀与统一治理方面仍有提升空间。

二、我们要解决什么问题

这次建设的目标,并不是简单“再做一个工具”,而是希望系统性解决以下问题:让 DIFF 和压测成为搜索 C++ 引擎的标配回归能力、让回归结果具备可分析、可归因能力、让回归成为发布的硬性准出标准、保证工具本身的稳定性,不成为新风险、整体提升引擎的回归效率和交付质量、通过流程和流水线,降低对“人”的依赖。一句话总结:把回归这件事,从“靠自觉”,变成“靠系统”。

三、整体方案概览

围绕上述目标,我们将建设拆分为五个关键方向:流量录制:一次录制,多处复用。环境建设:稳定、可复用的 DIFF/ 压测环境。DIFF 工具体系:从“能跑”到“好分析”。一键压测能力:降低执行门槛。工具与索引平台集成:让回归真正被用起来。

下面将会按模块展开说明。

流量录制:回归的基础设施

为什么先做流量录制

DIFF 和压测的核心前提只有一个:真实、稳定、可复用的流量。因此我们优先建设了搜索 C++ 引擎的流量录制链路,作为后续所有测试能力的基础。

流量如何触发

  • 索引平台集群详情页直接发起流量录制。
  • 索引平台更新 ARK 配置中心中的录制配置。
  • 搜索 C++ 引擎实时监听配置变化。

录制配置设计

所有配置统一收敛在 dsearch3#test.properties,支持:

  • 全局开关。
  • 指定 app / group。
  • 截止时间。
  • 指定 IP。
  • 采样率(0~100)。

这使得录制行为可控、可回收、可精细化管理。

流量生成与存储

  • 引擎侧根据配置生成 Kafka 消息。
  • 多业务场景复用同一 ARK 集。
  • 多场景流量复用同一个 Kafka Topic。

最终流量落入 ODPS,按天分区,字段包含:

  • 请求体。
  • 流量场景。
  • 实验信息。
  • 环境信息(生产 / 预发)。

这为后续 DIFF、压测、问题复现提供了统一数据源。

流量存储字段说明:

request_type:流量标签(原C++引擎请求类型)
app_name:C++引擎appName
group_name:C++引擎groupName
request_body:录制的C++引擎请求体
env:录制的流量环境:预发/生产
graph_name:图名称
experiments:实验列表(搜索新增)
pt:ODPS分区,按天分

DIFF 测试:从无到“可归因”

DIFF 执行流程:

DIFF 的入口统一在索引平台:查询流量 →选择流量→配置参数→触发 DIFF→查看报告。底层由测试服务 + 脚本完成:流量筛选与改造、请求转发、去噪、报告生成与存储。

DIFF 对比方式:

对照组部署 master 分支,实验组部署预发布分支。指定行或者指定集群方式请求对照组和实验组环境。打开新功能开关进行响应比对,生成预期有DIFF报告。

DIFF 环境设计

支持两种模式:

  • 指定集群:对照组 / 实验组两套完整集群。
  • 指定行 精确绑定 search / rank IP。

通过该设计,保证对比的唯一变量只有代码和配置。

流量筛选与回放改造

支持多维度筛选:

  • 搜索场景(交易 / 社区 / 聚合等)。
  • 流量标签(综合 / 销量 / 新品等)。
  • 实验命中情况。

同时解决了生产流量无法直接在预发回放的问题(表名、图参数、模型等适配)。

DIFF 策略设计

我们不只关注“有没有 DIFF ”,而是关注这个 DIFF 是否符合预期,因此 DIFF 被拆为两类:

响应 DIFF
  • 响应字段对比。
  • 漏斗算子字段对比。
指标 DIFF
  • 相似度分布(忽略/不忽略排序)。
  • 漏斗算子一致率。
  • 字段增删改统计。
  • 定制化指标。

DIFF 去噪

DIFF 不可用,往往不是因为“真问题”,而是噪音太多。我们重点处理了:AA DIFF(排序不稳定、非确定性逻辑)、可忽略字段、数值微小波动、内部超时导致的异常结果,目标只有一个:让开发看到的DIFF,尽可能都是真问题。

DIFF 报告设计

报告展示

DIFF 汇总报告:

  • 应用、集群、请求接口、流量标签、路由信息、对比数量、DIFF 数量、完全一致率、query_tag 平均召回数、score 平均分等。
  • 相似度分布统计报告(不忽略排序/忽略排序)。
  • 漏斗算子一致率统计报告。
  • 字段增删改统计。

DIFF 详情报告:

  • traceId、一致率、增删改字段、请求体等。
  • 漏斗算子 DIFF 明细。
  • 响应 DIFF 明细。
报告通知

通知到群 @个人,添加报告链接。

压测:一键完成性能回归

压测执行流程:

  • 索引平台作为压力测试发起入口,查询流量->选择流量->填写压测参数->压测触发->压测记录查看。
  • 测试服务提供索引平台操作的接口能力,查询流量->流量筛选->压测文件生成->压测任务触发->压测状态更新。
  • 压测平台提供实际压测能力,启动压测任务->生成压测报告。

整个过程无需人工干预。

执行方式:

  • 对照组:master 分支。
  • 实验组:预发布分支。
  • 开启新功能开关。
  • 阶梯式加压,对比性能曲线。

压测环境设计

同 DIFF 环境建设。

压测报告设计

报告展示

压测平台报告。

报告通知

通知到群 @个人,添加报告链接。

发布流水线与准出机制

回归能力建设的最终目标,是进入发布流程。当前已完成:UT / MR 流水线初步建设,后续规划中将:把 DIFF 和压测作为发布硬性卡点、回归不通过,禁止上线、回归过程自动扩缩容,避免长期占用资源、自动生成准出报告。

四、后续规划

回归执行率 100%:解决“忘跑回归”。

准出流水线全自动化。

横向覆盖更多搜索场景(流控、商业化、国际搜索等)。

形成统一的上线 SOP 规范。

五、总结

搜索 C++ 引擎回归能力建设,并不是一次“工具升级”,而是一场工程化治理:把经验变成流程、把自觉变成约束、把风险前移到上线之前,最终目标只有一个:让搜索引擎的每一次升级,都更可控、更可信。

往期回顾

1.得物社区搜推公式融合调参框架-加乘树3.0实战

2.深入剖析Spark UI界面:参数与界面详解|得物技术 

3.Sentinel Java客户端限流原理解析|得物技术

4.社区推荐重排技术:双阶段框架的实践与演进|得物技术

5.Flink ClickHouse Sink:生产级高可用写入方案|得物技术

文 /耿辉

关注得物技术,每周更新技术干货

要是觉得文章对你有帮助的话,欢迎评论转发点赞~

未经得物技术许可严禁转载,否则依法追究法律责任。

服务拆分之旅:测试过程全揭秘|得物技术

作者 得物技术
2026年2月5日 14:47

一、引言

代码越写越多怎么办?在线等挺急的! Bidding-interface服务代码库代码量已经达到100w行!!

Bidding-interface应用是出价域核心应用之一,主要面向B端商家。跟商家后台有关的出价功能都围绕其展开。是目前出价域代码量最多的服务。

随着出价业务最近几年来的快速发展,出价服务承接的流量虽然都是围绕卖家出价,但是已远远超过卖家出价功能范围。业务的快速迭代而频繁变更给出价核心链路高可用、高性能都带来了巨大的风险。

经总结有如下几个痛点:

  • 核心出价链路未隔离:

    出价链路各子业务模块间代码有不同程度的耦合,迭代开发可扩展性差,往往会侵入到出价主流程代码的改动。每个子模块缺乏独立的封装,而且存在大量重复的代码,每次业务规则调整,需要改动多处,容易出现漏改漏测的问题。

  • 大单体&功能模块定义混乱:

    历史原因上层业务层代码缺乏抽象,代码无法实现复用,需求开发代码量大,导致需求估时偏高,经常出现20+人日的大需求,需求开发中又写出大量重复代码,导致出价服务代码库快速膨胀,应用启动耗时过长,恶性循环。

  • B/C端链路未隔离:

    B端卖家出价链路流量与C端价格业务场景链路流量没有完全隔离,由于历史原因,有些B端出价链路接口代码还存在于price应用中,偶尔B端需求开发会对C端应用做代码变更。存在一定的代码管控和应用权限管控成本。

  • 发布效率影响:

    代码量庞大,导致编译速度缓慢。代码过多,类的依赖关系更为复杂,持续迭代逐步加大编译成本,随着持续迭代,新的代码逻辑 ,引入更多jar 依赖,间接导致项目部署时长变长蓝绿发布和紧急问题处理时长显著增加;同时由于编译与部署时间长,直接影响开发人员在日常迭代中的效率(自测,debug,部署)。

  • 业务抽象&分层不合理:

    历史原因出价基础能力领域不明确,出价底层和业务层分层模糊,业务层代码和出价底层代码耦合严重,出价底层能力缺乏抽象,上层业务扩展需求频繁改动出价底层能力代码。给出价核心链路代码质量把控带来较高的成本, 每次上线变更也带来一定的风险。

以上,对于Bidding服务的拆分和治理,已经箭在弦上不得不发。否则,持续的迭代会继续恶化服务的上述问题。

经过前期慎重的筹备,设计,排期,拆分,和测试。目前Bidding应用经过四期的拆分节奏,已经马上要接近尾声了。服务被拆分成三个全新的应用,目前在小流量灰度放量中。

本次拆分涉及:1000+Dubbo接口,300+个HTTP接口,200+ MQ消息,100+个TOC任务,10+个 DJob任务。

本人是出价域测试一枚,参与了一期-四期的拆分测试工作。

项目在全组研发+测试的ALL IN投入下,已接近尾声。值此之际输出一篇文章,从测试视角复盘下,Bidding服务的拆分与治理,也全过程揭秘下出价域内的拆分测试过程。

二、服务拆分的原则

首先,在细节性介绍Bidding拆分之前。先过大概过一下服务拆分原则:

  • 单一职责原则 (SRP):  每个服务应该只负责一项特定的业务功能,避免功能混杂。

  • 高内聚、低耦合:  服务内部高度内聚,服务之间松耦合,尽量减少服务之间的依赖关系。

  • 业务能力导向:  根据业务领域和功能边界进行服务拆分,确保每个服务都代表一个完整的业务能力。

拆分原则之下,还有不同的策略可以采纳:基于业务能力拆分、基于领域驱动设计 (DDD) 拆分、基于数据拆分等等。同时,拆分时应该注意:避免过度拆分、考虑服务之间的通信成本、设计合理的 API 接口。

服务拆分是微服务架构设计的关键步骤,需要根据具体的业务场景和团队情况进行综合考虑。合理的服务拆分可以提高系统的灵活性、可扩展性和可维护性,而不合理的服务拆分则会带来一系列问题。

三、Bidding服务拆分的设计

如引言介绍过。Bidding服务被拆分出三个新的应用,同时保留bidding应用本身。目前共拆分成四个应用:Bidding-foundtion,Bidding-interface,Bidding-operation和Bidding-biz。详情如下:

  • 出价基础服务-Bidding-foundation:

出价基础服务,对出价基础能力抽象,出价领域能力封装,基础能力沉淀。

  • 出价服务-Bidding-interfaces:

商家端出价,提供出价基础能力和出价工具,提供商家在各端出价链路能力,重点保障商家出价基础功能和出价体验。

  • 出价运营服务-Bidding-operation:

出价运营,重点支撑运营对出价业务相关规则的维护以及平台其他域业务变更对出价域数据变更的业务处理:

  1. 出价管理相关配置:出价规则配置、指定卖家规则管理、出价应急隐藏/下线管理工具等;
  2. 业务大任务:包括控价生效/失效,商研鉴别能力变更,商家直发资质变更,品牌方出价资质变更等大任务执行。
  • 业务扩展服务-Bidding-biz:

更多业务场景扩展,侧重业务场景的灵活扩展,可拆出的现有业务范围:国补采购单出价,空中成单业务,活动出价,直播出价,现订现采业务,预约抢购,新品上线预出价,入仓预出价。

应用拆分前后流量分布情况:

图片

四、Bidding拆分的节奏和目标收益

服务拆分是项大工程,对目前的线上质量存在极大的挑战。合理的排期和拆分计划是重点,可预期的收益目标是灵魂。

经过前期充分调研和规划。Bidding拆分被分成了四期,每期推进一个新应用。并按如下六大步进行:

图片

Bidding拆分目标

  • 解决Bidding大单体问题: 对Bidding应用进行合理规划,完成代码和应用拆分,解决一直以来Bidding大单体提供的服务多而混乱,维护成本高,应用编译部署慢,发布效率低等等问题。
  • 核心链路隔离&提升稳定性: 明确出价基础能力,对出价基础能力下沉,出价基础能力代码拆分出独立的代码库,并且部署在独立的新应用中,实现出价核心链路隔离,提升出价核心链路稳定性。
  • 提升迭代需求开发效率: 完成业务层代码抽象,业务层做组件化配置化,实现业务层抽象复用,降低版本迭代需求开发成本。
  • 实现出价业务应用合理规划: 各服务定位、职能明确,分层抽象合理,更好服务于企/个商家、不同业务线运营等不同角色业务推进。

预期的拆分收益

  • 出价服务应用结构优化:

    完成对Bidding大单体应用合理规划拆分,向下沉淀出出价基础服务应用层,降低出价基础能力维护成功;向上抽离出业务扩展应用层,能够实现上层业务的灵活扩展;同时把面向平台运营和面向卖家出价的能力独立维护;在代码库和应用层面隔离,有效减少版本迭代业务需求开发变更对应用的影响面,降低应用和代码库的维护成本。

  • 完成业务层整体设计,业务层抽象复用,业务层做组件化配置化,提升版本迭代需求开发效率,降低版本迭代需求开发成本:

    按业务类型对业务代码进行分类,统一设计方案,提高代码复用性,支持业务场景变化时快速扩展,以引导降价为例,当有类似降价换流量/降价换销量新的降价场景需求时,可以快速上线,类似情况每个需求可以减少10-20人日开发工作量。

  • 代码质量提升 :

    通过拆分出价基础服务和对出价流程代码做重构,将出价基础底层能力代码与上层业务层代码解耦,降低代码复杂度,降低代码冲突和维护难度,从而提高整体代码质量和可维护性。

  • 开发效率提升 :

    1. 缩短应用部署时间: 治理后的出价服务将加快编译和部署速度,缩短Bidding-interfaces应用发布(编译+部署)时间 由12分钟降低到6分钟,从而显著提升开发人员的工作效率,减少自测、调试和部署所需的时间。以Bidding服务T1环境目前一个月编译部署至少1500次计算,每个月可以节约150h应用发布时间。
    2. 提升问题定位效率: 出价基础服务层与上层业务逻辑层代码库&应用分开后,排查定位开发过程中遇到的问题和线上问题时可以有效缩小代码范围,快速定位问题代码位置。

五、测试计划设计

服务拆分的前期,研发团队投入了大量的心血。现在代码终于提测了,进入我们的测试环节:

为了能收获更好的质量效果,同时也为了不同研发、测试同学的分工。我们需要细化到最细粒度,即接口维度整理出一份详细的文档。基于此文档的基础,我们确定工作量和人员排期:

如本迭代,我们投入4位研发同学,2位测试同学。完成该200个Dubbo接口和100个HTTP接口,以及20个Topic迁移。对应的提测接口,标记上负责的研发、测试、测试进度、接口详细信息等内容。

基于该文档的基础上,我们的工作清晰而明确。一个大型的服务拆分,也变成了一步一步的里程碑任务。

接下来给大家看一下,关于Bidding拆分。我们团队整体的测试计划,我们一共设计了五道流程。

  • 第一关:自测接口对比:

    每批次拆分接口提测前,研发同学必须完成接口自测。基于新旧接口返回结果对比验证。验证通过后标记在文档中,再进入测试流程。

    对于拆分项目,自测卡的相对更加严格。由于仅做接口迁移,逻辑无变更,自测也更加容易开展。由研发同学做好接口自测,可以避免提测后新接口不通的低级问题。提高项目进度。

    在这个环节中。偶尔遇见自测不充分、新接口参数传丢、新Topic未配置等问题。(三期、四期测试中,我们加强了对研发自测的要求)。

  • 第二关:测试功能回归

    这一步骤基本属于测试的人工验证,同时重点需关注写接口数据验证。

    回归时要测的细致。每个接口,测试同学进行合理评估。尽量针对接口主流程,进行细致功能回归。由于迁移的接口数量多,历史逻辑重。一方面在接口测试任务分配时,要尽量选择对该业务熟悉的同学。另一方面,承接的同学也有做好历史逻辑梳理。尽量不要产生漏测造成的问题。

    该步骤测出的问题五花八门。另外由于Bidding拆分成多个新服务。两个新服务经常彼此间调用会出现问题。比如二期Bidding-foundation迁移完成后,Bidding-operation的接口在迁移时,依赖接口需要从Bidding替换成foundation的接口。

    灰度打开情况下,调用新接口报错仍然走老逻辑。(测试时,需要关注trace中是否走了新应用)。

  • 第三关:自动化用例

    出价域内沉淀了比较完善的接口自动化用例。在人工测试时,测试同学可以借助自动化能力,完成对迁移接口的回归功能验证。

    同时在发布前天,组内会特地多跑一轮全量自动化。一次是迁移接口开关全部打开,一次是迁移接口开关全部关闭即正常的自动化回归。然后全员进行排错。

    全量的自动化用例执行,对迁移接口问题拦截,有比较好的效果。因为会有一些功能点,人工测试时关联功能未考虑到,但在接口自动化覆盖下无所遁形。

  • 第四关:流量回放

    在拆分接口开关打开的情况下,在预发环境进行流量回放。

    线上录制流量的数据往往更加复杂,经常会测出一些意料之外的问题。

    迭代过程中,我们组内仍然会在沿用两次回放。迁移接口开关打开后回放一次,开关关闭后回放一次。(跟发布配置保持一致)。

  • 第五关:灰度过程中,关闭接口开关,功能回滚

    为保证线上生产质量,在迁移接口小流量灰度过程中。我们持续监测线上问题告警群。

    以上,就是出价域测试团队,针对服务拆分的测试流程。同时遵循可回滚的发布标准,拆分接口做了非常完善的灰度功能。下一段落进行介绍。

六、各流量类型灰度切量方案

出价流程切新应用灰度控制从几个维度控制:总开关,出价类型范围,channel范围,source范围,bidSource范围,uid白名单&uid百分比(0-10000):

  • 灰度策略
  • 支持 接口维度 ,按照百分比进行灰度切流;

  • 支持一键回切;

Dubbo接口、HTTP接口、TOC任务迁移、DMQ消息迁移分别配有不同的灰度策略。

七、结语

拆分的过程中,伴随着很多迭代需求的开发。为了提高迁移效率,我们会在需求排期后,并行处理迭代功能相关的接口,把服务拆分和迭代需求一起完成掉。

目前,我们的拆分已经进入尾声。迭代发布后,整体的技术项目就结束了。灰度节奏在按预期节奏进行~

值得一提的是,目前我们的流量迁移仍处于第一阶段,即拆分应用出价域内灰度迁移,上游不感知。目前所有的流量仍然通过bidding服务接口进行转发。后续第二阶段,灰度验证完成后,需要进行上游接口替换,流量直接请求拆分后的应用。

往期回顾

1.大模型网关:大模型时代的智能交通枢纽|得物技术

2.从“人治”到“机治”:得物离线数仓发布流水线质量门禁实践

3.AI编程实践:从Claude Code实践到团队协作的优化思考|得物技术

4.入选AAAI-PerFM|得物社区推荐之基于大语言模型的新颖性推荐算法

5.Galaxy比数平台功能介绍及实现原理|得物技术

文 /寇森

关注得物技术,每周一、三更新技术干货

要是觉得文章对你有帮助的话,欢迎评论转发点赞~

未经得物技术许可严禁转载,否则依法追究法律责任。

❌
❌