用 mock 把 ES 单元测试@elastic/elasticsearch-mock 上手

发布于:2025-08-15 ⋅ 阅读:(12) ⋅ 点赞:(0)

一、为什么“单元测 ES”这么别扭?

测试 ES 代码时,最直觉的做法是连真集群做集成测试(Docker 起个 ES),但:

  • 启动 & 数据装填慢,不利于并行
  • 网络/磁盘抖动影响稳定性
  • 很多用例其实只想验证我写的逻辑,不是验证 ES 自己。

单元测试更适合快速回归。思路是:把客户端的 HTTP 层换成 mock,其余组件照常运行。这就是官方 mock 库 @elastic/elasticsearch-mock 的用武之地。

二、官方 JS 客户端的内部构件(理解这一张图,mock 不会走偏)

  • API layer:所有可调用的 ES API。
  • Transport:请求的准备、重试、sniff 等策略。
  • ConnectionPool:管理多个节点。
  • Serializer:JSON/Ndjson 序列化。
  • Connection:真正发 HTTP 的地方。

最佳 mock 点:Connection
我们只替换 Connection,其它(API、Transport、池、序列化)保持真实行为,既快又贴近真实调用路径。

三、安装与最小示例

npm i -D @elastic/elasticsearch-mock
npm i @elastic/elasticsearch
// test/info.mock.test.js  —— 最小可运行示例(Node >= 16)
const { Client } = require('@elastic/elasticsearch')
const Mock = require('@elastic/elasticsearch-mock')

const mock = new Mock()
const client = new Client({
  cloud: { id: '<cloud-id>' },
  auth: { apiKey: 'base64EncodedKey' },
  // 关键:用 mock 替换 Connection
  Connection: mock.getConnection()
})

// 定义一个最简单的路由:GET /
mock.add({ method: 'GET', path: '/' }, () => ({ status: 'ok' }))

;(async () => {
  const res = await client.info()
  console.log(res) // => { status: 'ok' }
})()

要点

  • mock.getConnection() 给出一个“假 HTTP”连接对象;
  • 之后所有请求都不会真正出网,速度与稳定性拉满

四、匹配策略:宽松 vs 严格

同一路径,你可以按需要定义多条 mock,越具体的匹配优先生效

// 宽松:只看 method + path
mock.add({
  method: 'POST',
  path: '/indexName/_search'
}, () => ({
  hits: { total: { value: 1, relation: 'eq' }, hits: [{ _source: { baz: 'faz' } }] }
}))

// 严格:连 body 也要完全匹配(深度相等)
mock.add({
  method: 'POST',
  path: '/indexName/_search',
  body: { query: { match: { foo: 'bar' } } }
}, () => ({
  hits: { total: { value: 0, relation: 'eq' }, hits: [] }
}))

规则:更具体(带 body 的)覆盖更宽松的。这样你能同时覆盖“默认搜索”和“特定查询”的两种分支。

五、动态路径与通配

// 动态段:/:index/_count
mock.add({ method: 'GET', path: '/:index/_count' }, () => ({ count: 42 }))

await client.count({ index: 'foo' }) // { count: 42 }
await client.count({ index: 'bar' }) // { count: 42 }
// 也支持通配符(如需要匹配一批相近路径)

六、让你的代码“经得起风浪”

编写“随机失败/间歇性 500”的用例,检验重试容错是否健壮。

const { Client, errors } = require('@elastic/elasticsearch')

mock.add({ method: 'GET', path: '/:index/_count' }, () => {
  if (Math.random() > 0.8) {
    // 方式 A(简单):直接抛 JS Error(Transport 会当作失败)
    const err = new Error('boom')
    err.statusCode = 500
    throw err

    // 方式 B(更贴近客户端):抛客户端的 ResponseError(不同版本构造略有差异)
    // throw new errors.ResponseError({ body: { error: 'fail' }, statusCode: 500 })
  }
  return { count: 42 }
})

提示:不同版本的 ResponseError 构造方式可能略有差异;如果不确定,抛普通 Error + 设置 statusCode 也能覆盖你的重试/分支逻辑。

七、在 AVA 里写测试(官方示例里的同款框架)

npm i -D ava

// test/search.ava.test.js
import test from 'ava'
import { Client } from '@elastic/elasticsearch'
import Mock from '@elastic/elasticsearch-mock'

test('search: 默认与特定查询两条分支', async t => {
  const mock = new Mock()
  const client = new Client({ node: 'http://unit.test', Connection: mock.getConnection() })

  // 宽松分支
  mock.add({ method: 'POST', path: '/indexName/_search' }, () => ({
    hits: { total: { value: 1, relation: 'eq' }, hits: [{ _source: { baz: 'faz' } }] }
  }))

  // 严格分支(匹配 body)
  mock.add({
    method: 'POST',
    path: '/indexName/_search',
    body: { query: { match: { foo: 'bar' } } }
  }, () => ({ hits: { total: { value: 0, relation: 'eq' }, hits: [] } }))

  // 默认搜索
  const a = await client.search({ index: 'indexName', query: { match_all: {} } })
  t.is(a.hits.hits[0]._source.baz, 'faz')

  // 特定查询
  const b = await client.search({ index: 'indexName', query: { match: { foo: 'bar' } } })
  t.is(b.hits.total.value, 0)
})

package.json 加脚本:

{
  "type": "module",
  "scripts": { "test": "ava" }
}

八、在 Jest 里写测试(更常用)

npm i -D jest @types/jest(TS 需要再装 ts-jest)

// test/count.jest.test.js
const { Client } = require('@elastic/elasticsearch')
const Mock = require('@elastic/elasticsearch-mock')

describe('count API', () => {
  test('动态路径 & 固定返回', async () => {
    const mock = new Mock()
    const client = new Client({ node: 'http://unit.test', Connection: mock.getConnection() })

    mock.add({ method: 'GET', path: '/:index/_count' }, () => ({ count: 42 }))

    await expect(client.count({ index: 'alpha' })).resolves.toEqual({ count: 42 })
    await expect(client.count({ index: 'beta'  })).resolves.toEqual({ count: 42 })
  })
})

package.json

{
  "scripts": { "test": "jest" }
}

九、TypeScript 友好写法

// test/info.ts
import { Client } from '@elastic/elasticsearch'
import Mock from '@elastic/elasticsearch-mock'

const mock = new Mock()
const client = new Client({
  node: 'http://unit.test',
  Connection: mock.getConnection()
})

mock.add({ method: 'GET', path: '/' }, () => ({ status: 'ok' }))

export async function getInfo() {
  return client.info()
}

tsconfig.json:确保 "moduleResolution": "node", "esModuleInterop": true,Jest 用 ts-jest 即可。

十、进阶手法

1) 校验“我的代码发出了期望的请求”

mock 的处理函数里可以检查入参(如 body 中的 query/分页条件),从而断言业务层是否正确组织了请求。

mock.add({ method: 'POST', path: '/goods/_search' }, (params) => {
  // params.body 就是请求体
  if (params?.body?.size !== 10) throw new Error('page size must be 10')
  return { hits: { total: { value: 0, relation: 'eq' }, hits: [] } }
})

2) 顺序响应(模拟滚动/重试)

同一路由注册多次,按注册顺序命中,便于模拟“第一次失败、第二次成功”的重试逻辑。

mock.add({ method: 'GET', path: '/:index/_count' }, () => { throw Object.assign(new Error('500'), { statusCode: 500 }) })
mock.add({ method: 'GET', path: '/:index/_count' }, () => ({ count: 42 }))

3) 与真实集成测试的分层配合

  • 单元测试:mock Connection,覆盖边界条件/重试/错误处理分支。
  • 集成测试:Docker 起一个真 ES(或 Testcontainers),验证 mapping、脚本字段、聚合等“ES 自身语义”。

十一、最佳实践与避坑清单

  • 每个测试用例新建一个 mock 实例:避免跨用例状态污染。
  • 优先写“宽松”匹配,再补“严格”匹配:覆盖默认路径后,针对关键分支加严格体检。
  • 特意写失败用例:5xx、超时、断线,确保重试/回退策略真的在跑。
  • 控制随机性:用假随机或 seed 固定,避免“随机失败”导致测试不稳定。
  • 特征:ES 版本差异:个别客户端版本对错误对象/响应包装略有差异;若你要断言错误类型,建议使用客户端自带的 errors.*(或直接断言 statusCode / name / message)。

十二、参考项目骨架(可抄)

your-project/
├─ src/
│  └─ search.js
├─ test/
│  ├─ info.mock.test.js
│  ├─ search.ava.test.js
│  └─ count.jest.test.js
├─ package.json
└─ tsconfig.json (若用 TS)

package.json(混合 AVA/Jest 也没问题):

{
  "type": "module",
  "scripts": {
    "test": "jest && ava"
  },
  "devDependencies": {
    "@elastic/elasticsearch-mock": "^x.y.z",
    "ava": "^x.y.z",
    "jest": "^x.y.z"
  },
  "dependencies": {
    "@elastic/elasticsearch": "^x.y.z"
  }
}

十三、结语

  • 把 Connection 换成 mock,你的测试就从“重集成”回到“轻单元”,速度与稳定性双赢;
  • 宽松 + 严格匹配动态路径/通配失败注入,能覆盖绝大多数线上分支;
  • 单测用 mock,回归再配一小撮 Docker 集成测试做端到端兜底,是性价比最高的组合。

如果你愿意,我可以把上面 AVA/Jest/TS 的样例整理成一个 examples/ 目录(含 package.json、脚手架与说明),你直接 npm test 就能跑。需要我打包一下吗?


网站公告

今日签到

点亮在社区的每一天
去签到