普通视图

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

作为前端你还不会 Playwright 进行单元测试吗?

作者 颜正义
2026年3月24日 17:29

Playwright 测试配置与使用指南

Playwright 是一个现代化的 Web 应用端到端测试框架,支持 Chromium、WebKit 和 Firefox,可以在 Windows、Linux 和 macOS 上运行。

安装

初始化项目

npm init playwright@latest

安装过程中会询问:

  • TypeScript 或 JavaScript(默认:TypeScript)
  • 测试文件夹名称(默认:tests,如果 tests 已存在则用 e2e)
  • 是否添加 GitHub Actions 工作流(推荐)
  • 是否安装 Playwright 浏览器(默认:是)

系统要求

  • Node.js:最新 20.x、22.x 或 24.x
  • macOS 14 (Ventura) 或更高版本
  • Debian 12/13,Ubuntu 22.04/24.04

项目结构

安装后生成以下结构:

playwright.config.ts    # 测试配置
package.json
package-lock.json
tests/
  example.spec.ts      # 示例测试

编写测试

基础测试示例

import { test, expect } from '@playwright/test';

test('基础测试'async ({ page }) => {
  await page.goto('https://example.com');
  await expect(page).toHaveTitle(/Example Domain/);
});

使用 Locators

test('表单提交测试', async ({ page }) => {
  await page.goto('https://example.com/form');
  
  // 填写表单
  await page.fill('input[name="email"]', 'test@example.com');
  await page.fill('input[name="password"]', 'password123');
  
  // 点击提交按钮
  await page.click('button[type="submit"]');
  
  // 验证成功跳转
  await expect(page).toHaveURL(/.*dashboard/);
});

断言

Playwright 提供了丰富的断言方法:

// 页面状态断言
await expect(page).toHaveTitle(/Page Title/);
await expect(page).toHaveURL('https://example.com');

// 元素可见性断言
await expect(page.locator('.header')).toBeVisible();
await expect(page.locator('.loading')).toBeHidden();

// 文本内容断言
await expect(page.locator('h1')).toHaveText('Welcome');
await expect(page.locator('.count')).toContainText('5');

// 属性断言
await expect(page.locator('input')).toHaveAttribute('type', 'email');

运行测试

运行所有测试

npx playwright test

运行单个测试文件

npx playwright test tests/example.spec.ts

有头模式运行(显示浏览器窗口)

npx playwright test --headed

运行特定浏览器

npx playwright test --project=chromium
npx playwright test --project=webkit

UI 模式

UI 模式提供监视模式、实时步骤视图、时间旅行调试等:

npx playwright test --ui

HTML 测试报告

测试运行后,HTML Reporter 提供可过滤的仪表板,按浏览器、通过、失败、跳过、不稳定等分类:

npx playwright show-report

配置文件

playwright.config.ts 基础配置

import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  // 测试文件夹
  testDir'./tests',
  
  // 完全并行运行测试
  fullyParalleltrue,
  
  // CI 中失败时重试
  forbidOnly: !!process.env.CI,
  
  // CI 中重试次数
  retries: process.env.CI ? 2 0,
  
  // 并行工作进程数
  workers: process.env.CI ? 1 : undefined,
  
  // 报告器
  reporter'html',
  
  use: {
    // 基础 URL
    baseURL'http://localhost:3000',
    
    // 追踪重试失败的测试
    trace'on-first-retry',
  },

  // 配置不同浏览器
  projects: [
    {
      name'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
    {
      name'firefox',
      use: { ...devices['Desktop Firefox'] },
    },
    {
      name'webkit',
      use: { ...devices['Desktop Safari'] },
    },
  ],
});

高级功能

测试夹具(Fixtures)

import { test as base } from '@playwright/test';

// 自定义 fixture
type MyFixtures = {
  authenticatedPage: Page;
};

const test = base.extend<MyFixtures>({
  authenticatedPage: async ({ page }, use) => {
    // 登录
    await page.goto('/login');
    await page.fill('input[name="email"]''test@example.com');
    await page.fill('input[name="password"]''password');
    await page.click('button[type="submit"]');
    
    // 使用已认证的页面
    await use(page);
  },
});

test('使用自定义 fixture'async ({ authenticatedPage }) => {
  await authenticatedPage.goto('/dashboard');
  await expect(authenticatedPage).toHaveURL(/.*dashboard/);
});

代码生成(Codegen)

Playwright Codegen 可以通过录制用户操作生成测试代码:

npx playwright codegen https://example.com

追踪查看器

查看测试失败的详细追踪:

npx playwright show-trace trace.zip

更新 Playwright

npm install -@playwright/test@latest
npx playwright install --with-deps

检查版本:

npx playwright --version

最佳实践

  1. 使用 Locators 而不是 Selectors

    // ✅ 推荐
    await page.locator('button').click();
    
    // ❌ 不推荐
    await page.$('button').click();
    
  2. 使用 Playwright 的等待机制

    // 自动等待元素可交互
    await page.click('button');
    
    // 显式等待
    await page.waitForSelector('.loaded');
    
  3. 使用页面对象模式(Page Object Model)

    class LoginPage {
      constructor(private page: Page) {}
      
      async login(email: string, password: string) {
        await this.page.fill('input[name="email"]', email);
        await this.page.fill('input[name="password"]', password);
        await this.page.click('button[type="submit"]');
      }
    }
    
  4. 隔离测试数据

    test.use({ storageState: 'auth.json' });
    
    test('需要登录的测试'async ({ page }) => {
      // 测试逻辑
    });
    

参考资料

大型 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比数平台功能介绍及实现原理|得物技术

文 /寇森

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

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

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

❌
❌